Срез 2c: загрузка файлов ОСВ, цикл маппинга результата в document/osv_entry, API документов, DBML v5.15 #15
25
api/src/api/dependencies/files.py
Normal file
25
api/src/api/dependencies/files.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""Зависимости API первичных файлов и документов (Срез 2c; R-6: сессия — только `SessionDep`)."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit, get_request_audit
|
||||
from src.api.dependencies.db import SessionDep
|
||||
from src.api.dependencies.directories import SettingsDep, get_scope
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.services.primary_files import PrimaryFilesService
|
||||
|
||||
|
||||
def get_primary_files_service(
|
||||
request: Request,
|
||||
session: SessionDep,
|
||||
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||
scope: Annotated[VisibilityScope, Depends(get_scope)],
|
||||
app_settings: SettingsDep,
|
||||
) -> PrimaryFilesService:
|
||||
"""Сервис первичных файлов; S3 — `app.state.storage` (`None`, если S3 недоступно или не подключено)."""
|
||||
return PrimaryFilesService(session, audit, scope, getattr(request.app.state, "storage", None), app_settings)
|
||||
|
||||
|
||||
PrimaryFilesServiceDep = Annotated[PrimaryFilesService, Depends(get_primary_files_service)]
|
||||
@ -10,22 +10,13 @@ from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.directories import DirectoryImportServiceDep, SettingsDep
|
||||
from src.common.filenames import read_limited
|
||||
from src.domain.directory_import import OnExisting
|
||||
from src.schemas.directory_import import ImportReportOut
|
||||
from src.services.directory_import import check_size
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _read_limited(file: UploadFile, limit: int) -> bytes:
|
||||
"""Содержимое загрузки не больше `limit` байт: размер из multipart, если известен, иначе чтение `limit + 1`."""
|
||||
if file.size is not None:
|
||||
check_size(file.size, limit)
|
||||
content = await file.read(limit + 1)
|
||||
check_size(len(content), limit)
|
||||
return content
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import",
|
||||
response_model=ImportReportOut,
|
||||
@ -40,6 +31,6 @@ async def import_directory(
|
||||
on_existing: Annotated[OnExisting, Form(description="Существующие ГК/ЮЛ: оставить или обновить")] = OnExisting.skip,
|
||||
) -> ImportReportOut:
|
||||
"""Отчёт: счётчики и построчные коды §13.2; при `dry_run = false` — запись в справочник, аудит и файл в S3."""
|
||||
content = await _read_limited(file, settings.IMPORT_MAX_BYTES)
|
||||
content = await read_limited(file, settings.IMPORT_MAX_BYTES)
|
||||
report = await service.run(filename=file.filename or "", content=content, dry_run=dry_run, on_existing=on_existing)
|
||||
return ImportReportOut.model_validate(report.to_dict())
|
||||
|
||||
8
api/src/api/endpoints/files/__init__.py
Normal file
8
api/src/api/endpoints/files/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""Маршруты первичных файлов и документов (specs primary-files, documents-api)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.endpoints.files import upload
|
||||
|
||||
router = APIRouter(tags=["files"])
|
||||
router.include_router(upload.router)
|
||||
72
api/src/api/endpoints/files/upload.py
Normal file
72
api/src/api/endpoints/files/upload.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""`POST /legal-entities/{legal_entity_id}/files` — загрузка файлов организации (spec primary-files «Загрузка файлов
|
||||
организации», «Дедуп по содержимому»; design D-4).
|
||||
|
||||
multipart: одно или несколько полей `files`; право `DOCUMENT_EDIT`; ЮЛ — в области видимости пользователя, иначе 404.
|
||||
Тип документа и период не запрашиваются (макет `40`). Каждый файл принимается или отклоняется отдельно (частичный
|
||||
успех): 201, если принят хотя бы один, иначе 422. Файл больше `UPLOAD_MAX_BYTES` отклоняется кодом `FILE_TOO_LARGE`
|
||||
без чтения в память целиком; без S3 — 503 `STORAGE_UNAVAILABLE` на весь запрос.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.files import PrimaryFilesServiceDep
|
||||
from src.common.exceptions import DomainError
|
||||
from src.common.filenames import read_limited, safe_filename
|
||||
from src.schemas.common import ErrorDetail
|
||||
from src.schemas.files import AcceptedOut, RejectedOut, UploadResult
|
||||
from src.services.primary_files import FILE_TOO_LARGE, RejectedFile
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/legal-entities/{legal_entity_id}/files",
|
||||
operation_id="upload_legal_entity_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes)
|
||||
response_model=UploadResult,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require("DOCUMENT_EDIT"))],
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "ЮЛ нет или оно вне видимости"},
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": UploadResult, "description": "ни один файл не принят"},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"},
|
||||
},
|
||||
summary="Загрузка файлов организации",
|
||||
)
|
||||
async def upload_files(
|
||||
legal_entity_id: uuid.UUID,
|
||||
files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")],
|
||||
service: PrimaryFilesServiceDep,
|
||||
response: Response,
|
||||
) -> UploadResult:
|
||||
"""Принятые файлы (`file_id`, `job_id`, имя) и отклонённые (имя, код, пояснение, `file_id` дубля)."""
|
||||
entity = await service.get_entity(legal_entity_id)
|
||||
service.require_storage()
|
||||
accepted: list[AcceptedOut] = []
|
||||
rejected: list[RejectedOut] = []
|
||||
for upload in files:
|
||||
name = upload.filename or ""
|
||||
try:
|
||||
content = await read_limited(upload, service.max_upload_bytes)
|
||||
except HTTPException as error:
|
||||
if error.status_code != status.HTTP_413_CONTENT_TOO_LARGE:
|
||||
raise
|
||||
rejected.append(
|
||||
RejectedOut(name=safe_filename(name, default=""), code=FILE_TOO_LARGE, message=error.detail)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
result = await service.upload_one(entity, name, content)
|
||||
except DomainError as error:
|
||||
failure = RejectedFile.from_error(safe_filename(name, default=""), error)
|
||||
rejected.append(
|
||||
RejectedOut(name=failure.name, code=failure.code, message=failure.message, file_id=failure.file_id)
|
||||
)
|
||||
continue
|
||||
accepted.append(AcceptedOut(file_id=result.file_id, job_id=result.job_id, name=result.name))
|
||||
if not accepted:
|
||||
response.status_code = status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
return UploadResult(accepted=accepted, rejected=rejected)
|
||||
@ -1,13 +1,13 @@
|
||||
"""Корневой роутер API `/api/v1` (R-6: эндпоинты — `src/api/endpoints/`).
|
||||
|
||||
`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*` и `/admin/*` — всегда,
|
||||
dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и
|
||||
подключается в `src/app.py`.
|
||||
`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*`, `/admin/*` и файлы
|
||||
(`/legal-entities/{id}/files`) — всегда, dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный
|
||||
`router` собран по настройкам процесса и подключается в `src/app.py`.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.endpoints import admin, directories, me
|
||||
from src.api.endpoints import admin, directories, files, me
|
||||
from src.api.endpoints.dev_auth import build_dev_router
|
||||
from src.core.config import Settings, get_settings
|
||||
|
||||
@ -20,6 +20,7 @@ def build_api_router(settings: Settings) -> APIRouter:
|
||||
api_router.include_router(me.router)
|
||||
api_router.include_router(directories.router)
|
||||
api_router.include_router(admin.router)
|
||||
api_router.include_router(files.router)
|
||||
if settings.AUTH_DEV_ISSUER and settings.DEBUG:
|
||||
api_router.include_router(build_dev_router(settings))
|
||||
return api_router
|
||||
|
||||
71
api/src/common/filenames.py
Normal file
71
api/src/common/filenames.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""Имена и размер загружаемых файлов: общее для импорта справочников и загрузки первичных файлов (Срез 2c, D-4).
|
||||
|
||||
`safe_filename` очищает имя, присланное клиентом, для аудита, БД и ключа S3; `CONTENT_TYPES` — MIME по расширению;
|
||||
`read_limited` читает загрузку не больше лимита, превышение — `HTTPException` 413 строкой (как в импорте).
|
||||
"""
|
||||
|
||||
import unicodedata
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
|
||||
DEFAULT_FILENAME = "import"
|
||||
MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/{uuid}/{uuid}/{имя}` остаётся в пределах 1024 байт
|
||||
MAX_SUFFIX_LENGTH = 16
|
||||
MEGABYTE = 1024 * 1024
|
||||
|
||||
CONTENT_TYPES = {
|
||||
"csv": "text/csv",
|
||||
"xls": "application/vnd.ms-excel",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"xlsb": "application/vnd.ms-excel.sheet.binary.macroEnabled.12",
|
||||
"pdf": "application/pdf",
|
||||
"png": "image/png",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
}
|
||||
|
||||
_DROPPED_CATEGORIES = frozenset({"Cc", "Cf"})
|
||||
|
||||
|
||||
def safe_filename(filename: str | None, *, default: str = DEFAULT_FILENAME) -> str:
|
||||
"""Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3.
|
||||
|
||||
Управляющие (Cc, в т. ч. NUL — его не принимает PostgreSQL) и форматирующие (Cf: bidi-переопределения U+202E,
|
||||
нулевой ширины U+200B) символы отбрасываются до обрезки длины; длина ограничена
|
||||
`MAX_FILENAME_LENGTH` с сохранением расширения (T10.4), чтобы имя не давало 500 и ключ S3 не выходил за 1024 байта.
|
||||
Пустое имя (и `.`/`..`) — `default`: импорт подставляет `import`, загрузка первичных файлов — пустую строку
|
||||
(такой файл отклоняется как `UNSUPPORTED_FILE`).
|
||||
"""
|
||||
cleaned = "".join(
|
||||
char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) not in _DROPPED_CATEGORIES
|
||||
)
|
||||
name = PurePosixPath(cleaned).name.strip()
|
||||
if name in {"", ".", ".."}:
|
||||
return default
|
||||
if len(name) > MAX_FILENAME_LENGTH:
|
||||
suffix = PurePosixPath(name).suffix
|
||||
suffix = suffix if len(suffix) <= MAX_SUFFIX_LENGTH else ""
|
||||
name = name[: MAX_FILENAME_LENGTH - len(suffix)] + suffix
|
||||
return name
|
||||
|
||||
|
||||
def extension(name: str) -> str:
|
||||
"""Расширение имени в нижнем регистре без точки (`ОСВ.XLS` → `xls`; без расширения — пустая строка)."""
|
||||
return PurePosixPath(name).suffix.lstrip(".").lower()
|
||||
|
||||
|
||||
def check_size(size: int, limit: int) -> None:
|
||||
"""413, если файл больше `limit` байт."""
|
||||
if size > limit:
|
||||
limit_text = f"{limit // MEGABYTE} МБ" if limit >= MEGABYTE else f"{limit} байт"
|
||||
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=f"файл больше {limit_text}")
|
||||
|
||||
|
||||
async def read_limited(file: UploadFile, limit: int) -> bytes:
|
||||
"""Содержимое загрузки не больше `limit` байт: размер из multipart, если известен, иначе чтение `limit + 1`."""
|
||||
if file.size is not None:
|
||||
check_size(file.size, limit)
|
||||
content = await file.read(limit + 1)
|
||||
check_size(len(content), limit)
|
||||
return content
|
||||
74
api/src/domain/file_types.py
Normal file
74
api/src/domain/file_types.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Тип загружаемого первичного файла (spec primary-files «Загрузка файлов организации»; design D-4).
|
||||
|
||||
Тип определяется по расширению имени и сверяется с сигнатурой содержимого: `xls` — составной документ OLE2 (BIFF8),
|
||||
`xlsx`/`xlsb` — zip, `pdf`, `png`, `jpg`. Расширение, которого нет в списке, пустое имя или несовпадение сигнатуры —
|
||||
`UNSUPPORTED_FILE`. Конвейер распознавания: таблицы → `tabular` (парсер, 3 попытки), изображения и PDF → `image`
|
||||
(ИС Сканирование, 1 попытка — DBML `recognition_job.max_attempts`).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.common.exceptions import DomainValidationError
|
||||
from src.common.filenames import CONTENT_TYPES, extension
|
||||
from src.models.enums import FileType, RecognitionPipeline
|
||||
|
||||
UNSUPPORTED_FILE = "UNSUPPORTED_FILE"
|
||||
|
||||
OLE2_SIGNATURE = b"\xd0\xcf\x11\xe0"
|
||||
ZIP_SIGNATURE = b"PK\x03\x04"
|
||||
PDF_SIGNATURE = b"%PDF"
|
||||
PNG_SIGNATURE = b"\x89PNG"
|
||||
JPG_SIGNATURE = b"\xff\xd8\xff"
|
||||
|
||||
FILE_TYPES: dict[str, FileType] = {
|
||||
"xls": FileType.XLS,
|
||||
"xlsx": FileType.XLSX,
|
||||
"xlsb": FileType.XLSB,
|
||||
"pdf": FileType.PDF,
|
||||
"png": FileType.PNG,
|
||||
"jpg": FileType.JPG,
|
||||
"jpeg": FileType.JPG,
|
||||
}
|
||||
SIGNATURES: dict[FileType, bytes] = {
|
||||
FileType.XLS: OLE2_SIGNATURE,
|
||||
FileType.XLSX: ZIP_SIGNATURE,
|
||||
FileType.XLSB: ZIP_SIGNATURE,
|
||||
FileType.PDF: PDF_SIGNATURE,
|
||||
FileType.PNG: PNG_SIGNATURE,
|
||||
FileType.JPG: JPG_SIGNATURE,
|
||||
}
|
||||
PIPELINES: dict[FileType, RecognitionPipeline] = {
|
||||
FileType.XLS: RecognitionPipeline.tabular,
|
||||
FileType.XLSX: RecognitionPipeline.tabular,
|
||||
FileType.XLSB: RecognitionPipeline.tabular,
|
||||
FileType.PDF: RecognitionPipeline.image,
|
||||
FileType.PNG: RecognitionPipeline.image,
|
||||
FileType.JPG: RecognitionPipeline.image,
|
||||
}
|
||||
MAX_ATTEMPTS: dict[RecognitionPipeline, int] = {RecognitionPipeline.tabular: 3, RecognitionPipeline.image: 1}
|
||||
|
||||
SUPPORTED_TEXT = "xls, xlsx, xlsb, pdf, png, jpg"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DetectedFile:
|
||||
"""Тип файла, конвейер распознавания и MIME для `s3_object.content_type`."""
|
||||
|
||||
file_type: FileType
|
||||
pipeline: RecognitionPipeline
|
||||
content_type: str
|
||||
|
||||
|
||||
def detect_file(name: str, content: bytes) -> DetectedFile:
|
||||
"""Тип по расширению `name` (без учёта регистра), подтверждённый сигнатурой `content`; иначе `UNSUPPORTED_FILE`."""
|
||||
suffix = extension(name)
|
||||
file_type = FILE_TYPES.get(suffix)
|
||||
if file_type is None:
|
||||
raise DomainValidationError(
|
||||
UNSUPPORTED_FILE, f"неподдерживаемый тип файла «{name}»: допустимы {SUPPORTED_TEXT}"
|
||||
)
|
||||
if not content.startswith(SIGNATURES[file_type]):
|
||||
raise DomainValidationError(
|
||||
UNSUPPORTED_FILE, f"содержимое файла «{name}» не соответствует расширению .{suffix}"
|
||||
)
|
||||
return DetectedFile(file_type=file_type, pipeline=PIPELINES[file_type], content_type=CONTENT_TYPES[suffix])
|
||||
93
api/src/schemas/files.py
Normal file
93
api/src/schemas/files.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""Схемы API первичных файлов (spec primary-files, documents-api; design D-4, D-11).
|
||||
|
||||
`UploadResult` — ответ загрузки (частичный успех: принятые и отклонённые файлы). `FileOut`/`JobBrief`/`DocumentBrief` —
|
||||
карточка файла для чтения (Task 9 Среза 2c): статус файла, текущее задание с причиной ошибки и предупреждениями,
|
||||
документы файла.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from src.models.enums import (
|
||||
DocumentCheckResult,
|
||||
DocumentStatus,
|
||||
DocumentType,
|
||||
FileStatus,
|
||||
FileType,
|
||||
PeriodQuarter,
|
||||
RecognitionStatus,
|
||||
)
|
||||
|
||||
|
||||
class AcceptedOut(BaseModel):
|
||||
"""Принятый файл: `stored_file.id`, `recognition_job.id` и очищенное имя."""
|
||||
|
||||
file_id: uuid.UUID
|
||||
job_id: uuid.UUID
|
||||
name: str
|
||||
|
||||
|
||||
class RejectedOut(BaseModel):
|
||||
"""Отклонённый файл: имя, код (`UNSUPPORTED_FILE`, `FILE_TOO_LARGE`, `DUPLICATE_FILE`, `STORAGE_UNAVAILABLE`),
|
||||
пояснение; `file_id` — существующий файл при `DUPLICATE_FILE`."""
|
||||
|
||||
name: str
|
||||
code: str
|
||||
message: str
|
||||
file_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class UploadResult(BaseModel):
|
||||
"""Ответ `POST /legal-entities/{id}/files`: 201, если принят хотя бы один файл, иначе 422."""
|
||||
|
||||
accepted: list[AcceptedOut]
|
||||
rejected: list[RejectedOut]
|
||||
|
||||
|
||||
class JobBrief(BaseModel):
|
||||
"""Текущее задание распознавания файла: статус, причина ошибки, предупреждения сервиса и маппинга."""
|
||||
|
||||
id: uuid.UUID
|
||||
status: RecognitionStatus
|
||||
error_code: str | None
|
||||
error_message: str | None
|
||||
warnings: list[str]
|
||||
mapping_warnings: list[str]
|
||||
finished_at: datetime.datetime | None
|
||||
|
||||
|
||||
class DocumentBrief(BaseModel):
|
||||
"""Документ файла в карточке файла."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
document_type: DocumentType
|
||||
osv_account_code: str | None
|
||||
period_year: int | None
|
||||
period_quarter: PeriodQuarter | None
|
||||
is_cumulative: bool | None
|
||||
status: DocumentStatus
|
||||
period_check: DocumentCheckResult | None
|
||||
counterparty_check: DocumentCheckResult | None
|
||||
locator: str | None
|
||||
|
||||
|
||||
class FileOut(BaseModel):
|
||||
"""Карточка файла: поля `stored_file`, текущее задание и документы."""
|
||||
|
||||
id: uuid.UUID
|
||||
legal_entity_id: uuid.UUID
|
||||
original_name: str | None
|
||||
file_type: FileType
|
||||
status: FileStatus
|
||||
version_no: int
|
||||
size_bytes: int | None
|
||||
content_hash: str | None
|
||||
uploaded_at: datetime.datetime | None
|
||||
uploaded_by: uuid.UUID | None
|
||||
superseded_by_file_id: uuid.UUID | None
|
||||
job: JobBrief | None
|
||||
documents: list[DocumentBrief]
|
||||
@ -15,13 +15,10 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import hashlib
|
||||
import unicodedata
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from loguru import logger
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@ -29,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.clock import utc_now
|
||||
from src.common.exceptions import DomainValidationError
|
||||
from src.common.filenames import CONTENT_TYPES, check_size, extension, safe_filename
|
||||
from src.core.config import Settings
|
||||
from src.core.storage import ObjectStorage
|
||||
from src.domain.directory_import import (
|
||||
@ -65,43 +63,6 @@ from src.services.related_groups import CODE_PREFIXES
|
||||
OBJECT_TYPE = "directory_import"
|
||||
IMPORT_DESCRIPTION = "импорт ЕЦП" # `description` событий изменения ГК/ЮЛ, сделанных импортом
|
||||
S3_PREFIX = "uploads/imports"
|
||||
DEFAULT_FILENAME = "import"
|
||||
CONTENT_TYPES = {
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"csv": "text/csv",
|
||||
}
|
||||
MEGABYTE = 1024 * 1024
|
||||
MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/imports/{uuid}/{имя}` остаётся в пределах 1024 байт
|
||||
MAX_SUFFIX_LENGTH = 16
|
||||
|
||||
|
||||
def check_size(size: int, limit: int) -> None:
|
||||
"""413, если файл больше `IMPORT_MAX_BYTES`."""
|
||||
if size > limit:
|
||||
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=f"файл больше {limit // MEGABYTE} МБ")
|
||||
|
||||
|
||||
_DROPPED_CATEGORIES = frozenset({"Cc", "Cf"})
|
||||
|
||||
|
||||
def safe_filename(filename: str | None) -> str:
|
||||
"""Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3.
|
||||
|
||||
Управляющие (Cc, в т. ч. NUL — его не принимает PostgreSQL) и форматирующие (Cf: bidi-переопределения U+202E,
|
||||
нулевой ширины U+200B) символы отбрасываются до обрезки длины; длина ограничена
|
||||
`MAX_FILENAME_LENGTH` с сохранением расширения (T10.4), чтобы имя не давало 500 и ключ S3 не выходил за 1024 байта.
|
||||
"""
|
||||
cleaned = "".join(
|
||||
char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) not in _DROPPED_CATEGORIES
|
||||
)
|
||||
name = PurePosixPath(cleaned).name.strip()
|
||||
if name in {"", ".", ".."}:
|
||||
return DEFAULT_FILENAME
|
||||
if len(name) > MAX_FILENAME_LENGTH:
|
||||
suffix = PurePosixPath(name).suffix
|
||||
suffix = suffix if len(suffix) <= MAX_SUFFIX_LENGTH else ""
|
||||
name = name[: MAX_FILENAME_LENGTH - len(suffix)] + suffix
|
||||
return name
|
||||
|
||||
|
||||
class DirectoryImportService:
|
||||
@ -267,7 +228,7 @@ class DirectoryImportService:
|
||||
bucket_role=S3BucketRole.uploads,
|
||||
bucket=self._storage.bucket,
|
||||
key=key,
|
||||
content_type=CONTENT_TYPES.get(PurePosixPath(name).suffix.lstrip(".").lower()),
|
||||
content_type=CONTENT_TYPES.get(extension(name)),
|
||||
size_bytes=len(content),
|
||||
sha256=hashlib.sha256(content).hexdigest(),
|
||||
created_by=f"user:{self._audit.user_id}",
|
||||
|
||||
248
api/src/services/primary_files.py
Normal file
248
api/src/services/primary_files.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""Первичные файлы организации: загрузка (spec primary-files «Загрузка файлов организации», «Дедуп по содержимому»;
|
||||
audit-trail «События файлов и документов»; design D-4).
|
||||
|
||||
Загрузка одного файла — SAVEPOINT (`begin_nested()`) внутри транзакции запроса (`SessionDep`): отказ файла откатывает
|
||||
только его записи, принятые раньше файлы того же запроса остаются. Порядок внутри SAVEPOINT:
|
||||
|
||||
1. рекомендательная блокировка `(ЮЛ, sha256)` до конца транзакции запроса и проверка дубля — до записи в S3;
|
||||
2. `s3_object` + `flush` (конфликт ключа или ошибка БД — до загрузки объекта, бакет не засоряется);
|
||||
3. `put_object`; ошибка S3 — `STORAGE_UNAVAILABLE`, SAVEPOINT откатывается (строки `s3_object` не остаётся);
|
||||
4. `stored_file` (`UPLOADED`, `version_no = 1`) и `recognition_job` (`queued`) + `flush`,
|
||||
`stored_file.current_recognition_job_id`, аудит `FILE_UPLOAD`.
|
||||
|
||||
Если после шага 3 упадёт БД (вставка файла/задания, аудит или commit запроса), объект в S3 останется без записи
|
||||
`s3_object` — допустимо: ключ содержит свежие `file_id`/`job_id`, ни на что не ссылается и не будет переиспользован;
|
||||
уборка таких объектов — ретеншн бакета (OQ-22).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette import status as http_status
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.clock import utc_now
|
||||
from src.common.exceptions import ConflictError, DomainError, NotFoundError
|
||||
from src.common.filenames import safe_filename
|
||||
from src.contract import CONTRACT_VERSION, request_subject
|
||||
from src.core.config import Settings
|
||||
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.files import RecognitionJob, S3Object, StoredFile
|
||||
from src.repositories import directories as directories_repo
|
||||
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
|
||||
|
||||
OBJECT_TYPE = "stored_file"
|
||||
UPLOADS_PREFIX = "uploads"
|
||||
DUPLICATE_FILE = "DUPLICATE_FILE"
|
||||
FILE_TOO_LARGE = "FILE_TOO_LARGE"
|
||||
STORAGE_UNAVAILABLE = "STORAGE_UNAVAILABLE"
|
||||
STORAGE_UNAVAILABLE_MESSAGE = "хранилище файлов недоступно"
|
||||
|
||||
|
||||
class StorageUnavailableError(DomainError):
|
||||
"""503 `STORAGE_UNAVAILABLE`: S3 не подключено (dev без S3) или не приняло объект."""
|
||||
|
||||
def __init__(self, message: str = STORAGE_UNAVAILABLE_MESSAGE) -> None:
|
||||
super().__init__(http_status.HTTP_503_SERVICE_UNAVAILABLE, STORAGE_UNAVAILABLE, message)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptedFile:
|
||||
"""Принятый файл: `stored_file.id`, `recognition_job.id`, очищенное имя."""
|
||||
|
||||
file_id: uuid.UUID
|
||||
job_id: uuid.UUID
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RejectedFile:
|
||||
"""Отклонённый файл; `file_id` — существующий файл при `DUPLICATE_FILE`."""
|
||||
|
||||
name: str
|
||||
code: str
|
||||
message: str
|
||||
file_id: uuid.UUID | None = None
|
||||
|
||||
@classmethod
|
||||
def from_error(cls, name: str, error: DomainError) -> "RejectedFile":
|
||||
"""Отказ по доменной ошибке загрузки файла `name`."""
|
||||
raw_file_id = error.extra.get("file_id")
|
||||
return cls(
|
||||
name=name,
|
||||
code=error.code,
|
||||
message=error.message,
|
||||
file_id=uuid.UUID(str(raw_file_id)) if raw_file_id is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def upload_key(file_id: uuid.UUID, job_id: uuid.UUID, name: str) -> str:
|
||||
"""Ключ исходного файла в S3: `uploads/{file_id}/{job_id}/{имя}` (`03-architecture.md`, схема ключей)."""
|
||||
return f"{UPLOADS_PREFIX}/{file_id}/{job_id}/{name}"
|
||||
|
||||
|
||||
class PrimaryFilesService:
|
||||
"""Загрузка первичных файлов ЮЛ от имени пользователя запроса (право `DOCUMENT_EDIT` — на маршруте)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
audit: RequestAudit,
|
||||
scope: VisibilityScope,
|
||||
storage: ObjectStorage | None,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._audit = audit
|
||||
self._scope = scope
|
||||
self._storage = storage
|
||||
self._settings = settings
|
||||
|
||||
@property
|
||||
def max_upload_bytes(self) -> int:
|
||||
"""Предел размера одного файла (`UPLOAD_MAX_BYTES`)."""
|
||||
return self._settings.UPLOAD_MAX_BYTES
|
||||
|
||||
async def get_entity(self, entity_id: uuid.UUID) -> LegalEntity:
|
||||
"""ЮЛ в области видимости пользователя; чужое и несуществующее — одинаковый 404 (§10.1)."""
|
||||
entity = await directories_repo.get_legal_entity(self._session, self._scope, entity_id)
|
||||
if entity is None:
|
||||
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
|
||||
return entity
|
||||
|
||||
def require_storage(self) -> ObjectStorage:
|
||||
"""S3 приложения; без него загрузка невозможна — 503 `STORAGE_UNAVAILABLE`."""
|
||||
if self._storage is None:
|
||||
raise StorageUnavailableError()
|
||||
return self._storage
|
||||
|
||||
async def find_duplicate(self, entity_id: uuid.UUID, sha256: str) -> StoredFile | None:
|
||||
"""Файл ЮЛ с тем же содержимым, кроме заменённых (`SUPERSEDED`): дубль по spec «Дедуп по содержимому»."""
|
||||
stmt = (
|
||||
select(StoredFile)
|
||||
.where(
|
||||
StoredFile.legal_entity_id == entity_id,
|
||||
StoredFile.content_hash == sha256,
|
||||
StoredFile.status != FileStatus.SUPERSEDED,
|
||||
)
|
||||
.order_by(StoredFile.uploaded_at, StoredFile.id)
|
||||
.limit(1)
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def upload_one(self, entity: LegalEntity, name: str, content: bytes) -> AcceptedFile:
|
||||
"""Принять один файл (см. docstring модуля); отказ — `DomainError` (`UNSUPPORTED_FILE`, `DUPLICATE_FILE`,
|
||||
`STORAGE_UNAVAILABLE`), записи этого файла откатываются вместе с его SAVEPOINT."""
|
||||
storage = self.require_storage()
|
||||
safe_name = safe_filename(name, default="")
|
||||
detected = detect_file(safe_name, content)
|
||||
sha256 = hashlib.sha256(content).hexdigest()
|
||||
async with self._session.begin_nested():
|
||||
await self._lock_content(entity.id, sha256)
|
||||
duplicate = await self.find_duplicate(entity.id, sha256)
|
||||
if duplicate is not None:
|
||||
raise ConflictError(
|
||||
DUPLICATE_FILE,
|
||||
f"файл «{safe_name}» уже загружен для этого ЮЛ",
|
||||
file_id=str(duplicate.id),
|
||||
)
|
||||
return await self._store(
|
||||
entity, name=safe_name, content=content, sha256=sha256, storage=storage, detected=detected
|
||||
)
|
||||
|
||||
async def _lock_content(self, entity_id: uuid.UUID, sha256: str) -> None:
|
||||
"""Сериализует загрузки одного содержимого в одно ЮЛ до конца транзакции запроса (двойной клик): второй
|
||||
запрос дождётся commit первого и увидит дубль. Блокировка снимается с транзакцией, а не с SAVEPOINT."""
|
||||
lock_key = func.hashtextextended(f"{OBJECT_TYPE}:{entity_id}:{sha256}", 0)
|
||||
await self._session.execute(select(func.pg_advisory_xact_lock(lock_key)))
|
||||
|
||||
async def _store( # noqa: PLR0913 - шаги одной загрузки: всё, что уже вычислено в upload_one
|
||||
self,
|
||||
entity: LegalEntity,
|
||||
*,
|
||||
name: str,
|
||||
content: bytes,
|
||||
sha256: str,
|
||||
storage: ObjectStorage,
|
||||
detected: DetectedFile,
|
||||
) -> AcceptedFile:
|
||||
now = utc_now()
|
||||
user_id = self._audit.user_id
|
||||
file_id, job_id = uuid.uuid4(), uuid.uuid4()
|
||||
key = upload_key(file_id, job_id, name)
|
||||
s3_object = S3Object(
|
||||
id=uuid.uuid4(),
|
||||
bucket_role=S3BucketRole.uploads,
|
||||
bucket=storage.bucket,
|
||||
key=key,
|
||||
content_type=detected.content_type,
|
||||
size_bytes=len(content),
|
||||
sha256=sha256,
|
||||
created_by=f"user:{user_id}",
|
||||
created_at=now,
|
||||
)
|
||||
self._session.add(s3_object)
|
||||
await self._session.flush()
|
||||
try:
|
||||
await storage.put_object(key, content)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"S3 не приняло загружаемый файл, файл отклонён. error_category=storage_error, key={key}, error={error}",
|
||||
key=key,
|
||||
error=f"{type(error).__name__}: {error}",
|
||||
)
|
||||
raise StorageUnavailableError(f"{STORAGE_UNAVAILABLE_MESSAGE}: файл «{name}» не сохранён") from error
|
||||
|
||||
stored_file = StoredFile(
|
||||
id=file_id,
|
||||
legal_entity_id=entity.id,
|
||||
file_type=detected.file_type,
|
||||
original_name=name,
|
||||
s3_object_id=s3_object.id,
|
||||
content_hash=sha256,
|
||||
size_bytes=len(content),
|
||||
status=FileStatus.UPLOADED,
|
||||
version_no=1,
|
||||
uploaded_by=user_id,
|
||||
uploaded_at=now,
|
||||
)
|
||||
job = RecognitionJob(
|
||||
id=job_id,
|
||||
file_id=file_id,
|
||||
pipeline=detected.pipeline,
|
||||
request_subject=request_subject(detected.pipeline.value),
|
||||
schema_version=CONTRACT_VERSION,
|
||||
status=RecognitionStatus.queued,
|
||||
attempt=1,
|
||||
max_attempts=MAX_ATTEMPTS[detected.pipeline],
|
||||
requested_at=now,
|
||||
)
|
||||
# Связей `relationship()` в моделях нет — порядок INSERT задаём сами: файл, затем задание (FK `file_id`);
|
||||
# FK `current_recognition_job_id` не отложенный (use_alter): ссылку ставим, когда задание уже вставлено
|
||||
self._session.add(stored_file)
|
||||
await self._session.flush()
|
||||
self._session.add(job)
|
||||
await self._session.flush()
|
||||
stored_file.current_recognition_job_id = job.id
|
||||
await self._session.flush()
|
||||
await self._audit.record(
|
||||
action=AuditAction.FILE_UPLOAD,
|
||||
section=AuditSection.PRIMARY_DOCUMENTS,
|
||||
object_type=OBJECT_TYPE,
|
||||
object_id=file_id,
|
||||
after_value={
|
||||
"original_name": name,
|
||||
"content_hash": sha256,
|
||||
"job_id": str(job_id),
|
||||
"file_type": detected.file_type.value,
|
||||
},
|
||||
)
|
||||
return AcceptedFile(file_id=file_id, job_id=job_id, name=name)
|
||||
@ -18,6 +18,7 @@ from fastapi import FastAPI
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.common.filenames import MAX_FILENAME_LENGTH, safe_filename
|
||||
from src.core.config import get_settings
|
||||
from src.models.access import AppUser
|
||||
from src.models.audit_ex import AuditEvent
|
||||
@ -32,7 +33,6 @@ from src.models.directories import (
|
||||
)
|
||||
from src.models.enums import AuditAction, AuditSection, EntityType, LeHistoryEvent, RelatedGroupKind, S3BucketRole
|
||||
from src.models.files import S3Object
|
||||
from src.services.directory_import import MAX_FILENAME_LENGTH, safe_filename
|
||||
from tests.conftest import AuthClientFactory, FakeStorage
|
||||
from tests.factories import make_company_group, make_legal_entity
|
||||
|
||||
|
||||
533
api/tests/test_api_files_upload.py
Normal file
533
api/tests/test_api_files_upload.py
Normal file
@ -0,0 +1,533 @@
|
||||
"""Загрузка файлов организации (spec primary-files «Загрузка», «Дедуп»; audit-trail «События файлов и документов»).
|
||||
|
||||
Сценарии поверх фикстуры `app` (транзакция теста с SAVEPOINT): запрос — `begin_nested()`, каждый файл — ещё один
|
||||
SAVEPOINT внутри него. Реальная ОСВ и параллельный дубль идут на `committed_*` (временная БД с зафиксированным seed,
|
||||
сессия запроса как в проде): публикатор outbox и второй запрос работают в своих транзакциях и видят только
|
||||
зафиксированное.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.api.dependencies.directories import get_app_settings
|
||||
from src.core.config import get_settings
|
||||
from src.models.access import AppUser
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.directories import LegalEntity, RegionalOffice
|
||||
from src.models.enums import (
|
||||
AuditAction,
|
||||
AuditSection,
|
||||
FileStatus,
|
||||
FileType,
|
||||
RecognitionPipeline,
|
||||
RecognitionStatus,
|
||||
S3BucketRole,
|
||||
)
|
||||
from src.models.files import RecognitionJob, S3Object, StoredFile
|
||||
from src.services.recognition_exchange import publish_pending
|
||||
from tests.conftest import AuthClientFactory, FakeStorage
|
||||
from tests.factories import make_company_group, make_legal_entity
|
||||
from tests.test_recognition_exchange import REQUEST_VALIDATOR, FakeNats
|
||||
|
||||
HTTP_CREATED = 201
|
||||
HTTP_FORBIDDEN = 403
|
||||
HTTP_NOT_FOUND = 404
|
||||
HTTP_UNPROCESSABLE = 422
|
||||
HTTP_UNAVAILABLE = 503
|
||||
|
||||
KA_RF = "ka_rf@armka.local" # Ростовский филиал
|
||||
CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал, право DOCUMENT_EDIT есть
|
||||
CURATOR_GO = "curator_go@armka.local" # права DOCUMENT_EDIT нет
|
||||
ROSTOV = "Ростовский региональный филиал"
|
||||
|
||||
REAL_OSV = "ОСВ_66 за 2024.xls"
|
||||
REAL_OSV_PATH = ("osv", "rb", "2024-FY", REAL_OSV)
|
||||
|
||||
BIFF8 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
||||
ZIP = b"PK\x03\x04"
|
||||
PDF = b"%PDF-1.7\n"
|
||||
XLS_TYPE = "application/vnd.ms-excel"
|
||||
XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
TINY_LIMIT = 10 # байт: «файл 51 МиБ» без 51 МиБ в памяти теста
|
||||
|
||||
Upload = tuple[str, bytes]
|
||||
|
||||
|
||||
def _files_url(entity_id: uuid.UUID) -> str:
|
||||
return f"/legal-entities/{entity_id}/files"
|
||||
|
||||
|
||||
def _xls(tag: str) -> bytes:
|
||||
"""Байты с сигнатурой BIFF8 и уникальным хвостом (разный sha256 — не дубль)."""
|
||||
return BIFF8 + tag.encode()
|
||||
|
||||
|
||||
async def _post(client: httpx.AsyncClient, entity_id: uuid.UUID, *uploads: Upload) -> httpx.Response:
|
||||
files = [("files", (name, content, "application/octet-stream")) for name, content in uploads]
|
||||
return await client.post(_files_url(entity_id), files=files)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def _user_id(session: AsyncSession, email: str) -> uuid.UUID:
|
||||
return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one()
|
||||
|
||||
|
||||
async def _counts(session: AsyncSession) -> dict[str, int]:
|
||||
return {
|
||||
model.__tablename__: (await session.execute(select(func.count()).select_from(model))).scalar_one()
|
||||
for model in (S3Object, StoredFile, RecognitionJob)
|
||||
} | {
|
||||
"file_upload": (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.FILE_UPLOAD)
|
||||
)
|
||||
).scalar_one()
|
||||
}
|
||||
|
||||
|
||||
async def _upload_events(session: AsyncSession) -> Sequence[AuditEvent]:
|
||||
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_UPLOAD).order_by(AuditEvent.occurred_at)
|
||||
return (await session.execute(stmt)).scalars().all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(app: FastAPI) -> FakeStorage:
|
||||
fake = FakeStorage()
|
||||
app.state.storage = fake
|
||||
return fake
|
||||
|
||||
|
||||
class FlakyStorage(FakeStorage):
|
||||
"""S3, у которого падает запись объектов с заданным суффиксом ключа."""
|
||||
|
||||
def __init__(self, failing_suffix: str) -> None:
|
||||
super().__init__()
|
||||
self.failing_suffix = failing_suffix
|
||||
|
||||
async def put_object(self, obj_name: str, file_data: bytes) -> None:
|
||||
if obj_name.endswith(self.failing_suffix):
|
||||
raise ConnectionError("S3 недоступно")
|
||||
await super().put_object(obj_name, file_data)
|
||||
|
||||
|
||||
class CapturedPublish:
|
||||
"""Делегат `FakeNats`: запоминает тела опубликованных запросов."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.payloads: list[dict[str, Any]] = []
|
||||
|
||||
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: # noqa: ARG002 - протокол издателя
|
||||
self.payloads.append(json.loads(payload))
|
||||
|
||||
|
||||
# --- «Реальная ОСВ принята и ушла в outbox» ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fixtures
|
||||
async def test_real_osv_is_accepted_and_published_from_outbox(
|
||||
fixtures_dir: Any,
|
||||
committed_client: AuthClientFactory,
|
||||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||||
committed_storage: FakeStorage,
|
||||
) -> None:
|
||||
content = fixtures_dir.joinpath(*REAL_OSV_PATH).read_bytes()
|
||||
async with committed_sessionmaker() as session, session.begin():
|
||||
entity = await _rostov_entity(session, "АО Русский Бисквит")
|
||||
user_id = await _user_id(session, KA_RF)
|
||||
client = await committed_client(KA_RF)
|
||||
|
||||
response = await _post(client, entity.id, (REAL_OSV, content))
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
body = response.json()
|
||||
assert body["rejected"] == []
|
||||
(accepted,) = body["accepted"]
|
||||
file_id, job_id = uuid.UUID(accepted["file_id"]), uuid.UUID(accepted["job_id"])
|
||||
assert accepted["name"] == REAL_OSV
|
||||
key = f"uploads/{file_id}/{job_id}/{REAL_OSV}"
|
||||
assert committed_storage.objects == {key: content}
|
||||
|
||||
async with committed_sessionmaker() as session:
|
||||
file = await session.get(StoredFile, file_id)
|
||||
job = await session.get(RecognitionJob, job_id)
|
||||
assert file is not None and job is not None
|
||||
s3 = await session.get(S3Object, file.s3_object_id)
|
||||
assert s3 is not None
|
||||
sha256 = hashlib.sha256(content).hexdigest()
|
||||
assert (file.status, file.file_type, file.content_hash, file.version_no) == (
|
||||
FileStatus.UPLOADED,
|
||||
FileType.XLS,
|
||||
sha256,
|
||||
1,
|
||||
)
|
||||
assert (file.legal_entity_id, file.uploaded_by, file.original_name) == (entity.id, user_id, REAL_OSV)
|
||||
assert file.current_recognition_job_id == job.id
|
||||
assert (job.status, job.pipeline, job.max_attempts, job.file_id) == (
|
||||
RecognitionStatus.queued,
|
||||
RecognitionPipeline.tabular,
|
||||
3,
|
||||
file_id,
|
||||
)
|
||||
assert (s3.bucket_role, s3.key, s3.sha256, s3.size_bytes) == (S3BucketRole.uploads, key, sha256, len(content))
|
||||
|
||||
capture = CapturedPublish()
|
||||
report = await publish_pending(
|
||||
committed_sessionmaker,
|
||||
FakeNats(delegate=capture),
|
||||
ack_timeout=timedelta(seconds=30),
|
||||
max_republish=3,
|
||||
)
|
||||
|
||||
assert report.published == 1
|
||||
(request,) = capture.payloads
|
||||
REQUEST_VALIDATOR.validate(request)
|
||||
assert request["s3"] == {"bucket": committed_storage.bucket, "key": key}
|
||||
assert (request["job_id"], request["file_id"], request["file_type"]) == (str(job_id), str(file_id), "XLS")
|
||||
assert request["original_name"] == REAL_OSV
|
||||
|
||||
|
||||
# --- поля записей, имя файла, аудит ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_hostile_name_gives_flat_key_and_full_records(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
"""Review Focus 4: кириллица, пробелы, `..` и обратные слэши — ключ S3 без путей, имя без каталогов."""
|
||||
entity = await _rostov_entity(seeded)
|
||||
user_id = await _user_id(seeded, KA_RF)
|
||||
client = await auth_client(KA_RF)
|
||||
content = _xls("hostile")
|
||||
|
||||
response = await _post(client, entity.id, ("../..\\ОСВ 60 (2).xls", content))
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
(accepted,) = response.json()["accepted"]
|
||||
file_id, job_id = uuid.UUID(accepted["file_id"]), uuid.UUID(accepted["job_id"])
|
||||
assert accepted["name"] == "ОСВ 60 (2).xls"
|
||||
key = f"uploads/{file_id}/{job_id}/ОСВ 60 (2).xls"
|
||||
assert list(storage.objects) == [key]
|
||||
assert ".." not in key
|
||||
assert "\\" not in key
|
||||
assert key.count("/") == 3 # noqa: PLR2004 - uploads/{file_id}/{job_id}/{имя}
|
||||
|
||||
file = await seeded.get(StoredFile, file_id)
|
||||
job = await seeded.get(RecognitionJob, job_id)
|
||||
assert file is not None and job is not None
|
||||
s3 = await seeded.get(S3Object, file.s3_object_id)
|
||||
assert s3 is not None
|
||||
sha256 = hashlib.sha256(content).hexdigest()
|
||||
assert file.original_name == "ОСВ 60 (2).xls"
|
||||
assert (file.status, file.version_no, file.content_hash, file.size_bytes) == (
|
||||
FileStatus.UPLOADED,
|
||||
1,
|
||||
sha256,
|
||||
len(content),
|
||||
)
|
||||
assert file.uploaded_by == user_id
|
||||
assert file.uploaded_at is not None
|
||||
assert file.current_recognition_job_id == job_id
|
||||
assert job.request_subject == "recognition.tabular.request"
|
||||
assert job.schema_version == "1.0"
|
||||
assert (job.status, job.attempt, job.max_attempts) == (RecognitionStatus.queued, 1, 3)
|
||||
assert job.requested_at is not None
|
||||
assert (s3.bucket_role, s3.bucket, s3.key, s3.content_type) == (
|
||||
S3BucketRole.uploads,
|
||||
storage.bucket,
|
||||
key,
|
||||
XLS_TYPE,
|
||||
)
|
||||
assert (s3.sha256, s3.size_bytes, s3.created_by) == (sha256, len(content), f"user:{user_id}")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_image_file_goes_to_image_pipeline_with_one_attempt(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
entity = await _rostov_entity(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await _post(client, entity.id, ("ББ 2024.pdf", PDF + b"scan"))
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
job = await seeded.get(RecognitionJob, uuid.UUID(response.json()["accepted"][0]["job_id"]))
|
||||
assert job is not None
|
||||
assert (job.pipeline, job.max_attempts, job.request_subject) == (
|
||||
RecognitionPipeline.image,
|
||||
1,
|
||||
"recognition.image.request",
|
||||
)
|
||||
assert len(storage.objects) == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_two_files_write_two_upload_events(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
"""audit-trail «Загрузка двух файлов»: два `FILE_UPLOAD` с `object_id` каждого файла."""
|
||||
entity = await _rostov_entity(seeded)
|
||||
user_id = await _user_id(seeded, KA_RF)
|
||||
client = await auth_client(KA_RF)
|
||||
first, second = _xls("first"), ZIP + b"second"
|
||||
|
||||
response = await _post(client, entity.id, ("ОСВ 60.xls", first), ("ОСВ 62.xlsx", second))
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
accepted = response.json()["accepted"]
|
||||
assert [item["name"] for item in accepted] == ["ОСВ 60.xls", "ОСВ 62.xlsx"]
|
||||
events = await _upload_events(seeded)
|
||||
assert len(events) == len(accepted)
|
||||
by_object = {event.object_id: event for event in events}
|
||||
for item, content, file_type in ((accepted[0], first, "XLS"), (accepted[1], second, "XLSX")):
|
||||
event = by_object[uuid.UUID(item["file_id"])]
|
||||
assert (event.section, event.object_type, event.user_id) == (
|
||||
AuditSection.PRIMARY_DOCUMENTS,
|
||||
"stored_file",
|
||||
user_id,
|
||||
)
|
||||
assert event.after_value == {
|
||||
"original_name": item["name"],
|
||||
"content_hash": hashlib.sha256(content).hexdigest(),
|
||||
"job_id": item["job_id"],
|
||||
"file_type": file_type,
|
||||
}
|
||||
assert len(storage.objects) == len(accepted)
|
||||
|
||||
|
||||
# --- «Смесь принятых и отклонённых», «Расширение не совпадает с содержимым» ---------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_mixed_request_accepts_valid_and_rejects_others(
|
||||
app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
app.dependency_overrides[get_app_settings] = lambda: get_settings().model_copy(
|
||||
update={"UPLOAD_MAX_BYTES": TINY_LIMIT}
|
||||
)
|
||||
entity = await _rostov_entity(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
before = await _counts(seeded)
|
||||
xlsx = ZIP + b"ok" # 6 байт — в пределах лимита
|
||||
big = BIFF8 + b"overflow" # больше лимита — как файл 51 МиБ при 50 МиБ
|
||||
|
||||
response = await _post(client, entity.id, ("osv.xlsx", xlsx), ("report.docx", ZIP + b"d"), ("big.xls", big))
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
body = response.json()
|
||||
assert [item["name"] for item in body["accepted"]] == ["osv.xlsx"]
|
||||
assert {item["name"]: item["code"] for item in body["rejected"]} == {
|
||||
"report.docx": "UNSUPPORTED_FILE",
|
||||
"big.xls": "FILE_TOO_LARGE",
|
||||
}
|
||||
assert all(item["message"] and item["file_id"] is None for item in body["rejected"])
|
||||
after = await _counts(seeded)
|
||||
assert {name: after[name] - before[name] for name in after} == {
|
||||
"s3_object": 1,
|
||||
"stored_file": 1,
|
||||
"recognition_job": 1,
|
||||
"file_upload": 1,
|
||||
}
|
||||
(key,) = storage.objects
|
||||
assert key.endswith("/osv.xlsx")
|
||||
s3 = (await seeded.execute(select(S3Object).where(S3Object.key == key))).scalar_one()
|
||||
assert s3.content_type == XLSX_TYPE
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_signature_mismatch_is_rejected_with_422(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
entity = await _rostov_entity(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
before = await _counts(seeded)
|
||||
|
||||
response = await _post(client, entity.id, ("osv.xlsx", _xls("biff")))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||||
assert response.json()["accepted"] == []
|
||||
(rejected,) = response.json()["rejected"]
|
||||
assert (rejected["name"], rejected["code"]) == ("osv.xlsx", "UNSUPPORTED_FILE")
|
||||
assert await _counts(seeded) == before
|
||||
assert storage.objects == {}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_empty_filename_is_unsupported(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
"""Review Focus 4: имя `..` (после очистки — пустое) — `UNSUPPORTED_FILE`, а не файл без имени."""
|
||||
entity = await _rostov_entity(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await _post(client, entity.id, ("..", _xls("noname")))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||||
(rejected,) = response.json()["rejected"]
|
||||
assert (rejected["name"], rejected["code"]) == ("", "UNSUPPORTED_FILE")
|
||||
assert storage.objects == {}
|
||||
|
||||
|
||||
# --- «Чужое ЮЛ», права, S3 --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_foreign_entity_is_404_like_missing(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
entity = await _rostov_entity(seeded)
|
||||
client = await auth_client(CLIENT_MANAGER)
|
||||
before = await _counts(seeded)
|
||||
|
||||
foreign = await _post(client, entity.id, ("osv.xls", _xls("foreign")))
|
||||
missing = await _post(client, uuid.uuid4(), ("osv.xls", _xls("missing")))
|
||||
|
||||
assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND, foreign.text
|
||||
assert foreign.json() == missing.json()
|
||||
assert foreign.json()["detail"]["code"] == "NOT_FOUND"
|
||||
assert await _counts(seeded) == before
|
||||
assert storage.objects == {}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_upload_requires_document_edit(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
entity = await _rostov_entity(seeded)
|
||||
client = await auth_client(CURATOR_GO)
|
||||
|
||||
response = await _post(client, entity.id, ("osv.xls", _xls("curator")))
|
||||
|
||||
assert response.status_code == HTTP_FORBIDDEN, response.text
|
||||
assert "DOCUMENT_EDIT" in response.json()["detail"]
|
||||
assert storage.objects == {}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_without_storage_is_503(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
entity = await _rostov_entity(seeded) # `app.state.storage` фикстуры `app` — None
|
||||
client = await auth_client(KA_RF)
|
||||
before = await _counts(seeded)
|
||||
|
||||
response = await _post(client, entity.id, ("osv.xls", _xls("nostorage")))
|
||||
|
||||
assert response.status_code == HTTP_UNAVAILABLE, response.text
|
||||
assert response.json()["detail"]["code"] == "STORAGE_UNAVAILABLE"
|
||||
assert await _counts(seeded) == before
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_storage_failure_rejects_only_that_file(
|
||||
app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""Отказ S3 на одном файле откатывает только его SAVEPOINT: принятый файл и его записи остаются."""
|
||||
storage = FlakyStorage(failing_suffix="/broken.xls")
|
||||
app.state.storage = storage
|
||||
entity = await _rostov_entity(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
before = await _counts(seeded)
|
||||
|
||||
response = await _post(client, entity.id, ("good.xls", _xls("good")), ("broken.xls", _xls("broken")))
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
body = response.json()
|
||||
assert [item["name"] for item in body["accepted"]] == ["good.xls"]
|
||||
(rejected,) = body["rejected"]
|
||||
assert (rejected["name"], rejected["code"]) == ("broken.xls", "STORAGE_UNAVAILABLE")
|
||||
after = await _counts(seeded)
|
||||
assert {name: after[name] - before[name] for name in after} == {
|
||||
"s3_object": 1,
|
||||
"stored_file": 1,
|
||||
"recognition_job": 1,
|
||||
"file_upload": 1,
|
||||
}
|
||||
assert [key.rsplit("/", 1)[1] for key in storage.objects] == ["good.xls"]
|
||||
|
||||
|
||||
# --- «Дедуп по содержимому» --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_duplicate_in_same_entity_is_rejected_other_entity_accepts(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
entity = await _rostov_entity(seeded)
|
||||
other = await _rostov_entity(seeded, "ООО Другое")
|
||||
client = await auth_client(KA_RF)
|
||||
content = _xls("dup")
|
||||
first = await _post(client, entity.id, ("ОСВ 60.xls", content))
|
||||
assert first.status_code == HTTP_CREATED, first.text
|
||||
first_id = first.json()["accepted"][0]["file_id"]
|
||||
before = await _counts(seeded)
|
||||
|
||||
again = await _post(client, entity.id, ("ОСВ 60 копия.xls", content))
|
||||
|
||||
assert again.status_code == HTTP_UNPROCESSABLE, again.text
|
||||
(rejected,) = again.json()["rejected"]
|
||||
assert (rejected["code"], rejected["file_id"], rejected["name"]) == ("DUPLICATE_FILE", first_id, "ОСВ 60 копия.xls")
|
||||
assert await _counts(seeded) == before
|
||||
assert len(storage.objects) == 1
|
||||
|
||||
elsewhere = await _post(client, other.id, ("ОСВ 60.xls", content))
|
||||
assert elsewhere.status_code == HTTP_CREATED, elsewhere.text
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_superseded_file_does_not_block_same_content(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
|
||||
) -> None:
|
||||
entity = await _rostov_entity(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
content = _xls("superseded")
|
||||
first = await _post(client, entity.id, ("ОСВ 60.xls", content))
|
||||
first_id = uuid.UUID(first.json()["accepted"][0]["file_id"])
|
||||
await seeded.execute(update(StoredFile).where(StoredFile.id == first_id).values(status=FileStatus.SUPERSEDED))
|
||||
|
||||
again = await _post(client, entity.id, ("ОСВ 60.xls", content))
|
||||
|
||||
assert again.status_code == HTTP_CREATED, again.text
|
||||
assert again.json()["accepted"][0]["file_id"] != str(first_id)
|
||||
assert len(storage.objects) == 2 # noqa: PLR2004 - старый и новый объект
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_concurrent_duplicates_accept_only_one(
|
||||
committed_client: AuthClientFactory,
|
||||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||||
committed_storage: FakeStorage,
|
||||
) -> None:
|
||||
"""Двойной клик: два запроса с одним содержимым одновременно — принят один, второй `DUPLICATE_FILE`."""
|
||||
async with committed_sessionmaker() as session, session.begin():
|
||||
entity = await _rostov_entity(session)
|
||||
first_client, second_client = await committed_client(KA_RF), await committed_client(KA_RF)
|
||||
content = _xls("race")
|
||||
|
||||
responses = await asyncio.gather(
|
||||
_post(first_client, entity.id, ("ОСВ 60.xls", content)),
|
||||
_post(second_client, entity.id, ("ОСВ 60.xls", content)),
|
||||
)
|
||||
|
||||
assert sorted(response.status_code for response in responses) == [HTTP_CREATED, HTTP_UNPROCESSABLE]
|
||||
accepted = next(r for r in responses if r.status_code == HTTP_CREATED).json()["accepted"][0]
|
||||
rejected = next(r for r in responses if r.status_code == HTTP_UNPROCESSABLE).json()["rejected"][0]
|
||||
assert (rejected["code"], rejected["file_id"]) == ("DUPLICATE_FILE", accepted["file_id"])
|
||||
async with committed_sessionmaker() as session:
|
||||
files = (await session.execute(select(func.count()).select_from(StoredFile))).scalar_one()
|
||||
assert files == 1
|
||||
assert len(committed_storage.objects) == 1
|
||||
75
api/tests/test_domain_file_types.py
Normal file
75
api/tests/test_domain_file_types.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Тип загружаемого файла: расширение → `FileType`/pipeline, сверка с сигнатурой (spec primary-files «Загрузка»)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common.exceptions import DomainValidationError
|
||||
from src.domain.file_types import MAX_ATTEMPTS, detect_file
|
||||
from src.models.enums import FileType, RecognitionPipeline
|
||||
|
||||
BIFF8 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 16
|
||||
ZIP = b"PK\x03\x04" + b"\x00" * 16
|
||||
PDF = b"%PDF-1.7\n" + b"\x00" * 16
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||
JPG = b"\xff\xd8\xff\xe0" + b"\x00" * 16
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "content", "file_type", "pipeline", "content_type"),
|
||||
[
|
||||
("ОСВ_66 за 2024.xls", BIFF8, FileType.XLS, RecognitionPipeline.tabular, "application/vnd.ms-excel"),
|
||||
(
|
||||
"osv.xlsx",
|
||||
ZIP,
|
||||
FileType.XLSX,
|
||||
RecognitionPipeline.tabular,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
),
|
||||
(
|
||||
"osv.xlsb",
|
||||
ZIP,
|
||||
FileType.XLSB,
|
||||
RecognitionPipeline.tabular,
|
||||
"application/vnd.ms-excel.sheet.binary.macroEnabled.12",
|
||||
),
|
||||
("ББ 2024.pdf", PDF, FileType.PDF, RecognitionPipeline.image, "application/pdf"),
|
||||
("скан.png", PNG, FileType.PNG, RecognitionPipeline.image, "image/png"),
|
||||
("скан.jpg", JPG, FileType.JPG, RecognitionPipeline.image, "image/jpeg"),
|
||||
],
|
||||
)
|
||||
def test_type_by_extension_and_signature(
|
||||
name: str, content: bytes, file_type: FileType, pipeline: RecognitionPipeline, content_type: str
|
||||
) -> None:
|
||||
detected = detect_file(name, content)
|
||||
assert detected.file_type is file_type
|
||||
assert detected.pipeline is pipeline
|
||||
assert detected.content_type == content_type
|
||||
|
||||
|
||||
def test_extension_is_case_insensitive_and_jpeg_is_jpg() -> None:
|
||||
assert detect_file("ОСВ.XLS", BIFF8).file_type is FileType.XLS
|
||||
assert detect_file("скан.JPEG", JPG).file_type is FileType.JPG
|
||||
|
||||
|
||||
def test_max_attempts_by_pipeline() -> None:
|
||||
assert MAX_ATTEMPTS == {RecognitionPipeline.tabular: 3, RecognitionPipeline.image: 1}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "content"),
|
||||
[
|
||||
("osv.xlsx", BIFF8), # сценарий спеки «Расширение не совпадает с содержимым»
|
||||
("osv.xls", ZIP),
|
||||
("scan.pdf", PNG),
|
||||
("scan.png", JPG),
|
||||
("osv.xls", b""),
|
||||
("report.docx", ZIP), # zip, но не таблица ОСВ
|
||||
("notes.txt", b"text"),
|
||||
("osv", BIFF8), # без расширения
|
||||
("", BIFF8), # Review Focus 4: пустое имя
|
||||
],
|
||||
)
|
||||
def test_unsupported_or_mismatched_file_is_rejected(name: str, content: bytes) -> None:
|
||||
with pytest.raises(DomainValidationError) as error:
|
||||
detect_file(name, content)
|
||||
assert error.value.code == "UNSUPPORTED_FILE"
|
||||
assert error.value.message
|
||||
@ -44,6 +44,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
||||
f"{A}/roles": {"get"},
|
||||
f"{A}/permissions": {"get"},
|
||||
f"{A}/roles/{{code}}/permissions": {"put"},
|
||||
# primary-files (Срез 2c)
|
||||
"/api/v1/legal-entities/{legal_entity_id}/files": {"post"},
|
||||
}
|
||||
|
||||
# Модели, которые обязаны быть в схеме ровно один раз под коротким именем
|
||||
|
||||
@ -116,9 +116,9 @@ class PrimaryFilesService:
|
||||
```
|
||||
- `schemas/files.py`: `UploadResult{accepted: list[AcceptedOut], rejected: list[RejectedOut]}`, `JobBrief{id, status, error_code, error_message, warnings: list[str], mapping_warnings: list[str], finished_at}`, `DocumentBrief{id, document_type, osv_account_code, period_year, period_quarter, is_cumulative, status, period_check, counterparty_check, locator}`, `FileOut{id, legal_entity_id, original_name, file_type, status, version_no, size_bytes, content_hash, uploaded_at, uploaded_by, superseded_by_file_id, job: JobBrief | None, documents: list[DocumentBrief]}` (используется Task 9).
|
||||
|
||||
- [ ] **Step 1: Тесты.** `test_domain_file_types.py`: 6 типов по сигнатуре, `osv.xlsx` с BIFF8 → `UNSUPPORTED_FILE`, `.docx` → `UNSUPPORTED_FILE`, пустое имя → `UNSUPPORTED_FILE` (Review Focus 4). `test_api_files_upload.py` (integration, `auth_client`, `app.state.storage = FakeStorage()`): сценарии спеки `primary-files` «Реальная ОСВ принята» (байты `../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`; затем `publish_pending` с `FakeNats` из `tests/test_recognition_exchange.py` публикует запрос с ключом `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`), «Смесь» (xlsx + docx + 51 МиБ через `UPLOAD_MAX_BYTES=10` в настройках теста), «Сигнатура», «Чужое ЮЛ» (`client_manager` → 404), «Дубль» (второй раз → `DUPLICATE_FILE` с `file_id`; тот же файл у другого ЮЛ → принят); имя `../..\\ОСВ 60 (2).xls` → ключ без `..` и слэшей; `storage=None` → 503; два `FILE_UPLOAD` в аудите.
|
||||
- [ ] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_api_files_upload.py -q`, `make check`.
|
||||
- [ ] **Step 5: Commit** `feat(files): загрузка файлов организации — S3, stored_file, recognition_job в outbox, дедуп, аудит`.
|
||||
- [x] **Step 1: Тесты.** `test_domain_file_types.py`: 6 типов по сигнатуре, `osv.xlsx` с BIFF8 → `UNSUPPORTED_FILE`, `.docx` → `UNSUPPORTED_FILE`, пустое имя → `UNSUPPORTED_FILE` (Review Focus 4). `test_api_files_upload.py` (integration, `auth_client`, `app.state.storage = FakeStorage()`): сценарии спеки `primary-files` «Реальная ОСВ принята» (байты `../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`; затем `publish_pending` с `FakeNats` из `tests/test_recognition_exchange.py` публикует запрос с ключом `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`), «Смесь» (xlsx + docx + 51 МиБ через `UPLOAD_MAX_BYTES=10` в настройках теста), «Сигнатура», «Чужое ЮЛ» (`client_manager` → 404), «Дубль» (второй раз → `DUPLICATE_FILE` с `file_id`; тот же файл у другого ЮЛ → принят); имя `../..\\ОСВ 60 (2).xls` → ключ без `..` и слэшей; `storage=None` → 503; два `FILE_UPLOAD` в аудите.
|
||||
- [x] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_api_files_upload.py -q`, `make check`.
|
||||
- [x] **Step 5: Commit** `feat(files): загрузка файлов организации — S3, stored_file, recognition_job в outbox, дедуп, аудит`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
|
||||
## 2. Загрузка и статусы
|
||||
|
||||
- [ ] 2.1 **Загрузка файлов.** `domain/file_types.py` (расширение → `FileType`/`pipeline`, проверка сигнатуры), `services/primary_files.py`
|
||||
- [x] 2.1 **Загрузка файлов.** `domain/file_types.py` (расширение → `FileType`/`pipeline`, проверка сигнатуры), `services/primary_files.py`
|
||||
(`upload_many`, дедуп, S3, `stored_file`/`recognition_job`/`current_recognition_job_id`, аудит `FILE_UPLOAD`),
|
||||
`schemas/files.py` (`UploadResult{accepted,rejected}`, `FileOut`, `JobBrief`, `DocumentBrief`), эндпоинт
|
||||
`POST /legal-entities/{id}/files`, роутер. Тесты: сценарии спеки `primary-files` (принята, смесь, сигнатура, чужое ЮЛ, дубль);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user