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:
parent
e2fde90293
commit
15021735e9
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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])
|
||||
|
||||
362
api/tests/test_api_files_delete.py
Normal file
362
api/tests/test_api_files_delete.py
Normal 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
|
||||
@ -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"},
|
||||
|
||||
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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. Сквозной прогон и документы
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user