Merge pull request 'test-init' (#1) from test-init into main
Reviewed-on: #1
This commit is contained in:
commit
1667e11add
@ -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())
|
||||||
|
|||||||
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
|
||||||
]
|
|
||||||
|
|||||||
139
api/src/app.py
139
api/src/app.py
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 = "__"
|
||||||
|
|||||||
@ -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
2832
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user