fix(files): DELETE ставит expires_at только объектам папок своего файла
M-3: s3_object.expires_at = now ставится только объектам с ролью uploads и ключом
uploads/{file_id}/… или с ролью results и ключом results/{file_id}/…; ссылка
задания на объект другого файла (inbox переиспользует s3_object при конфликте
(bucket, key)) не трогается и ошибкой не считается.
Фабрика make_stored_file кладёт загрузку в папку файла uploads/{file_id}/…, как
upload_key в проде.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
583edf2a8d
commit
2ac19c6953
@ -12,12 +12,12 @@ import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, delete, func, select, update
|
||||
from sqlalchemy import Select, and_, delete, func, or_, 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.enums import FileStatus, S3BucketRole
|
||||
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
||||
from src.models.osv import OsvEntry
|
||||
|
||||
@ -174,7 +174,22 @@ async def delete_file_rows(session: AsyncSession, file: StoredFile) -> None:
|
||||
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))
|
||||
async def expire_s3_objects(
|
||||
session: AsyncSession,
|
||||
object_ids: Sequence[uuid.UUID],
|
||||
*,
|
||||
owned_prefixes: Sequence[tuple[S3BucketRole, str]],
|
||||
now: datetime.datetime,
|
||||
) -> None:
|
||||
"""`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22) — только
|
||||
объектам из `object_ids`, чья пара (роль бакета, начало ключа) входит в `owned_prefixes`. Остальные (например,
|
||||
ссылка задания на объект другого файла) не трогаются и ошибкой не считаются (M-3)."""
|
||||
if not object_ids or not owned_prefixes:
|
||||
return
|
||||
owned = or_(
|
||||
*(
|
||||
and_(S3Object.bucket_role == role, S3Object.key.startswith(prefix, autoescape=True))
|
||||
for role, prefix in owned_prefixes
|
||||
)
|
||||
)
|
||||
await session.execute(update(S3Object).where(S3Object.id.in_(object_ids), owned).values(expires_at=now))
|
||||
|
||||
@ -50,6 +50,7 @@ from src.schemas.common import PageParams
|
||||
from src.schemas.files import FileOut
|
||||
from src.services.documents import file_out
|
||||
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
|
||||
from src.services.result_mapping import RESULTS_PREFIX
|
||||
|
||||
OBJECT_TYPE = "stored_file"
|
||||
UPLOADS_PREFIX = "uploads"
|
||||
@ -174,7 +175,8 @@ class PrimaryFilesService:
|
||||
исчезнувший, пока ждали блокировку, — 404.
|
||||
|
||||
Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий
|
||||
остаются, им ставится `expires_at = now`. ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает
|
||||
остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и
|
||||
`results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает
|
||||
ретеншн бакета. `before_value` аудита — карточка файла."""
|
||||
visible = await files_repo.get_file(self._session, self._scope, file_id)
|
||||
if visible is None:
|
||||
@ -197,7 +199,12 @@ class PrimaryFilesService:
|
||||
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())
|
||||
# M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла
|
||||
owned_prefixes = [
|
||||
(S3BucketRole.uploads, f"{UPLOADS_PREFIX}/{file_id}/"),
|
||||
(S3BucketRole.results, f"{RESULTS_PREFIX}/{file_id}/"),
|
||||
]
|
||||
await files_repo.expire_s3_objects(self._session, object_ids, owned_prefixes=owned_prefixes, now=utc_now())
|
||||
await self._audit.record(
|
||||
action=AuditAction.FILE_DELETE,
|
||||
section=AuditSection.PRIMARY_DOCUMENTS,
|
||||
|
||||
@ -191,15 +191,17 @@ async def make_stored_file( # noqa: PLR0913 - сигнатура фабрики
|
||||
сессии (`flush`).
|
||||
"""
|
||||
now = _now()
|
||||
file_id = uuid.uuid4()
|
||||
s3 = S3Object(
|
||||
bucket_role=S3BucketRole.uploads,
|
||||
bucket=FACTORY_BUCKET,
|
||||
key=s3_key or f"uploads/{uuid.uuid4()}/{original_name}",
|
||||
key=s3_key or f"uploads/{file_id}/{uuid.uuid4()}/{original_name}", # как `upload_key`: папка файла
|
||||
created_at=now,
|
||||
)
|
||||
session.add(s3)
|
||||
await session.flush()
|
||||
file = StoredFile(
|
||||
id=file_id,
|
||||
legal_entity_id=entity.id,
|
||||
file_type=file_type,
|
||||
original_name=original_name,
|
||||
|
||||
@ -102,6 +102,28 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa
|
||||
assert event.after_value is None
|
||||
|
||||
|
||||
async def test_delete_expires_only_own_objects(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""M-3: `expires_at` ставится только объектам папок файла (`uploads/{file_id}/`, `results/{file_id}/`); ссылка
|
||||
задания на объект другого файла (inbox переиспользует `s3_object` при конфликте `(bucket, key)`) его не трогает."""
|
||||
entity = await _rostov_entity(seeded)
|
||||
file, job = await make_stored_file(
|
||||
seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.error
|
||||
)
|
||||
other_file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR, original_name="other.xls")
|
||||
job.result_s3_object_id = other_file.s3_object_id # результат «указывает» на загрузку другого файла
|
||||
await seeded.flush()
|
||||
file_id, own_id, foreign_id = file.id, file.s3_object_id, other_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
|
||||
own = await seeded.get(S3Object, own_id, populate_existing=True)
|
||||
foreign = await seeded.get(S3Object, foreign_id, populate_existing=True)
|
||||
assert own is not None and own.expires_at is not None
|
||||
assert foreign is not None and foreign.expires_at is None
|
||||
|
||||
|
||||
async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||
"""Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл."""
|
||||
entity = await _rostov_entity(seeded)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user