Merge pull request 'test-init' (#1) from test-init into main

Reviewed-on: #1
This commit is contained in:
PotapovaA 2026-09-16 16:19:44 +03:00
commit 1667e11add
9 changed files with 2940 additions and 105 deletions

View File

@ -1,12 +1,27 @@
import asyncio import asyncio
from logging.config import fileConfig from logging.config import fileConfig
from pathlib import Path
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.ext.asyncio import AsyncEngine
from alembic import context from alembic import context
from src.core.config import settings
from src.models import * from src.models import *
class MigrationSettings(BaseSettings):
database_url: str = Field(validation_alias=AliasChoices(
"OPENBAO__DATABASE__DATABASE_URL",
"OPENBAO__SETTINGS__DATABASE_URL",
))
model_config = SettingsConfigDict(
env_file=Path(__file__).resolve().parents[1] / ".env",
extra="ignore",
)
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
config = context.config config = context.config
@ -16,7 +31,7 @@ config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", settings.OPENBAO.DATABASE.DATABASE_URL) config.set_main_option("sqlalchemy.url", MigrationSettings().database_url.replace("%", "%%"))
# add your model's MetaData object here # add your model's MetaData object here
# for 'autogenerate' support # for 'autogenerate' support
# from myapp import mymodel # from myapp import mymodel
@ -45,6 +60,7 @@ def do_run_migrations(connection) -> None:
) )
with context.begin_transaction(): with context.begin_transaction():
context.execute('CREATE SCHEMA IF NOT EXISTS "dkb"')
context.run_migrations() context.run_migrations()
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
@ -71,6 +87,7 @@ def run_migrations_offline() -> None:
) )
with context.begin_transaction(): with context.begin_transaction():
context.execute('CREATE SCHEMA IF NOT EXISTS "dkb"')
context.run_migrations() context.run_migrations()
@ -96,7 +113,9 @@ async def run_migrations_online() -> None:
await connectable.dispose() await connectable.dispose()
if context.is_offline_mode(): if config.attributes.get("connection") is not None:
do_run_migrations(config.attributes["connection"])
elif context.is_offline_mode():
run_migrations_offline() run_migrations_offline()
else: else:
asyncio.run(run_migrations_online()) asyncio.run(run_migrations_online())

View File

@ -1,5 +1,6 @@
import uvicorn import uvicorn
from src import app, get_settings from src.app import app
from src.core.config import get_settings
if __name__ == "__main__": if __name__ == "__main__":
settings = get_settings() settings = get_settings()
@ -9,4 +10,3 @@ if __name__ == "__main__":
port=settings.server.PORT, port=settings.server.PORT,
lifespan="on", lifespan="on",
) )

View File

@ -1,7 +1 @@
from .app import app """API package. Import application entry points explicitly from src.app."""
from .core.config import get_settings
__all__ = [
'app',
'get_settings',
]

View File

@ -24,8 +24,8 @@ from src.core.clients import S3Storage, PollWorker
from src.core.async_cache import AsyncInMemoryCache from src.core.async_cache import AsyncInMemoryCache
# from src.core.logger import setup_logger # from src.core.logger import setup_logger
from nats.aio.client import Client as NATS from nats.aio.client import Client as NATS
import nkeys
import base64 import base64
import nkeys
from loguru import logger from loguru import logger
import os import os
@ -42,20 +42,6 @@ VERSION = "1.0.0"
UP_TIME = datetime.now(timezone.utc) UP_TIME = datetime.now(timezone.utc)
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')
signed = kp.sign(nonce)
return base64.b64encode(signed)
async def sub_cb(msg): async def sub_cb(msg):
return None return None
@ -94,80 +80,75 @@ async def lifespan(app):
app.state.nats_subs = [] app.state.nats_subs = []
app.state.storage = None app.state.storage = None
app.state.nats = None app.state.nats = None
app.state.poller = None
app.state.pdf_poller = None
s3_client = None
smb_registered = False
try: try:
smbclient.register_session(settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD) minio = settings.OPENBAO.MINIO
except: if minio is not None:
logger.exception("Ошибка при регистрации сессии SMB") s3_client = Minio(
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) minio.MINIO_URL,
try: access_key=minio.MINIO_ADMIN_LOGIN,
bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET) secret_key=minio.MINIO_ADMIN_PASSWORD,
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) secure=minio.MINIO_SECURE,
except Exception as exc:
logger.exception(
"Не удалось проверить наличие бакетa. error_category=s3_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
) )
return if not await s3_client.bucket_exists(minio.MINIO_BUCKET):
storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) raise RuntimeError(f"Бакет MinIO {minio.MINIO_BUCKET!r} не создан")
app.state.storage = storage app.state.storage = S3Storage(s3_client, minio.MINIO_BUCKET)
cache = AsyncInMemoryCache() logger.info("MinIO подключён, бакет: {}", minio.MINIO_BUCKET)
nc = NATS()
if not settings.DEBUG: smb = settings.OPENBAO.SMB
try: if smb is not None:
await nc.connect(settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb, smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD,
max_reconnect_attempts=2) port=smb.SMB_PORT)
logger.info("Подключение к NATS произошло") smb_registered = True
except Exception as exc: logger.info("SMB подключён")
logger.exception(
"Не удалось проверить наличие бакетa. error_category=nats_error, error_type={error_type}, error={error}", nats_settings = settings.OPENBAO.NATS
error_type=type(exc).__name__, if nats_settings is not None:
error=repr(exc), nc = NATS()
) app.state.nats = nc
return if not settings.DEBUG:
poller = PollWorker(nc, cache, storage) keypair = nkeys.from_seed(nats_settings.NATS_SEED.encode("utf-8"))
await poller.start()
pdf_poller = PDFPollWorker(cache, storage) def user_jwt_cb():
await pdf_poller.start() return nats_settings.NATS_JWT.encode("utf-8")
app.state.nats = nc
app.state.poller = poller def signature_cb(nonce):
app.state.pdf_poller = pdf_poller return base64.b64encode(keypair.sign(nonce.encode("utf-8")))
if not settings.DEBUG:
handler_cb = build_file_handler(nc, storage) await nc.connect(nats_settings.NATS_URL, user_jwt_cb=user_jwt_cb,
signature_cb=signature_cb, max_reconnect_attempts=2)
logger.info("NATS подключён")
missing = [name for name in ("MINIO", "NATS", "SMB")
if getattr(settings.OPENBAO, name) is None]
if missing:
logger.info("Распознавание файлов отключено: не настроены {}", ", ".join(missing))
else:
cache = AsyncInMemoryCache()
app.state.poller = PollWorker(nc, cache, app.state.storage)
await app.state.poller.start()
app.state.pdf_poller = PDFPollWorker(cache, app.state.storage)
await app.state.pdf_poller.start()
try:
# sub_xls = await nc.subscribe('files.tabular_parsed', cb=handler_cb)
# sub_img = await nc.subscribe('files.img_parsed', cb=handler_cb)
#
# app.state.nats_subs = [sub_xls, sub_img]
logger.info("Подписка на топики завершена")
except Exception as exc:
logger.warning(
"Не удалось подписаться на тему. error_category=nats_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
try:
logger.info("Приложение запущено") logger.info("Приложение запущено")
yield yield
except Exception as exc:
logger.exception("ОШИБКА ПРИ РАБОТЕ С СЕКРЕТАМИ error={error}", error=repr(exc))
finally: finally:
logger.info("Приложение закрывается") for worker in (app.state.poller, app.state.pdf_poller):
smbclient.reset_connection_cache() if worker is not None:
await worker.stop()
if not settings.DEBUG: if not settings.DEBUG:
for sub in app.state.nats_subs: for sub in app.state.nats_subs:
logger.info("Отписываемся от топика")
await sub.unsubscribe() await sub.unsubscribe()
if app.state.nats: if app.state.nats is not None:
await app.state.nats.close() await app.state.nats.close()
if app.state.poller: if s3_client is not None:
await app.state.poller.stop() await s3_client.close_session()
if app.state.storage: if smb_registered:
await app.state.storage.client.close_session() smbclient.reset_connection_cache()
if app.state.pdf_poller: logger.info("Приложение закрывается")
await app.state.pdf_poller.stop()
logger.info("CLOSING APP AT ALL")
app = FastAPI( app = FastAPI(
title="Fast API", title="Fast API",

View File

@ -65,7 +65,7 @@ class PDFPollWorker:
logger.exception("Не получилось достать данные из кэша") logger.exception("Не получилось достать данные из кэша")
jobs = [] jobs = []
all_files = smbclient.listdir(self.smb_path) all_files = smbclient.listdir(self.smb_path, port=settings.OPENBAO.SMB.SMB_PORT)
for job in jobs: for job in jobs:
search_fname = f"{job.file_id}" search_fname = f"{job.file_id}"
@ -85,8 +85,9 @@ class PDFPollWorker:
logger.info("Начинаю обработку файла по пути smb_dir={smb_dir}", smb_dir=new_dir) logger.info("Начинаю обработку файла по пути smb_dir={smb_dir}", smb_dir=new_dir)
files = [] files = []
for fname in smbclient.listdir(new_dir): for fname in smbclient.listdir(new_dir, port=settings.OPENBAO.SMB.SMB_PORT):
with smbclient.open_file(f'{new_dir}/{fname}', 'r') as f: with smbclient.open_file(f'{new_dir}/{fname}', 'r',
port=settings.OPENBAO.SMB.SMB_PORT) as f:
files.append(json.loads(f.read())) files.append(json.loads(f.read()))
s3_json_dict = { s3_json_dict = {
'file_id': job.file_id, 'file_id': job.file_id,

View File

@ -108,7 +108,8 @@ class PollWorker:
if row['subject'] == 'file.img_uploaded': if row['subject'] == 'file.img_uploaded':
last_part = row['row_payload']['s3_key'].split('/')[-1] last_part = row['row_payload']['s3_key'].split('/')[-1]
with smbclient.open_file(f'{settings.OPENBAO.SMB.SMB_PATH}/Файлы/{last_part}', 'wb') as f: with smbclient.open_file(f'{settings.OPENBAO.SMB.SMB_PATH}/Файлы/{last_part}', 'wb',
port=settings.OPENBAO.SMB.SMB_PORT) as f:
data = await self.storage.get_object(row['row_payload']['s3_key']) data = await self.storage.get_object(row['row_payload']['s3_key'])
content = await data.read() content = await data.read()
f.write(content) f.write(content)

View File

@ -1,4 +1,5 @@
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class DBSettings(BaseSettings): class DBSettings(BaseSettings):
@ -9,23 +10,25 @@ class MinioSettings(BaseSettings):
MINIO_ADMIN_LOGIN: str MINIO_ADMIN_LOGIN: str
MINIO_ADMIN_PASSWORD: str MINIO_ADMIN_PASSWORD: str
MINIO_BUCKET: str MINIO_BUCKET: str
MINIO_SECURE: bool = True
class NATSSettings(BaseSettings): class NATSSettings(BaseSettings):
NATS_URL: str NATS_URL: str
NATS_SEED: str NATS_JWT: str = Field(min_length=1)
NATS_JWT: str NATS_SEED: str = Field(min_length=1)
class SMBSettings(BaseSettings): class SMBSettings(BaseSettings):
SMB_HOST: str SMB_HOST: str
SMB_PORT: int = 445
SMB_PATH: str SMB_PATH: str
SMB_LOGIN: str SMB_LOGIN: str
SMB_PASSWORD: str SMB_PASSWORD: str
class OpenBAOSettings(BaseSettings): class OpenBAOSettings(BaseSettings):
DATABASE: DBSettings DATABASE: DBSettings
MINIO: MinioSettings MINIO: MinioSettings | None = None
NATS: NATSSettings NATS: NATSSettings | None = None
SMB: SMBSettings SMB: SMBSettings | None = None
class ServerSettings(BaseSettings): class ServerSettings(BaseSettings):
HOST: str = "0.0.0.0" HOST: str = "0.0.0.0"
@ -39,6 +42,7 @@ class Settings(BaseSettings):
VERSION: str = '1.0.0' VERSION: str = '1.0.0'
class Config: class Config:
case_sensitive = True
extra = "ignore" extra = "ignore"
env_file = ".env" env_file = ".env"
env_nested_delimiter = "__" env_nested_delimiter = "__"

View File

@ -1,7 +1,7 @@
import json import json
import uuid import uuid
from fastapi import UploadFile from fastapi import HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.inspection import inspect from sqlalchemy.inspection import inspect
from loguru import logger from loguru import logger
@ -21,6 +21,8 @@ class FileService:
self.session = session self.session = session
async def save_file(self, file: UploadFile, legal_entity_id: uuid.UUID, quarter: str, year: int): async def save_file(self, file: UploadFile, legal_entity_id: uuid.UUID, quarter: str, year: int):
if self.storage is None:
raise HTTPException(status_code=503, detail="Обработка файлов не настроена")
try: try:
db_payload = { db_payload = {
'original_name': file.filename, 'original_name': file.filename,
@ -70,6 +72,8 @@ class FileService:
return [self._get_transformed_file_job_info(new_file) for new_file in result] return [self._get_transformed_file_job_info(new_file) for new_file in result]
async def get_file_bytes(self, file_id): async def get_file_bytes(self, file_id):
if self.storage is None:
raise HTTPException(status_code=503, detail="Файловое хранилище не настроено")
result = await self.file_repo.get_by_id(file_id) result = await self.file_repo.get_by_id(file_id)
return (await self.storage.get_object(result.s3_key)) return (await self.storage.get_object(result.s3_key))
@ -82,4 +86,3 @@ class FileService:
'original_name': stored_file_db_obj.original_name, 'original_name': stored_file_db_obj.original_name,
'document_type': stored_file_db_obj.document_type 'document_type': stored_file_db_obj.document_type
} }

2832
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff