Срез 0 [1.2]: каркас приложения без БД и фронта, настройки scan-gateway

- app.py: create_app(settings), SMB-сессия только при SCAN_HOTFOLDER_MODE=smb, без SPA/CORS/РАИСА/nkeys;
  /healthcheck отдаёт uptime и version
- config.py: ENV_FILE, get_settings() с lru_cache, параметры горячих папок/NATS, блок OPENBAO__SMB необязателен,
  валидация режимов local/smb; убраны DBSettings и OPENBAO.DATABASE
- exception_handlers.py: убрана зависимость от sqlalchemy
- requirements.txt: убраны asyncpg, SQLAlchemy, alembic, argon2-cffi(+bindings), raisa_*, greenlet, Mako
- api/.env.example: минимальный local-режим против compose; unit-тесты config/app

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 02:27:42 +04:00
parent 31a50879c2
commit b6eecfcf34
11 changed files with 356 additions and 206 deletions

3
.gitignore vendored
View File

@ -330,3 +330,6 @@ my_project_env
.codegraph/
.superpowers/
.bounded
# scan-gateway: локальная горячая папка dev-режима
api/.hotfolder/

20
api/.env.example Normal file
View File

@ -0,0 +1,20 @@
# Локальное окружение scan-gateway. Скопируйте в .env: cp .env.example .env (.env в git не попадает)
# Значения совпадают с ../docs/dev/docker-compose.yml и ../docs/dev/.env (compose: NATS 4222, MinIO 9000, бакет arm-ka).
# Файл окружения можно выбрать переменной ENV_FILE (по умолчанию .env, путь от каталога api/).
DEBUG=true
# MinIO: адрес без схемы (host:port) — так ждёт miniopy-async; MinIO из compose работает по http
OPENBAO__MINIO__MINIO_URL=localhost:9000
OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin
OPENBAO__MINIO__MINIO_BUCKET=arm-ka
OPENBAO__MINIO__MINIO_SECURE=false
# NATS без JetStream; NATS_SEED/NATS_JWT не задаются (пусто — подключение без nkeys)
OPENBAO__NATS__NATS_URL=nats://localhost:4222
# Горячие папки ИС Сканирование: local — обычный каталог (каталог должен существовать: mkdir -p .hotfolder).
# Режим smb требует блока OPENBAO__SMB__SMB_HOST / SMB_PATH / SMB_LOGIN / SMB_PASSWORD.
SCAN_HOTFOLDER_MODE=local
SCAN_HOTFOLDER_PATH=./.hotfolder

View File

@ -2,13 +2,9 @@ aiohappyeyeballs==2.7.1
aiohttp==3.14.3
aiohttp-retry==2.9.1
aiosignal==1.4.0
alembic==1.14.0
annotated-doc==0.0.4
annotated-types==0.8.0
anyio==4.14.2
argon2-cffi==23.1.0
argon2-cffi-bindings==25.1.0
asyncpg==0.30.0
attrs==26.1.0
certifi==2025.11.12
cffi==2.1.0
@ -20,7 +16,6 @@ fastapi-cli==0.0.24
fastapi-cloud-cli==0.14.1
fastar==0.8.0
frozenlist==1.8.0
greenlet==3.4.0
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
@ -28,7 +23,6 @@ httpx==0.28.1
idna==3.18
Jinja2==3.1.6
loguru==0.7.3
Mako==1.3.10
markdown-it-py==4.2.0
MarkupSafe==3.0.3
mdurl==0.1.2
@ -48,13 +42,11 @@ PyNaCl==1.5.0
python-dotenv==1.2.2
python-multipart==0.0.32
PyYAML==6.0.3
raisa_fastapi_protected_api==1.0.0
rich==15.0.0
rich-toolkit==0.19.7
rignore==0.7.6
sentry-sdk==2.66.1
shellingham==1.5.4
SQLAlchemy==2.0.36
smbprotocol==1.15.0
starlette==1.3.1
typer==0.27.0

View File

@ -1,69 +1,23 @@
"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода.
"""Точка входа FastAPI сервиса scan-gateway (адаптер ИС Сканирование). Без БД и без фронта.
Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT), SMB (горячие папки ИС Сканирование),
middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck, раздача собранного SPA.
Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result
(docs/context/03-architecture.md), зависимость require(permission).
Что здесь есть: настройки, регистрация SMB-сессии (только при `SCAN_HOTFOLDER_MODE=smb`), middleware (gzip, proxy
headers), обработчики ошибок, `GET /healthcheck` (`uptime`, `version`).
Чего здесь пока нет: NATS-воркера и S3-клиента (подключаются в lifespan задачами 5.2/5.3 change slice-0-skeleton),
полей `nats`/`hotfolder` в healthcheck.
"""
import base64
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import nkeys
import smbclient
from fastapi import FastAPI
from fastapi.exceptions import HTTPException, StarletteHTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from loguru import logger
from miniopy_async import Minio
from nats.aio.client import Client as NATS
from starlette.middleware.gzip import GZipMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from src.api.router import router
from src.core.clients import S3Storage
from src.core.config import get_settings
from src.core.config import Settings, get_settings
from src.core.exception_handlers import register_exception_handlers
settings = get_settings()
VERSION = settings.VERSION
UP_TIME = datetime.now(timezone.utc)
# --- NATS: аутентификация по nkeys/JWT ---------------------------------------------------------
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8"))
def jwt_cb():
return settings.OPENBAO.NATS.NATS_JWT.encode("utf-8")
def sig_cb(nonce):
if isinstance(nonce, str):
nonce = nonce.encode("utf-8")
return base64.b64encode(kp.sign(nonce))
# --- РАИСА: защищённый API (только вне DEBUG) ---------------------------------------------------
protected_middleware_available = False
if not settings.DEBUG:
try:
from raisa_fastapi_protected_api import ( # pyright: ignore[reportMissingImports]
AuthorizationMiddleware,
OpenEndpoint,
ProtectedOAuthSettings,
ProtectedRolesSettings,
ProtectedSettings,
SearchType,
)
protected_middleware_available = True
except ImportError:
protected_middleware_available = False
def _normalize_root_path(value: str) -> str:
if not value:
@ -74,116 +28,40 @@ def _normalize_root_path(value: str) -> str:
return "/" + value.strip("/")
_APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH)
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
version = settings.VERSION
up_time = datetime.now(timezone.utc)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.nats_subs = []
app.state.storage = None
app.state.nats = None
try:
smbclient.register_session(
settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD
)
except Exception:
logger.exception("Ошибка при регистрации сессии SMB")
s3_client = Minio(
settings.OPENBAO.MINIO.MINIO_URL,
access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN,
secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD,
cert_check=False,
)
try:
bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET)
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists)
except Exception as exc:
logger.exception(
"Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
return
app.state.storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
nc = NATS()
if not settings.DEBUG:
@asynccontextmanager
async def lifespan(app: FastAPI):
smb_session = False
if settings.SCAN_HOTFOLDER_MODE == "smb":
smb = settings.OPENBAO.SMB
try:
smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD)
smb_session = True
except Exception:
logger.exception("Ошибка при регистрации сессии SMB")
try:
await nc.connect(
settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb, max_reconnect_attempts=2
)
logger.info("Подключение к NATS произошло")
except Exception as exc:
logger.exception(
"Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
return
app.state.nats = nc
# Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md
logger.info("Приложение запущено")
yield
finally:
logger.info("Приложение закрывается")
if smb_session:
smbclient.reset_connection_cache()
try:
logger.info("Приложение запущено")
yield
finally:
logger.info("Приложение закрывается")
smbclient.reset_connection_cache()
for sub in app.state.nats_subs:
await sub.unsubscribe()
if app.state.nats and not settings.DEBUG:
await app.state.nats.close()
if app.state.storage:
await app.state.storage.client.close_session()
app = FastAPI(title="scan-gateway", version=version, root_path=settings.ROOT_PATH, lifespan=lifespan)
@app.get("/healthcheck", status_code=200)
async def healthcheck():
return {"uptime": up_time, "version": version}
app.include_router(router)
register_exception_handlers(app)
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
return app
app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan)
@app.get("/healthcheck", status_code=200)
async def healthcheck():
return {"uptime": UP_TIME, "version": VERSION}
app.include_router(router)
register_exception_handlers(app)
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Раздача собранного SPA (web/) ---------------------------------------------------------------
os.makedirs("web", exist_ok=True)
def sep_static(path: str, stat_dir: str) -> str:
sep_path = path.split("/")
index = sep_path.index(stat_dir)
return "/".join(sep_path[index:])
class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
try:
return await super().get_response(path, scope)
except (HTTPException, StarletteHTTPException) as ex:
if ex.status_code == 404:
if "images" in path:
return await super().get_response(sep_static(path, "images"), scope)
if "static" in path:
return await super().get_response(sep_static(path, "static"), scope)
return await super().get_response("index.html", scope)
raise
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
app = create_app()

View File

@ -1,50 +1,97 @@
"""Настройки сервиса (pydantic-settings).
Вложенные модели — тоже BaseSettings (как в шаблоне): имена полей в ENV регистронезависимы только для них.
Секреты платформы приходят вложенными переменными `OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__*`;
прикладные параметры — обычными именами верхнего уровня (`SCAN_HOTFOLDER_MODE`, `SCAN_POLL_SECONDS`, ...).
Файл окружения выбирается переменной `ENV_FILE` (по умолчанию `.env`, путь считается от текущего каталога — `api/`);
переменные окружения приоритетнее файла. Настройки читаются при первом вызове `get_settings()`, не при импорте.
"""
import os
from functools import lru_cache
from typing import Literal
from pydantic import PositiveFloat, PositiveInt, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class DBSettings(BaseSettings):
DATABASE_URL: str
class MinioSettings(BaseSettings):
MINIO_URL: str
MINIO_ADMIN_LOGIN: str
MINIO_ADMIN_PASSWORD: str
MINIO_BUCKET: str
MINIO_SECURE: bool = True # https (платформа); false — http (MinIO из docker compose)
class NATSSettings(BaseSettings):
NATS_URL: str
NATS_SEED: str
NATS_JWT: str
NATS_SEED: str = "" # пусто — подключение к NATS без nkeys/JWT (локальная разработка, тесты)
NATS_JWT: str = ""
class SMBSettings(BaseSettings):
SMB_HOST: str
SMB_PATH: str
SMB_LOGIN: str
SMB_PASSWORD: str
"""SMB-шара горячих папок ИС Сканирование. Нужна только при `SCAN_HOTFOLDER_MODE=smb` (там все поля обязательны,
проверяет `Settings`); в режиме local весь блок можно не задавать."""
SMB_HOST: str = ""
SMB_PATH: str = ""
SMB_LOGIN: str = ""
SMB_PASSWORD: str = ""
class OpenBAOSettings(BaseSettings):
DATABASE: DBSettings
MINIO: MinioSettings
NATS: NATSSettings
SMB: SMBSettings
SMB: SMBSettings = SMBSettings() # весь блок необязателен: в режиме local не требуется
class ServerSettings(BaseSettings):
HOST: str = "0.0.0.0"
PORT: int = 8000
class Settings(BaseSettings):
OPENBAO: OpenBAOSettings
DEBUG: bool = False
server: ServerSettings = ServerSettings()
ROOT_PATH: str = '/'
VERSION: str = '1.0.0'
ROOT_PATH: str = "/"
VERSION: str = "1.0.0"
SERVICE_NAME: str = "scan-gateway" # service.name в конверте recognition.result
class Config:
extra = "ignore"
env_file = ".env"
env_nested_delimiter = "__"
nested_model_default_partial_update = False
# NATS: подписка на recognition.image.request и публикация. Выключено — воркер не запускается (dev без NATS)
NATS_ENABLED: bool = True
PUBLISH_RETRIES: PositiveInt = 3 # попытки публикации результата/ack
DONE_CACHE_SIZE: PositiveInt = 1000 # размер LRU обработанных job_id (дедупликация повторов)
# Горячие папки ИС Сканирование: local — обычный каталог (dev, тесты), smb — SMB-шара платформы
SCAN_HOTFOLDER_MODE: Literal["local", "smb"] = "local"
SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать)
SCAN_INPUT_DIR: str = "Input"
SCAN_OUTPUT_DIR: str = "Output"
SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих»
SCAN_TIMEOUT_SECONDS: PositiveFloat = 300 # потолок ожидания ответа ИС (дополнительно к reply_deadline запроса)
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
model_config = SettingsConfigDict(
extra="ignore",
env_file_encoding="utf-8",
env_nested_delimiter="__",
nested_model_default_partial_update=False,
)
@model_validator(mode="after")
def _check_hotfolder(self) -> "Settings":
if self.SCAN_HOTFOLDER_MODE == "local" and not self.SCAN_HOTFOLDER_PATH.strip():
raise ValueError("SCAN_HOTFOLDER_MODE=local требует SCAN_HOTFOLDER_PATH (корень горячей папки)")
if self.SCAN_HOTFOLDER_MODE == "smb":
missing = [name for name, value in self.OPENBAO.SMB.model_dump().items() if not value.strip()]
if missing:
raise ValueError(
"SCAN_HOTFOLDER_MODE=smb требует OPENBAO__SMB__SMB_HOST/SMB_PATH/SMB_LOGIN/SMB_PASSWORD; "
f"не заданы: {', '.join(missing)}"
)
return self
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
return Settings(_env_file=os.environ.get("ENV_FILE", ".env"))

View File

@ -1,7 +1,6 @@
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
def register_exception_handlers(app: FastAPI) -> None:
@ -12,20 +11,6 @@ def register_exception_handlers(app: FastAPI) -> None:
content={"detail": exc.errors()},
)
@app.exception_handler(IntegrityError)
async def integrity_error_handler(_: Request, exc: IntegrityError):
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": "нарушение целостности данных в БД"},
)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_error_handler(_: Request, exc: SQLAlchemyError):
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "ошибка базы данных"},
)
@app.exception_handler(HTTPException)
async def http_exception_handler(_: Request, exc: HTTPException):
# Не ломаем существующие ответы endpoint-ов и прокидываем исходный detail.

View File

@ -0,0 +1,27 @@
"""Общие фикстуры. Минимальное окружение local-режима задаётся здесь, чтобы `import src` (он создаёт приложение)
работал без `.env.test` и без инфраструктуры; значения можно переопределить переменными окружения."""
import os
import pytest
_MINIMAL_ENV = {
"OPENBAO__MINIO__MINIO_URL": "localhost:9000",
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "minioadmin",
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "minioadmin",
"OPENBAO__MINIO__MINIO_BUCKET": "arm-ka",
"OPENBAO__NATS__NATS_URL": "nats://localhost:4222",
"SCAN_HOTFOLDER_MODE": "local",
"SCAN_HOTFOLDER_PATH": "/tmp/scan-gateway-test-hotfolder",
}
for _key, _value in _MINIMAL_ENV.items():
os.environ.setdefault(_key, _value)
@pytest.fixture(autouse=True)
def _fresh_settings():
"""get_settings кэшируется (lru_cache): сбрасываем кэш до и после каждого теста."""
from src.core.config import get_settings
get_settings.cache_clear()
yield
get_settings.cache_clear()

View File

@ -0,0 +1,76 @@
from datetime import datetime
from unittest.mock import MagicMock
import httpx
import smbclient
from src.app import create_app
from src.core.config import get_settings
def _client(app) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")
async def test_healthcheck_returns_uptime_and_version_without_infrastructure(monkeypatch):
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
app = create_app(get_settings())
# lifespan не запускается: подключений к NATS/S3 нет
async with _client(app) as client:
response = await client.get("/healthcheck")
assert response.status_code == 200
body = response.json()
assert set(body) == {"uptime", "version"}
assert body["version"] == get_settings().VERSION
datetime.fromisoformat(body["uptime"])
async def test_lifespan_does_not_register_smb_session_in_local_mode(monkeypatch):
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local")
register = MagicMock()
monkeypatch.setattr(smbclient, "register_session", register)
app = create_app(get_settings())
async with app.router.lifespan_context(app), _client(app) as client:
assert (await client.get("/healthcheck")).status_code == 200
register.assert_not_called()
async def test_lifespan_registers_smb_session_in_smb_mode(monkeypatch):
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb")
monkeypatch.setenv("OPENBAO__SMB__SMB_HOST", "smb.example")
monkeypatch.setenv("OPENBAO__SMB__SMB_PATH", "share")
monkeypatch.setenv("OPENBAO__SMB__SMB_LOGIN", "user")
monkeypatch.setenv("OPENBAO__SMB__SMB_PASSWORD", "secret")
register = MagicMock()
reset = MagicMock()
monkeypatch.setattr(smbclient, "register_session", register)
monkeypatch.setattr(smbclient, "reset_connection_cache", reset)
app = create_app(get_settings())
async with app.router.lifespan_context(app), _client(app) as client:
assert (await client.get("/healthcheck")).status_code == 200
register.assert_called_once_with("smb.example", "user", "secret")
reset.assert_called_once()
async def test_unhandled_error_has_json_500(monkeypatch):
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
app = create_app(get_settings())
@app.get("/boom")
async def boom():
raise RuntimeError("x")
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/boom")
assert response.status_code == 500
assert response.json() == {"detail": "внутренняя ошибка сервера"}

View File

@ -0,0 +1,122 @@
import pytest
from pydantic import ValidationError
from src.core.config import Settings, get_settings
SMB_ENV = {
"OPENBAO__SMB__SMB_HOST": "smb.example",
"OPENBAO__SMB__SMB_PATH": "share",
"OPENBAO__SMB__SMB_LOGIN": "user",
"OPENBAO__SMB__SMB_PASSWORD": "secret",
}
def _clean_env(monkeypatch):
"""Убрать из окружения всё, что относится к SMB/NATS-учётным данным и режиму горячей папки."""
for key in list(SMB_ENV) + ["OPENBAO__NATS__NATS_SEED", "OPENBAO__NATS__NATS_JWT", "SCAN_HOTFOLDER_MODE"]:
monkeypatch.delenv(key, raising=False)
def test_defaults_local_mode_without_smb_and_nats_credentials(monkeypatch):
_clean_env(monkeypatch)
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
s = get_settings()
assert s.SERVICE_NAME == "scan-gateway"
assert s.NATS_ENABLED is True
assert s.PUBLISH_RETRIES == 3
assert s.DONE_CACHE_SIZE == 1000
assert s.SCAN_HOTFOLDER_MODE == "local"
assert s.SCAN_INPUT_DIR == "Input"
assert s.SCAN_OUTPUT_DIR == "Output"
assert s.SCAN_POLL_SECONDS == 5
assert s.SCAN_TIMEOUT_SECONDS == 300
assert s.SCAN_MAX_INFLIGHT == 4
assert s.OPENBAO.SMB.SMB_HOST == ""
assert s.OPENBAO.NATS.NATS_SEED == ""
assert s.OPENBAO.NATS.NATS_JWT == ""
assert not hasattr(s.OPENBAO, "DATABASE")
def test_local_mode_requires_hotfolder_path(monkeypatch):
_clean_env(monkeypatch)
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
monkeypatch.setenv("SCAN_HOTFOLDER_PATH", "")
with pytest.raises(ValidationError, match="SCAN_HOTFOLDER_PATH"):
get_settings()
def test_smb_mode_without_smb_block_fails_with_clear_error(monkeypatch):
_clean_env(monkeypatch)
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb")
with pytest.raises(ValidationError, match="OPENBAO__SMB"):
get_settings()
def test_smb_mode_with_smb_block_ok(monkeypatch):
_clean_env(monkeypatch)
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb")
for key, value in SMB_ENV.items():
monkeypatch.setenv(key, value)
s = get_settings()
assert s.OPENBAO.SMB.SMB_HOST == "smb.example"
def test_unknown_hotfolder_mode_rejected(monkeypatch):
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "ftp")
with pytest.raises(ValidationError):
get_settings()
def test_env_file_selected_by_env_file_variable(monkeypatch, tmp_path):
_clean_env(monkeypatch)
for key in (
"SCAN_HOTFOLDER_PATH",
"SCAN_POLL_SECONDS",
"SERVER__PORT",
"OPENBAO__NATS__NATS_URL",
"OPENBAO__MINIO__MINIO_URL",
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN",
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD",
"OPENBAO__MINIO__MINIO_BUCKET",
):
monkeypatch.delenv(key, raising=False)
env_file = tmp_path / "custom.env"
env_file.write_text(
"\n".join(
[
"OPENBAO__MINIO__MINIO_URL=minio.test:9000",
"OPENBAO__MINIO__MINIO_ADMIN_LOGIN=a",
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=b",
"OPENBAO__MINIO__MINIO_BUCKET=bucket",
"OPENBAO__NATS__NATS_URL=nats://nats.test:4222",
"SCAN_HOTFOLDER_PATH=/data/hot",
"SCAN_POLL_SECONDS=1",
"SERVER__PORT=8123",
]
)
)
monkeypatch.setenv("ENV_FILE", str(env_file))
s = get_settings()
assert s.OPENBAO.MINIO.MINIO_URL == "minio.test:9000"
assert s.OPENBAO.NATS.NATS_URL == "nats://nats.test:4222"
assert s.SCAN_HOTFOLDER_PATH == "/data/hot"
assert s.SCAN_POLL_SECONDS == 1
assert s.server.PORT == 8123
def test_get_settings_is_cached(monkeypatch):
monkeypatch.setenv("ENV_FILE", "/nonexistent/.env")
assert get_settings() is get_settings()
assert isinstance(get_settings(), Settings)

View File

@ -24,7 +24,7 @@
### Task 2: [1.2] `src/app.py`
- [ ] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию (только при `SCAN_HOTFOLDER_MODE=smb`); `src/core/config.py`: убрать `DBSettings`; добавить `ENV_FILE`, `SERVICE_NAME="scan-gateway"`, `NATS_ENABLED=true`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`, `SCAN_HOTFOLDER_MODE="local"`, `SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR="Input"`, `SCAN_OUTPUT_DIR="Output"`, `SCAN_POLL_SECONDS=5`, `SCAN_TIMEOUT_SECONDS=300`, `SCAN_MAX_INFLIGHT=4`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: чистый venv (python3.11), `python run_server.py` с `.env.example`, `/healthcheck` 200
- [x] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию (только при `SCAN_HOTFOLDER_MODE=smb`); `src/core/config.py`: убрать `DBSettings`; добавить `ENV_FILE`, `SERVICE_NAME="scan-gateway"`, `NATS_ENABLED=true`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`, `SCAN_HOTFOLDER_MODE="local"`, `SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR="Input"`, `SCAN_OUTPUT_DIR="Output"`, `SCAN_POLL_SECONDS=5`, `SCAN_TIMEOUT_SECONDS=300`, `SCAN_MAX_INFLIGHT=4`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: чистый venv (python3.11), `python run_server.py` с `.env.example`, `/healthcheck` 200
### Task 3: [1.3] `README.md` — описание сервиса (назначение, протокол горячих папок, env, запуск

View File

@ -7,7 +7,7 @@
- [x] 1.1 Удалить `web/`, `setup/.npmrc`, npm-шаги `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`,
`api/src/models/`, `api/src/repositories/`; убрать `Dockerfile` из `.gitignore`; проверка: `git status` — только удаления и `.gitignore`
- [ ] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию
- [x] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию
(только при `SCAN_HOTFOLDER_MODE=smb`); `src/core/config.py`: убрать `DBSettings`; добавить `ENV_FILE`,
`SERVICE_NAME="scan-gateway"`, `NATS_ENABLED=true`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`,
`SCAN_HOTFOLDER_MODE="local"`, `SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR="Input"`, `SCAN_OUTPUT_DIR="Output"`,