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 collections.abc import Sequence
|
||||||
from typing import Any
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.domain.policy import VisibilityScope, legal_entities_visible
|
from src.domain.policy import VisibilityScope, legal_entities_visible
|
||||||
from src.models.directories import LegalEntity
|
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.files import Document, RecognitionJob, S3Object, StoredFile
|
||||||
from src.models.osv import OsvEntry
|
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))
|
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:
|
async def expire_s3_objects(
|
||||||
"""`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22)."""
|
session: AsyncSession,
|
||||||
if object_ids:
|
object_ids: Sequence[uuid.UUID],
|
||||||
await session.execute(update(S3Object).where(S3Object.id.in_(object_ids)).values(expires_at=now))
|
*,
|
||||||
|
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.schemas.files import FileOut
|
||||||
from src.services.documents import file_out
|
from src.services.documents import file_out
|
||||||
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
|
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
|
||||||
|
from src.services.result_mapping import RESULTS_PREFIX
|
||||||
|
|
||||||
OBJECT_TYPE = "stored_file"
|
OBJECT_TYPE = "stored_file"
|
||||||
UPLOADS_PREFIX = "uploads"
|
UPLOADS_PREFIX = "uploads"
|
||||||
@ -174,7 +175,8 @@ class PrimaryFilesService:
|
|||||||
исчезнувший, пока ждали блокировку, — 404.
|
исчезнувший, пока ждали блокировку, — 404.
|
||||||
|
|
||||||
Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий
|
Удаляются `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` аудита — карточка файла."""
|
ретеншн бакета. `before_value` аудита — карточка файла."""
|
||||||
visible = await files_repo.get_file(self._session, self._scope, file_id)
|
visible = await files_repo.get_file(self._session, self._scope, file_id)
|
||||||
if visible is None:
|
if visible is None:
|
||||||
@ -197,7 +199,12 @@ class PrimaryFilesService:
|
|||||||
before = file_out(file, current, documents).model_dump(mode="json")
|
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)]
|
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.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(
|
await self._audit.record(
|
||||||
action=AuditAction.FILE_DELETE,
|
action=AuditAction.FILE_DELETE,
|
||||||
section=AuditSection.PRIMARY_DOCUMENTS,
|
section=AuditSection.PRIMARY_DOCUMENTS,
|
||||||
|
|||||||
@ -191,15 +191,17 @@ async def make_stored_file( # noqa: PLR0913 - сигнатура фабрики
|
|||||||
сессии (`flush`).
|
сессии (`flush`).
|
||||||
"""
|
"""
|
||||||
now = _now()
|
now = _now()
|
||||||
|
file_id = uuid.uuid4()
|
||||||
s3 = S3Object(
|
s3 = S3Object(
|
||||||
bucket_role=S3BucketRole.uploads,
|
bucket_role=S3BucketRole.uploads,
|
||||||
bucket=FACTORY_BUCKET,
|
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,
|
created_at=now,
|
||||||
)
|
)
|
||||||
session.add(s3)
|
session.add(s3)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
file = StoredFile(
|
file = StoredFile(
|
||||||
|
id=file_id,
|
||||||
legal_entity_id=entity.id,
|
legal_entity_id=entity.id,
|
||||||
file_type=file_type,
|
file_type=file_type,
|
||||||
original_name=original_name,
|
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
|
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:
|
async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||||||
"""Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл."""
|
"""Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл."""
|
||||||
entity = await _rostov_entity(seeded)
|
entity = await _rostov_entity(seeded)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user