feat(files): удаление файла с ограничениями, expires_at у объекта S3, FILE_DELETE

DELETE /files/{file_id} (DOCUMENT_EDIT, policy): блокировки «задание → файл → документы», проверки после
блокировок (409 FILE_HAS_CONFIRMED_DOCUMENTS, FILE_SUPERSEDES_OTHERS), удаление osv_entry/document/recognition_job/
stored_file (разрыв FK-цикла), expires_at у объектов S3 файла и результатов, аудит FILE_DELETE с before_value.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 23:43:38 +04:00
parent e2fde90293
commit 15021735e9
7 changed files with 497 additions and 10 deletions

View File

@ -1,7 +1,9 @@
"""`GET /legal-entities/{legal_entity_id}/files`, `GET /files/{file_id}` — файлы организации и карточка файла
(spec documents-api «Файлы организации и карточка файла»; design D-11).
(spec documents-api «Файлы организации и карточка файла»; design D-11); `DELETE /files/{file_id}` — удаление файла
(spec primary-files «Удаление файла»; design D-10).
Право `DOCUMENT_VIEW` проверяется до видимости; ЮЛ или файл вне видимости — тот же 404, что несуществующий.
Право `DOCUMENT_VIEW` (удаление — `DOCUMENT_EDIT`) проверяется до видимости; ЮЛ или файл вне видимости — тот же 404,
что несуществующий.
Список — по убыванию `uploaded_at`, заменённые (`SUPERSEDED`) — только при `include_superseded=true`.
"""
@ -18,9 +20,18 @@ from src.schemas.files import FileOut
router = APIRouter()
VIEW = [Depends(require("DOCUMENT_VIEW"))]
EDIT = [Depends(require("DOCUMENT_EDIT"))]
NOT_FOUND: dict[int | str, dict[str, Any]] = {
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}
}
DELETE_RESPONSES: dict[int | str, dict[str, Any]] = {
**NOT_FOUND,
status.HTTP_409_CONFLICT: {
"model": ErrorDetail,
"description": "FILE_HAS_CONFIRMED_DOCUMENTS (есть подтверждённый документ) или FILE_SUPERSEDES_OTHERS "
"(файл заменил более ранние версии)",
},
}
class FileListQuery(PageParams):
@ -53,3 +64,15 @@ async def list_files(
async def get_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> FileOut:
"""Карточка файла — как элемент списка файлов организации."""
return await service.get_file(file_id)
@router.delete(
"/files/{file_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=EDIT,
responses=DELETE_RESPONSES,
summary="Удалить файл",
)
async def delete_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> None:
"""Удаляет строки ОСВ, документы, задания и файл; объекту S3 ставится `expires_at`; аудит `FILE_DELETE`."""
await service.delete(file_id)

View File

@ -7,17 +7,18 @@ SQL без бизнес-правил; видимость — через ЮЛ-в
Функции не делают `commit`: транзакцией запроса управляет `SessionDep`.
"""
import datetime
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import Select, func, select
from sqlalchemy import Select, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.policy import VisibilityScope, legal_entities_visible
from src.models.directories import LegalEntity
from src.models.enums import FileStatus
from src.models.files import Document, RecognitionJob, StoredFile
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry
@ -127,3 +128,53 @@ async def lock_document(session: AsyncSession, document_id: uuid.UUID) -> Docume
select(Document).where(Document.id == document_id).with_for_update().execution_options(populate_existing=True)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def lock_jobs(session: AsyncSession, file_id: uuid.UUID) -> list[RecognitionJob]:
"""`SELECT recognition_job … FOR UPDATE` всех заданий файла (по `id`) с перечитыванием. Порядок блокировок обмена и
маппинга — «задание → файл», поэтому вызывать до `lock_file`."""
stmt = (
select(RecognitionJob)
.where(RecognitionJob.file_id == file_id)
.order_by(RecognitionJob.id)
.with_for_update()
.execution_options(populate_existing=True)
)
return list((await session.execute(stmt)).scalars().all())
async def lock_file_documents(session: AsyncSession, file_id: uuid.UUID) -> list[Document]:
"""`SELECT document … FOR UPDATE` всех документов файла (по `id`) с перечитыванием; после `lock_file`."""
stmt = (
select(Document)
.where(Document.stored_file_id == file_id)
.order_by(Document.id)
.with_for_update()
.execution_options(populate_existing=True)
)
return list((await session.execute(stmt)).scalars().all())
async def supersedes_others(session: AsyncSession, file_id: uuid.UUID) -> bool:
"""На файл ссылаются `superseded_by_file_id` других файлов (файл заменил другие версии)."""
stmt = select(select(StoredFile.id).where(StoredFile.superseded_by_file_id == file_id).exists())
return bool((await session.execute(stmt)).scalar_one())
async def delete_file_rows(session: AsyncSession, file: StoredFile) -> None:
"""Удаляет строки ОСВ, документы, все задания и сам файл. FK-цикл `stored_file.current_recognition_job_id` ↔
`recognition_job.file_id` разрывается обнулением ссылки до удаления заданий. Блокировки — у вызывающего."""
file_id = file.id
file.current_recognition_job_id = None
await session.flush()
document_ids = select(Document.id).where(Document.stored_file_id == file_id)
await session.execute(delete(OsvEntry).where(OsvEntry.document_id.in_(document_ids)))
await session.execute(delete(Document).where(Document.stored_file_id == file_id))
await session.execute(delete(RecognitionJob).where(RecognitionJob.file_id == file_id))
await session.execute(delete(StoredFile).where(StoredFile.id == file_id))
async def expire_s3_objects(session: AsyncSession, object_ids: Sequence[uuid.UUID], *, now: datetime.datetime) -> None:
"""`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22)."""
if object_ids:
await session.execute(update(S3Object).where(S3Object.id.in_(object_ids)).values(expires_at=now))

View File

@ -35,7 +35,14 @@ from src.core.storage import ObjectStorage
from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file
from src.domain.policy import VisibilityScope
from src.models.directories import LegalEntity
from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionStatus, S3BucketRole
from src.models.enums import (
AuditAction,
AuditSection,
DocumentStatus,
FileStatus,
RecognitionStatus,
S3BucketRole,
)
from src.models.files import RecognitionJob, S3Object, StoredFile
from src.repositories import directories as directories_repo
from src.repositories import files as files_repo
@ -51,6 +58,8 @@ FILE_TOO_LARGE = "FILE_TOO_LARGE"
STORAGE_UNAVAILABLE = "STORAGE_UNAVAILABLE"
STORAGE_UNAVAILABLE_MESSAGE = "хранилище файлов недоступно"
FILE_NOT_FOUND = "файл не найден" # одинаково для чужого и несуществующего
FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS"
FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS"
class StorageUnavailableError(DomainError):
@ -155,6 +164,48 @@ class PrimaryFilesService:
(card,) = await self._cards([file])
return card
async def delete(self, file_id: uuid.UUID) -> None:
"""Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10).
Порядок блокировок — как в обмене и маппинге: задания файла `FOR UPDATE`, затем `stored_file`, затем документы
(confirm берёт «файл → документ», автозамена — advisory → старые файлы по id). Состояние перечитывается
под блокировками, и 409 проверяются после них: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ `CONFIRMED`,
`FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка версий не рвётся). Файл,
исчезнувший, пока ждали блокировку, — 404.
Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий
остаются, им ставится `expires_at = now`. ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает
ретеншн бакета. `before_value` аудита — карточка файла."""
visible = await files_repo.get_file(self._session, self._scope, file_id)
if visible is None:
raise NotFoundError(FILE_NOT_FOUND)
jobs = await files_repo.lock_jobs(self._session, file_id)
file = await files_repo.lock_file(self._session, file_id)
if file is None: # удалён параллельно, пока ждали блокировку
raise NotFoundError(FILE_NOT_FOUND)
documents = await files_repo.lock_file_documents(self._session, file_id)
if any(document.status is DocumentStatus.CONFIRMED for document in documents):
raise ConflictError(
FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть подтверждённые документы"
)
if await files_repo.supersedes_others(self._session, file_id):
raise ConflictError(
FILE_SUPERSEDES_OTHERS,
"файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется",
)
current = next((job for job in jobs if job.id == file.current_recognition_job_id), None)
before = file_out(file, current, documents).model_dump(mode="json")
object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)]
await files_repo.delete_file_rows(self._session, file)
await files_repo.expire_s3_objects(self._session, object_ids, now=utc_now())
await self._audit.record(
action=AuditAction.FILE_DELETE,
section=AuditSection.PRIMARY_DOCUMENTS,
object_type=OBJECT_TYPE,
object_id=file_id,
before_value=before,
)
async def _cards(self, files: list[StoredFile]) -> list[FileOut]:
jobs = await files_repo.current_jobs(self._session, files)
documents = await files_repo.documents_by_file(self._session, [file.id for file in files])

View File

@ -0,0 +1,362 @@
"""Удаление файла `DELETE /files/{file_id}` (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`;
design D-10).
Основные сценарии — поверх фикстуры `app` (транзакция теста). Гонки с подтверждением и циклом маппинга — на
`committed_*` (временная БД, сессия запроса как в проде).
"""
import asyncio
import json
import uuid
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.core.config import get_settings
from src.models.audit_ex import AuditEvent
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, RecognitionStatus
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry
from src.services.result_mapping import process_once
from tests.conftest import AuthClientFactory, FakeStorage
from tests.factories import (
make_company_group,
make_document,
make_entries_from_result,
make_legal_entity,
make_result_object,
make_stored_file,
)
HTTP_NO_CONTENT = 204
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_CONFLICT = 409
KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT
RKA = "rka@armka.local" # Ростовский филиал, только DOCUMENT_VIEW
CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал
ROSTOV = "Ростовский региональный филиал"
MINIMAL_RESULT = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-minimal.json"
LOCK_WAIT_SECONDS = 10.0
pytestmark = pytest.mark.integration
async def _rostov_entity(session: AsyncSession, name: str = "ООО Удаление") -> LegalEntity:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {name}", office=office)
return await make_legal_entity(session, name=name, group=group)
def _minimal() -> dict[str, Any]:
return json.loads(MINIMAL_RESULT.read_text("utf-8"))
async def _count(session: AsyncSession, model: type, *where: Any) -> int:
return (await session.execute(select(func.count()).select_from(model).where(*where))).scalar_one()
async def _delete_events(session: AsyncSession) -> list[AuditEvent]:
rows = await session.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_DELETE))
return list(rows.scalars())
# --- успешное удаление ----------------------------------------------------------------------------------------------
async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""«Удаление ошибочного файла»: 204, записей нет, `expires_at` у объектов, аудит с карточкой в `before_value`."""
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.error
)
job.error_code = "PARSE_ERROR"
result_object = await make_result_object(seeded, job=job, storage=FakeStorage(), result={})
file_id, job_id, s3_id = file.id, job.id, file.s3_object_id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
assert response.content == b""
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0
assert await _count(seeded, RecognitionJob, RecognitionJob.file_id == file_id) == 0
for object_id in (s3_id, result_object.id): # строки s3_object остаются, им ставится expires_at
s3 = await seeded.get(S3Object, object_id, populate_existing=True)
assert s3 is not None
assert s3.expires_at is not None
[event] = await _delete_events(seeded)
assert event.section is AuditSection.PRIMARY_DOCUMENTS
assert (event.object_type, event.object_id) == ("stored_file", file_id)
assert event.user_id is not None
assert event.before_value is not None
assert event.before_value["id"] == str(file_id)
assert event.before_value["status"] == "ERROR"
assert event.before_value["job"]["id"] == str(job_id)
assert event.before_value["job"]["error_code"] == "PARSE_ERROR"
assert event.after_value is None
async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл."""
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
documents = await make_entries_from_result(seeded, file, job, _minimal())
assert documents
entry_count = await _count(seeded, OsvEntry, OsvEntry.recognition_job_id == job.id)
assert entry_count > 0
file_id, job_id = file.id, job.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
assert await _count(seeded, OsvEntry, OsvEntry.recognition_job_id == job_id) == 0
assert await _count(seeded, Document, Document.stored_file_id == file_id) == 0
assert await _count(seeded, RecognitionJob, RecognitionJob.id == job_id) == 0
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0
[event] = await _delete_events(seeded)
assert event.before_value is not None
assert [item["id"] for item in event.before_value["documents"]] == [str(d.id) for d in documents]
@pytest.mark.parametrize("status", [FileStatus.UPLOADED, FileStatus.PROCESSING, FileStatus.SUPERSEDED])
async def test_delete_file_in_other_statuses(
seeded: AsyncSession, auth_client: AuthClientFactory, status: FileStatus
) -> None:
"""Файл в `UPLOADED`/`PROCESSING` и `SUPERSEDED` (ни на что не влияет) удаляется."""
entity = await _rostov_entity(seeded)
file, _ = await make_stored_file(seeded, entity=entity, status=status)
file_id = file.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0
async def test_delete_superseded_file_keeps_new_one(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
new, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED)
old, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED)
old.superseded_by_file_id = new.id
await seeded.flush()
old_id, new_id = old.id, new.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{old_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
assert await _count(seeded, StoredFile, StoredFile.id == new_id) == 1
# --- отказы ---------------------------------------------------------------------------------------------------------
async def test_confirmed_document_blocks_delete(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.NEEDS_CHECK)
await make_document(seeded, file=file, job=job, account="62", status=DocumentStatus.CONFIRMED)
file_id, job_id = file.id, job.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS"
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1
assert await _count(seeded, RecognitionJob, RecognitionJob.id == job_id) == 1
assert await _count(seeded, Document, Document.stored_file_id == file_id) == 2 # noqa: PLR2004
assert await _delete_events(seeded) == []
s3 = await seeded.get(S3Object, file.s3_object_id, populate_existing=True)
assert s3 is not None
assert s3.expires_at is None
async def test_file_that_superseded_others_is_not_deletable(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
entity = await _rostov_entity(seeded)
new, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED)
old, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED)
old.superseded_by_file_id = new.id
await seeded.flush()
new_id = new.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{new_id}")
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "FILE_SUPERSEDES_OTHERS"
assert await _count(seeded, StoredFile, StoredFile.id == new_id) == 1
assert await _delete_events(seeded) == []
async def test_foreign_and_missing_file_are_same_404(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR)
file_id = file.id
client = await auth_client(CLIENT_MANAGER) # Краснодарский филиал: ЮЛ Ростова не видит, DOCUMENT_EDIT есть
foreign = await client.delete(f"/files/{file_id}")
missing = await client.delete(f"/files/{uuid.uuid4()}")
assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND
assert foreign.json() == missing.json()
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1
assert await _delete_events(seeded) == []
async def test_delete_requires_document_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR)
file_id = file.id
client = await auth_client(RKA) # свой филиал, только DOCUMENT_VIEW
assert (await client.get(f"/files/{file_id}")).status_code == 200 # noqa: PLR2004
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_FORBIDDEN
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1
async def test_repeated_delete_and_read_after_delete_are_404(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
entity = await _rostov_entity(seeded)
file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR)
file_id = file.id
client = await auth_client(KA_RF)
assert (await client.delete(f"/files/{file_id}")).status_code == HTTP_NO_CONTENT
again = await client.delete(f"/files/{file_id}")
read = await client.get(f"/files/{file_id}")
assert again.status_code == read.status_code == HTTP_NOT_FOUND
assert len(await _delete_events(seeded)) == 1
# --- гонки (временная БД) -------------------------------------------------------------------------------------------
async def _committed_file(
maker: async_sessionmaker[AsyncSession], storage: FakeStorage | None = None
) -> tuple[uuid.UUID, uuid.UUID, uuid.UUID]:
"""Файл `PROCESSING` ЮЛ Ростовского филиала с документом `NEEDS_CHECK` (и результатом в `storage`, если задан);
`(file_id, job_id, document_id)`."""
async with maker() as session, session.begin():
entity = await _rostov_entity(session, "ООО Гонка удаления")
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSING, job_status=RecognitionStatus.done
)
document = await make_document(session, file=file, job=job, status=DocumentStatus.NEEDS_CHECK)
if storage is not None:
await make_result_object(
session,
job=job,
storage=storage,
result=_minimal() | {"job_id": str(job.id), "file_id": str(file.id)},
)
return file.id, job.id, document.id
async def _wait_for_lock_waiter(maker: async_sessionmaker[AsyncSession]) -> None:
deadline = asyncio.get_running_loop().time() + LOCK_WAIT_SECONDS
while asyncio.get_running_loop().time() < deadline:
async with maker() as session:
waiting = (
await session.execute(
text(
"SELECT count(*) FROM pg_stat_activity "
"WHERE datname = current_database() AND wait_event_type = 'Lock'"
)
)
).scalar_one()
if waiting:
return
await asyncio.sleep(0.05)
raise AssertionError("удаление не встало в ожидание блокировки файла")
async def test_delete_rereads_state_after_locks(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
) -> None:
"""Подтверждение держит файл под `FOR UPDATE` и фиксирует документ `CONFIRMED`; удаление, начатое в это время,
ждёт блокировку, перечитывает документы и отвечает 409 — ничего не удалено."""
file_id, job_id, document_id = await _committed_file(committed_sessionmaker)
client = await committed_client(KA_RF)
async with committed_sessionmaker() as confirmer, confirmer.begin():
await confirmer.execute(select(StoredFile.id).where(StoredFile.id == file_id).with_for_update())
deletion = asyncio.create_task(client.delete(f"/files/{file_id}"))
await _wait_for_lock_waiter(committed_sessionmaker)
assert not deletion.done()
await confirmer.execute(
update(Document).where(Document.id == document_id).values(status=DocumentStatus.CONFIRMED)
)
response = await asyncio.wait_for(deletion, LOCK_WAIT_SECONDS)
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS"
async with committed_sessionmaker() as session:
assert await _count(session, StoredFile, StoredFile.id == file_id) == 1
assert await _count(session, RecognitionJob, RecognitionJob.id == job_id) == 1
assert await _delete_events(session) == []
async def test_delete_of_file_removed_while_waiting_is_404(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
) -> None:
"""Файл удалён параллельной транзакцией, пока удаление ждало блокировку: 404, не 500."""
file_id, job_id, _ = await _committed_file(committed_sessionmaker)
client = await committed_client(KA_RF)
async with committed_sessionmaker() as other, other.begin():
await other.execute(select(RecognitionJob.id).where(RecognitionJob.id == job_id).with_for_update())
deletion = asyncio.create_task(client.delete(f"/files/{file_id}"))
await _wait_for_lock_waiter(committed_sessionmaker)
file = await other.get(StoredFile, file_id)
assert file is not None
file.current_recognition_job_id = None
await other.flush()
await other.execute(text("DELETE FROM document WHERE stored_file_id = :id"), {"id": file_id})
await other.execute(text("DELETE FROM recognition_job WHERE file_id = :id"), {"id": file_id})
await other.execute(text("DELETE FROM stored_file WHERE id = :id"), {"id": file_id})
response = await asyncio.wait_for(deletion, LOCK_WAIT_SECONDS)
assert response.status_code == HTTP_NOT_FOUND, response.text
async with committed_sessionmaker() as session:
assert await _delete_events(session) == []
async def test_mapping_loop_ignores_deleted_file(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
) -> None:
"""Файл `PROCESSING` с готовым результатом удалён до цикла маппинга: `process_once` не падает, кандидатов нет,
документы и строки для файла не создаются; объект результата получил `expires_at`."""
storage = FakeStorage()
file_id, job_id, _ = await _committed_file(committed_sessionmaker, storage)
client = await committed_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
assert await process_once(committed_sessionmaker, storage, get_settings()) is False
async with committed_sessionmaker() as session:
assert await _count(session, StoredFile, StoredFile.id == file_id) == 0
assert await _count(session, Document, Document.stored_file_id == file_id) == 0
assert await _count(session, OsvEntry, OsvEntry.recognition_job_id == job_id) == 0
result_key = f"results/{file_id}/{job_id}/result.json"
expires = (await session.execute(select(S3Object.expires_at).where(S3Object.key == result_key))).scalar_one()
assert expires is not None

View File

@ -46,7 +46,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
f"{A}/roles/{{code}}/permissions": {"put"},
# primary-files, documents-api (Срез 2c)
"/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"},
"/api/v1/files/{file_id}": {"get"},
"/api/v1/files/{file_id}": {"get", "delete"},
"/api/v1/documents/{document_id}": {"get"},
"/api/v1/documents/{document_id}/entries": {"get"},
"/api/v1/documents/{document_id}/confirm": {"post"},

View File

@ -291,9 +291,9 @@ class DocumentService:
async def delete(self, file_id) -> None # get_file(scope) → 404; есть document CONFIRMED → ConflictError("FILE_HAS_CONFIRMED_DOCUMENTS"); EXISTS stored_file WHERE superseded_by_file_id = id → ConflictError("FILE_SUPERSEDES_OTHERS"); before = FileOut-словарь; DELETE osv_entry (по документам файла), document, recognition_job WHERE file_id (сначала file.current_recognition_job_id = NULL + flush — FK цикл), stored_file; s3_object.expires_at = now (ASSUMPTION(OQ-22)); audit FILE_DELETE before_value=before
```
- [ ] **Step 1: Тесты:** сценарии спеки (ошибочный файл → 204, записей нет, `expires_at`, аудит; `CONFIRMED` → 409; файл, заменивший другой → 409; `SUPERSEDED` → 204; чужой → 404; `PROCESSED` без подтверждения → 204 со строками).
- [ ] **Step 2–4:** падает → реализация → тесты, `make check`.
- [ ] **Step 5: Commit** `feat(files): удаление файла с ограничениями, expires_at у объекта S3, FILE_DELETE`.
- [x] **Step 1: Тесты:** сценарии спеки (ошибочный файл → 204, записей нет, `expires_at`, аудит; `CONFIRMED` → 409; файл, заменивший другой → 409; `SUPERSEDED` → 204; чужой → 404; `PROCESSED` без подтверждения → 204 со строками).
- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
- [x] **Step 5: Commit** `feat(files): удаление файла с ограничениями, expires_at у объекта S3, FILE_DELETE`.
---

View File

@ -53,7 +53,7 @@
- [x] 4.1 **API чтения и подтверждение.** `repositories/files.py`, `services/documents.py`, `schemas/documents.py`, эндпоинты
`GET /legal-entities/{id}/files`, `GET /files/{id}`, `GET /documents/{id}`, `GET /documents/{id}/entries`,
`POST /documents/{id}/confirm`; policy через ЮЛ-владельца. Тесты: сценарии спеки `documents-api`; `test_openapi_routes`.
- [ ] 4.2 **Удаление файла.** `DELETE /files/{id}`: проверки `CONFIRMED`/`superseded_by`, удаление строк/документов/заданий/файла,
- [x] 4.2 **Удаление файла.** `DELETE /files/{id}`: проверки `CONFIRMED`/`superseded_by`, удаление строк/документов/заданий/файла,
`s3_object.expires_at`, аудит `FILE_DELETE`. Тесты: сценарии спеки.
## 5. Сквозной прогон и документы