Compare commits

..

No commits in common. "8b83f155c2f577d6783ceb5ac9488c37519a63c8" and "aa4085209a2cd982184b5626dddb259f9e01530a" have entirely different histories.

10 changed files with 106 additions and 2943 deletions

4
.gitignore vendored
View File

@ -321,8 +321,6 @@ dist
.pnp.* .pnp.*
# Custom ignore # Custom ignore
.local/
/dev-servers/
.DS_Store .DS_Store
Dockerfile Dockerfile
docker-compose.yml docker-compose.yml
@ -340,4 +338,4 @@ graphify-out/
/AGENTS.md /AGENTS.md
/uv.lock /uv.lock
/pyproject.toml /pyproject.toml

View File

@ -1,27 +1,12 @@
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
@ -31,7 +16,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", MigrationSettings().database_url.replace("%", "%%")) config.set_main_option("sqlalchemy.url", settings.OPENBAO.DATABASE.DATABASE_URL)
# 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
@ -60,7 +45,6 @@ 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:
@ -87,7 +71,6 @@ 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()
@ -113,9 +96,7 @@ async def run_migrations_online() -> None:
await connectable.dispose() await connectable.dispose()
if config.attributes.get("connection") is not None: if context.is_offline_mode():
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,6 +1,5 @@
import uvicorn import uvicorn
from src.app import app from src import app, get_settings
from src.core.config import get_settings
if __name__ == "__main__": if __name__ == "__main__":
settings = get_settings() settings = get_settings()
@ -10,3 +9,4 @@ if __name__ == "__main__":
port=settings.server.PORT, port=settings.server.PORT,
lifespan="on", lifespan="on",
) )

View File

@ -1 +1,7 @@
"""API package. Import application entry points explicitly from src.app.""" from .app import 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 base64
import nkeys import nkeys
import base64
from loguru import logger from loguru import logger
import os import os
@ -42,6 +42,20 @@ 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
@ -80,75 +94,80 @@ 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:
minio = settings.OPENBAO.MINIO smbclient.register_session(settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD)
if minio is not None: except:
s3_client = Minio( logger.exception("Ошибка при регистрации сессии SMB")
minio.MINIO_URL, 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)
access_key=minio.MINIO_ADMIN_LOGIN, try:
secret_key=minio.MINIO_ADMIN_PASSWORD, bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET)
secure=minio.MINIO_SECURE, logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists)
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
storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
app.state.storage = storage
cache = AsyncInMemoryCache()
nc = NATS()
if not settings.DEBUG:
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(
"Не удалось проверить наличие бакетa. error_category=nats_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
) )
if not await s3_client.bucket_exists(minio.MINIO_BUCKET): return
raise RuntimeError(f"Бакет MinIO {minio.MINIO_BUCKET!r} не создан") poller = PollWorker(nc, cache, storage)
app.state.storage = S3Storage(s3_client, minio.MINIO_BUCKET) await poller.start()
logger.info("MinIO подключён, бакет: {}", minio.MINIO_BUCKET) pdf_poller = PDFPollWorker(cache, storage)
await pdf_poller.start()
smb = settings.OPENBAO.SMB app.state.nats = nc
if smb is not None: app.state.poller = poller
smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD, app.state.pdf_poller = pdf_poller
port=smb.SMB_PORT) if not settings.DEBUG:
smb_registered = True handler_cb = build_file_handler(nc, storage)
logger.info("SMB подключён")
nats_settings = settings.OPENBAO.NATS
if nats_settings is not None:
nc = NATS()
app.state.nats = nc
if not settings.DEBUG:
keypair = nkeys.from_seed(nats_settings.NATS_SEED.encode("utf-8"))
def user_jwt_cb():
return nats_settings.NATS_JWT.encode("utf-8")
def signature_cb(nonce):
return base64.b64encode(keypair.sign(nonce.encode("utf-8")))
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:
for worker in (app.state.poller, app.state.pdf_poller): logger.info("Приложение закрывается")
if worker is not None: smbclient.reset_connection_cache()
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 is not None: if app.state.nats:
await app.state.nats.close() await app.state.nats.close()
if s3_client is not None: if app.state.poller:
await s3_client.close_session() await app.state.poller.stop()
if smb_registered: if app.state.storage:
smbclient.reset_connection_cache() await app.state.storage.client.close_session()
logger.info("Приложение закрывается") if app.state.pdf_poller:
await app.state.pdf_poller.stop()
logger.info("CLOSING APP AT ALL")
app = FastAPI( app = FastAPI(
title="Fast API", title="Fast API",
@ -228,4 +247,4 @@ class SPAStaticFiles(StaticFiles):
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web") app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
app.mount("/", StaticFiles(directory="web", html=True), name="web") app.mount("/", StaticFiles(directory="web", html=True), name="web")

View File

@ -65,7 +65,7 @@ class PDFPollWorker:
logger.exception("Не получилось достать данные из кэша") logger.exception("Не получилось достать данные из кэша")
jobs = [] jobs = []
all_files = smbclient.listdir(self.smb_path, port=settings.OPENBAO.SMB.SMB_PORT) all_files = smbclient.listdir(self.smb_path)
for job in jobs: for job in jobs:
search_fname = f"{job.file_id}" search_fname = f"{job.file_id}"
@ -85,9 +85,8 @@ 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, port=settings.OPENBAO.SMB.SMB_PORT): for fname in smbclient.listdir(new_dir):
with smbclient.open_file(f'{new_dir}/{fname}', 'r', with smbclient.open_file(f'{new_dir}/{fname}', 'r') as f:
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,8 +108,7 @@ 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', with smbclient.open_file(f'{settings.OPENBAO.SMB.SMB_PATH}/Файлы/{last_part}', 'wb') as f:
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)
@ -178,4 +177,4 @@ class PollWorker:
file_stmt = update(StoredFile).where(StoredFile.current_recognition_job_id == row_id).values(status=None) file_stmt = update(StoredFile).where(StoredFile.current_recognition_job_id == row_id).values(status=None)
await session.execute(stmt) await session.execute(stmt)
await session.execute(file_stmt) await session.execute(file_stmt)
await session.commit() await session.commit()

View File

@ -1,5 +1,4 @@
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class DBSettings(BaseSettings): class DBSettings(BaseSettings):
@ -10,25 +9,23 @@ 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_JWT: str = Field(min_length=1) NATS_SEED: str
NATS_SEED: str = Field(min_length=1) NATS_JWT: str
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 | None = None MINIO: MinioSettings
NATS: NATSSettings | None = None NATS: NATSSettings
SMB: SMBSettings | None = None SMB: SMBSettings
class ServerSettings(BaseSettings): class ServerSettings(BaseSettings):
HOST: str = "0.0.0.0" HOST: str = "0.0.0.0"
@ -42,7 +39,6 @@ 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 = "__"
@ -51,4 +47,4 @@ class Settings(BaseSettings):
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings() return Settings()
settings = get_settings() settings = get_settings()

View File

@ -1,7 +1,7 @@
import json import json
import uuid import uuid
from fastapi import HTTPException, UploadFile from fastapi import 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,8 +21,6 @@ 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,
@ -72,8 +70,6 @@ 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))
@ -86,3 +82,4 @@ 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

File diff suppressed because it is too large Load Diff