chore: каркас из шаблона команды (DKB_ARMKA@ba172f8, доменный код удалён)
This commit is contained in:
commit
ad4f4559a9
330
.gitignore
vendored
Normal file
330
.gitignore
vendored
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
api/.env
|
||||||
|
api/.venv
|
||||||
|
api/env/
|
||||||
|
api/venv/
|
||||||
|
api/ENV/
|
||||||
|
api/env.bak/
|
||||||
|
api/venv.bak/
|
||||||
|
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# Custom ignore
|
||||||
|
.DS_Store
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
.vscode
|
||||||
|
my_project_env
|
||||||
|
.cursorignore
|
||||||
18
CLAUDE.md
Normal file
18
CLAUDE.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# CLAUDE.md — scan-gateway
|
||||||
|
|
||||||
|
Общий контекст системы — `../CLAUDE.md` и `../docs/`. Этот файл — только про этот сервис.
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: переименовать файл `UUID_имя`, положить в «Входящие»,
|
||||||
|
забрать `Исходящие/UUID/UUID.json`, сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется),
|
||||||
|
нормализовать в `../docs/contracts/recognition-result.schema.json` (`statementDocument`), опубликовать
|
||||||
|
`recognition.result`. Таймаут 5 мин → `timeout`. **Нет доступа к БД.**
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14): confidence → 0–100,
|
||||||
|
поля организации/даты — по тому, что реально приходит.
|
||||||
|
- Один PDF может содержать ББ и ОФР → два документа в результате.
|
||||||
|
- Dev-режим: эмулятор ИС Сканирование, возвращающий `aep_N.json` по имени файла (`SCAN_EMULATOR=1`).
|
||||||
|
|
||||||
|
## Соглашения шаблона
|
||||||
|
<!-- заполняет агент -->
|
||||||
163
README.md
Normal file
163
README.md
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
[](https://github.com/psf/black) [](https://pycqa.github.io/isort/) [](https://github.com/pylint-dev/pylint)
|
||||||
|
__________________________________________________________________________________________________________
|
||||||
|
# dkb_armka, v 0.0.1
|
||||||
|
____
|
||||||
|
|
||||||
|
| | Исполнитель | Заказчик |
|
||||||
|
|-----------|:----------------------------------------:|:----------------------------------------------:|
|
||||||
|
| Сотрудник | raykov-mse | raykov-mse |
|
||||||
|
| Отдел | aurora | aurora |
|
||||||
|
| Контакты | raykovmse@rshb.ru | raykovmse@rshb.ru |
|
||||||
|
-----
|
||||||
|
# Шаблон FastAPI
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Данное шаблон предоставляет проект, в котором приложение состоит из API и UI. При использовании данного типа проекта, Вы можете разрабатывать, как отдельные API сервисы, так и WEB приложения с интерфейсом. Для построения WEB интерфейса используется React. Далее описаны основные иснтрументы предустановленные данным шаблоном:
|
||||||
|
|
||||||
|
### Подготовка к разработке
|
||||||
|
|
||||||
|
Для удобной настройки проекта под разработку. Перейдите в каталог setup и выполните слудеющие команды:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd setup
|
||||||
|
chmod +x setup.sh
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
По итогу успешного выполнения Вы получите сообщение следующего
|
||||||
|
|
||||||
|
```bash
|
||||||
|
complete setup web application
|
||||||
|
To start api, cd to api directory, and use venv, start run_server.py
|
||||||
|
To start api, cd to web directory, and use command npm run start!
|
||||||
|
Happy Hacking! :)
|
||||||
|
```
|
||||||
|
|
||||||
|
Далее просто запускайте проекты и приступайте к разработке
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Исходный код API находится в каталоге api. После клонирования репозитория требуется создать виртуальное окружение и установить зависимости, это достигается следующими командами:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api
|
||||||
|
python -m venv ./.venv
|
||||||
|
cp ./../setup/pip.conf ./.venv/pip.conf
|
||||||
|
source ./.venv/bin/python
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск приложения осуществляется через файл: run_server.py
|
||||||
|
|
||||||
|
В качестве фреймворка ля api используется FastAPI. (Как понятно из названия шаблона) Подробнее ознакомится с возможностями Fast API вы можете в официальной документации: [Fast API документация](https://fastapi.tiangolo.com/)
|
||||||
|
|
||||||
|
Вы можете дополнительно устанавливать библиотеки необходимые для разработки. Настоятельно не рекомендуется изменять базовые предустановки из файла requirements.txt
|
||||||
|
|
||||||
|
Базовый шаблон предоставляет минимальный функционал по работе с данными. Далее описаны основные моменты влияющие на работу приложения.
|
||||||
|
|
||||||
|
#### Основная структура проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api
|
||||||
|
├── requirements.txt
|
||||||
|
├── run_server.py
|
||||||
|
├── src
|
||||||
|
│ ├── app.py
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── settings
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── models.py
|
||||||
|
│ │ └── settings.py
|
||||||
|
└── web
|
||||||
|
```
|
||||||
|
|
||||||
|
Вы можете придерживатся любой структуры проекта, главное, чтобы у Вас присутствовал файл run_server.py. Который запускает web сервер uvicorn или gunicorn. Также обязательно Ваш сервер должен подниматся на определенном хосте и порте. Требуемый хост и порт передаются внутрь через переменные окружения SERVER_HOST, SERVER_PORT
|
||||||
|
|
||||||
|
```python
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=settings.server.HOST,
|
||||||
|
port=settings.server.PORT,
|
||||||
|
lifespan="on"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Отказ от ui (react)
|
||||||
|
|
||||||
|
Если внутри вашего приложения не требуется добавлять визуальный интерфейс, а Вам требуется исключительно разработать API, то в таком случае не стоит удалять директорию web находящуюся на уровне с директорией api. ТРебуется внести небольшие правки в конфигурацию приложения, в частности в файл: /api/src/app.py, удалив следующее содержимое. В таком случае Fast API сервис не будет обрабатывать статические файлы, от web приложения.
|
||||||
|
|
||||||
|
```python
|
||||||
|
#Подключение собранного web приложения
|
||||||
|
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Исходный код UI находится в каталоге web. После клонирования репозитория требуется установить зависимости, это достигается следующими командами:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск приложения осуществляется через файл команду:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
В качестве фреймворка для ui используется React. Подробнее ознакомится с возможностями React вы можете в официальной документации: [React документация](https://react.dev/reference/react)
|
||||||
|
|
||||||
|
Вы можете дополнительно устанавливать библиотеки необходимые для разработки. Настоятельно не рекомендуется изменять базовые предустановки из файла package.json
|
||||||
|
|
||||||
|
Базовый шаблон предоставляет минимальный интерфейс по работе с данными. Далее описаны основные моменты влияющие на работу web приложения.
|
||||||
|
|
||||||
|
#### Основная структура проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
web
|
||||||
|
├── biome.json
|
||||||
|
├── .npmrc
|
||||||
|
├── dist
|
||||||
|
├── package.json
|
||||||
|
├── postcss.config.cjs
|
||||||
|
├── public
|
||||||
|
│ └── index.html
|
||||||
|
├── README.md
|
||||||
|
├── rsbuild.config.ts
|
||||||
|
├── src
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ ├── config.ts
|
||||||
|
│ ├── env.d.ts
|
||||||
|
│ ├── index.css
|
||||||
|
│ └── index.tsx
|
||||||
|
├── tailwind.config.js
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Вы можете придерживатся любой структуры проекта, внутри src. Но на верхнем уровне находятся файлы конфигурации и сборки их требуется изменять только в крайнем случае. Для сборки приложения используется [RSBuild](https://rsbuild.dev/)
|
||||||
|
|
||||||
|
Для создания UI настоятельно рекомендуется использовать предустановленную библиотеку компонентови стилей:
|
||||||
|
|
||||||
|
- tailwindcss
|
||||||
|
- daisy-ui
|
||||||
|
- react-daisy-ui
|
||||||
|
- raisa-ui
|
||||||
|
|
||||||
|
!!! Важно, если внутри web приложения Вы хотите обращатся к API данного проекта, используйте следующую конструкцию:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
|
||||||
|
fetch(`${Config.API_URL}/api`, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Речь идет об вызываемом эндпоинте ${Config.API_URL}/api, Обязательно используйте конструкцию данного вида т.к. при сборке приложения статический сайт обращается относительно api и вместо
|
||||||
|
запросов вида http://localhost:8000/api должно быть так: /api
|
||||||
|
|
||||||
149
api/alembic.ini
Normal file
149
api/alembic.ini
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||||
|
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
|
# `alembic[tz]` to the pip requirements.
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
api/alembic/README
Normal file
1
api/alembic/README
Normal file
@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
102
api/alembic/env.py
Normal file
102
api/alembic/env.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
from alembic import context
|
||||||
|
from src.core.config import settings
|
||||||
|
from src.models import *
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.OPENBAO.DATABASE.DATABASE_URL)
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
def include_name(name, type_, parent_names):
|
||||||
|
if type_ == 'schema':
|
||||||
|
return name == 'dkb'
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
version_table_schema="dkb",
|
||||||
|
include_schemas=True,
|
||||||
|
include_name=include_name
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
version_table_schema="dkb",
|
||||||
|
include_schemas=True,
|
||||||
|
include_name=include_name
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = AsyncEngine(
|
||||||
|
engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
future=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
28
api/alembic/script.py.mako
Normal file
28
api/alembic/script.py.mako
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
0
api/alembic/versions/.gitkeep
Normal file
0
api/alembic/versions/.gitkeep
Normal file
21
api/pytest.ini
Normal file
21
api/pytest.ini
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths =
|
||||||
|
tests
|
||||||
|
python_files =
|
||||||
|
test_*.py
|
||||||
|
tests*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = session
|
||||||
|
asyncio_default_test_loop_scope = session
|
||||||
|
addopts =
|
||||||
|
--tb=short
|
||||||
|
--strict-markers
|
||||||
|
--verbose
|
||||||
|
--capture=no
|
||||||
|
env =
|
||||||
|
APP_ENV=test
|
||||||
|
ENV_FILE=.env.test
|
||||||
|
norecursedirs = .git __pycache__ .venv env build dist
|
||||||
50
api/requirements-dev.txt
Normal file
50
api/requirements-dev.txt
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
aiosqlite
|
||||||
|
alembic
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.8.0
|
||||||
|
argon2-cffi
|
||||||
|
asyncpg
|
||||||
|
certifi==2024.12.14
|
||||||
|
click==8.1.8
|
||||||
|
colorama==0.4.6
|
||||||
|
dnspython==2.7.0
|
||||||
|
email_validator==2.2.0
|
||||||
|
et_xmlfile==2.0.0
|
||||||
|
fastapi==0.115.7
|
||||||
|
fastapi-cli==0.0.7
|
||||||
|
h11==0.14.0
|
||||||
|
httpcore==1.0.7
|
||||||
|
httptools==0.6.4
|
||||||
|
httpx==0.27.2
|
||||||
|
idna==3.10
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.5
|
||||||
|
markdown-it-py==3.0.0
|
||||||
|
MarkupSafe==3.0.2
|
||||||
|
mdurl==0.1.2
|
||||||
|
openpyxl==3.1.5
|
||||||
|
orjson==3.10.15
|
||||||
|
pydantic==2.10.6
|
||||||
|
pydantic-extra-types==2.10.2
|
||||||
|
pydantic-settings==2.7.1
|
||||||
|
pydantic_core==2.27.2
|
||||||
|
Pygments==2.19.1
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
python-jose
|
||||||
|
python-multipart==0.0.20
|
||||||
|
PyYAML==6.0.2
|
||||||
|
rich==13.9.4
|
||||||
|
rich-toolkit==0.13.2
|
||||||
|
shellingham==1.5.4
|
||||||
|
sniffio==1.3.1
|
||||||
|
sqlalchemy
|
||||||
|
starlette==0.45.3
|
||||||
|
typer==0.15.1
|
||||||
|
typing_extensions==4.12.2
|
||||||
|
ujson==5.10.0
|
||||||
|
uvicorn==0.34.0
|
||||||
|
watchfiles==1.0.4
|
||||||
|
websockets==14.2
|
||||||
|
pytest-asyncio
|
||||||
|
pytest-env
|
||||||
|
cosmic-ray
|
||||||
68
api/requirements.txt
Normal file
68
api/requirements.txt
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
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
|
||||||
|
click==8.4.2
|
||||||
|
dnspython==2.8.0
|
||||||
|
email-validator==2.3.0
|
||||||
|
fastapi==0.135.1
|
||||||
|
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
|
||||||
|
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
|
||||||
|
miniopy-async==1.23.4
|
||||||
|
multidict==6.7.1
|
||||||
|
nats-py==2.14.0
|
||||||
|
nkeys==0.2.1
|
||||||
|
propcache==0.5.2
|
||||||
|
pycparser==3.0
|
||||||
|
pycryptodome==3.23.0
|
||||||
|
pydantic==2.13.4
|
||||||
|
pydantic-extra-types==2.11.0
|
||||||
|
pydantic-settings==2.14.2
|
||||||
|
pydantic_core==2.46.4
|
||||||
|
Pygments==2.20.0
|
||||||
|
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
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.16.0
|
||||||
|
urllib3==2.7.0
|
||||||
|
uvicorn==0.46.0
|
||||||
|
uvloop==0.22.1
|
||||||
|
watchfiles==1.2.0
|
||||||
|
websockets==16.1.1
|
||||||
|
yarl==1.24.5
|
||||||
12
api/run_server.py
Normal file
12
api/run_server.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import uvicorn
|
||||||
|
from src import app, get_settings
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
settings = get_settings()
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=settings.server.HOST,
|
||||||
|
port=settings.server.PORT,
|
||||||
|
lifespan="on",
|
||||||
|
)
|
||||||
|
|
||||||
7
api/src/__init__.py
Normal file
7
api/src/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from .app import app
|
||||||
|
from .core.config import get_settings
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'app',
|
||||||
|
'get_settings',
|
||||||
|
]
|
||||||
0
api/src/api/dependencies/__init__.py
Normal file
0
api/src/api/dependencies/__init__.py
Normal file
0
api/src/api/endpoints/__init__.py
Normal file
0
api/src/api/endpoints/__init__.py
Normal file
3
api/src/api/router.py
Normal file
3
api/src/api/router.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
250
api/src/app.py
Normal file
250
api/src/app.py
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import smbclient
|
||||||
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from src.core.PDFWorker.PDFTempPoller import PDFPollWorker
|
||||||
|
from .core.config import get_settings
|
||||||
|
# from .api.router import files_router
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from src.core.nats_callback import build_file_handler
|
||||||
|
from fastapi.exceptions import HTTPException, StarletteHTTPException
|
||||||
|
from src.api.router import le_router, files_router, cg_router, rf_office_router, osv_router, fsl_router
|
||||||
|
from miniopy_async import Minio
|
||||||
|
from src.core.config import settings
|
||||||
|
from src.core.clients import S3Storage, PollWorker
|
||||||
|
from src.core.async_cache import AsyncInMemoryCache
|
||||||
|
# from src.core.logger import setup_logger
|
||||||
|
from nats.aio.client import Client as NATS
|
||||||
|
import nkeys
|
||||||
|
import base64
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||||
|
|
||||||
|
from src.core.exception_handlers import register_exception_handlers
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
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):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
return ""
|
||||||
|
value = value.strip()
|
||||||
|
if value == "/":
|
||||||
|
return ""
|
||||||
|
return "/" + value.strip("/")
|
||||||
|
|
||||||
|
|
||||||
|
_APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
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:
|
||||||
|
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(
|
||||||
|
"Не удалось проверить наличие бакет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),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
poller = PollWorker(nc, cache, storage)
|
||||||
|
await poller.start()
|
||||||
|
pdf_poller = PDFPollWorker(cache, storage)
|
||||||
|
await pdf_poller.start()
|
||||||
|
app.state.nats = nc
|
||||||
|
app.state.poller = poller
|
||||||
|
app.state.pdf_poller = pdf_poller
|
||||||
|
if not settings.DEBUG:
|
||||||
|
handler_cb = build_file_handler(nc, storage)
|
||||||
|
|
||||||
|
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("Приложение запущено")
|
||||||
|
yield
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("ОШИБКА ПРИ РАБОТЕ С СЕКРЕТАМИ error={error}", error=repr(exc))
|
||||||
|
finally:
|
||||||
|
logger.info("Приложение закрывается")
|
||||||
|
smbclient.reset_connection_cache()
|
||||||
|
if not settings.DEBUG:
|
||||||
|
for sub in app.state.nats_subs:
|
||||||
|
logger.info("Отписываемся от топика")
|
||||||
|
await sub.unsubscribe()
|
||||||
|
if app.state.nats:
|
||||||
|
await app.state.nats.close()
|
||||||
|
if app.state.poller:
|
||||||
|
await app.state.poller.stop()
|
||||||
|
if app.state.storage:
|
||||||
|
await app.state.storage.client.close_session()
|
||||||
|
if app.state.pdf_poller:
|
||||||
|
await app.state.pdf_poller.stop()
|
||||||
|
logger.info("CLOSING APP AT ALL")
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Fast API",
|
||||||
|
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(le_router)
|
||||||
|
app.include_router(files_router)
|
||||||
|
app.include_router(cg_router)
|
||||||
|
app.include_router(rf_office_router)
|
||||||
|
app.include_router(osv_router)
|
||||||
|
app.include_router(fsl_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=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#Создание каталога для web приложения
|
||||||
|
os.makedirs(os.path.join('web'), exist_ok=True)
|
||||||
|
|
||||||
|
#Подключаем все ендпоинты и роуты до маунта статических файлов
|
||||||
|
# app.include_router(files_router)
|
||||||
|
|
||||||
|
#Подключение собранного web приложения
|
||||||
|
|
||||||
|
|
||||||
|
def sep_static(path, stat_dir):
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
raise ex
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
|
||||||
|
|
||||||
|
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
||||||
5
api/src/common/exceptions.py
Normal file
5
api/src/common/exceptions.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
class NotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CouldntCreateError(Exception):
|
||||||
|
pass
|
||||||
0
api/src/core/__init__.py
Normal file
0
api/src/core/__init__.py
Normal file
52
api/src/core/async_cache.py
Normal file
52
api/src/core/async_cache.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileDictItem:
|
||||||
|
started_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
file_id: str
|
||||||
|
job_id: str
|
||||||
|
s3_key: str
|
||||||
|
legal_entity_id: str
|
||||||
|
|
||||||
|
class AsyncInMemoryCache:
|
||||||
|
def __init__(self):
|
||||||
|
self.mapping: dict[str, FileDictItem] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def add(self, job_id: str, file_id: str, s3_key: str, legal_entity_id: str) -> FileDictItem:
|
||||||
|
now = datetime.now()
|
||||||
|
expiration_date = datetime.now() + timedelta(minutes=5)
|
||||||
|
|
||||||
|
new_item = FileDictItem(
|
||||||
|
started_at= now,
|
||||||
|
expires_at= expiration_date,
|
||||||
|
file_id= str(file_id),
|
||||||
|
s3_key=s3_key,
|
||||||
|
job_id= str(job_id),
|
||||||
|
legal_entity_id = legal_entity_id
|
||||||
|
)
|
||||||
|
async with self._lock:
|
||||||
|
self.mapping[str(job_id)] = new_item
|
||||||
|
return new_item
|
||||||
|
|
||||||
|
async def get(self, job_id: uuid.UUID):
|
||||||
|
async with self._lock:
|
||||||
|
return self.mapping.get(str(job_id))
|
||||||
|
|
||||||
|
async def remove(self, source_link: str) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
self.mapping.pop(source_link, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_items(self) -> list[FileDictItem]:
|
||||||
|
async with self._lock:
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
for item in self.mapping.values()
|
||||||
|
]
|
||||||
180
api/src/core/clients.py
Normal file
180
api/src/core/clients.py
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
import smbclient
|
||||||
|
from miniopy_async import Minio
|
||||||
|
from io import BytesIO
|
||||||
|
import asyncio
|
||||||
|
from nats.aio.client import Client as NATS
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select, update, and_
|
||||||
|
|
||||||
|
from src.core.async_cache import AsyncInMemoryCache
|
||||||
|
from src.models import RecognitionJob, StoredFile
|
||||||
|
from src.schemas.enums import RecognitionStatus, FileStatus
|
||||||
|
from src.db.db import async_session_maker
|
||||||
|
from src.core.config import settings
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
ext_to_subject = {
|
||||||
|
"XLS": "file.tabular_uploaded",
|
||||||
|
"XLSX": "file.tabular_uploaded",
|
||||||
|
"PNG": "file.img_uploaded",
|
||||||
|
"PDF": "file.img_uploaded",
|
||||||
|
"JPG": "file.img_uploaded",
|
||||||
|
}
|
||||||
|
|
||||||
|
class S3Storage:
|
||||||
|
def __init__(self, client: Minio, bucket_name: str):
|
||||||
|
self.client = client
|
||||||
|
self.bucket = bucket_name
|
||||||
|
|
||||||
|
async def put_object(self, obj_name, file_data):
|
||||||
|
try:
|
||||||
|
stream = BytesIO(file_data)
|
||||||
|
await self.client.put_object(self.bucket, obj_name, stream, length=len(file_data))
|
||||||
|
except:
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_object(self, obj_name):
|
||||||
|
try:
|
||||||
|
response = await self.client.get_object(self.bucket, obj_name)
|
||||||
|
# file_bytes = await response.content.read()
|
||||||
|
return response
|
||||||
|
except:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PollWorker:
|
||||||
|
def __init__(self, nats_client: NATS, cache: AsyncInMemoryCache, s3_client: S3Storage):
|
||||||
|
self.task: asyncio.Task | None = None
|
||||||
|
self.stop_event = asyncio.Event()
|
||||||
|
self.nats_client = nats_client
|
||||||
|
self.cache = cache
|
||||||
|
self.storage = s3_client
|
||||||
|
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
self.stop_event.clear()
|
||||||
|
self.task = asyncio.create_task(self.run())
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
self.stop_event.set()
|
||||||
|
if self.task:
|
||||||
|
self.task.cancel()
|
||||||
|
try:
|
||||||
|
await self.task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
logger.info("Начат процесс опроса БД по RecognitionJob")
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
await self.poll_once()
|
||||||
|
except Exception:
|
||||||
|
# logger.exception("Poll iteration failed")
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self.stop_event.wait(),
|
||||||
|
timeout=7,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("PollWorker остановлен")
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_once(self):
|
||||||
|
try:
|
||||||
|
rows = await self.claim_pending_rows()
|
||||||
|
logger.info("Получил rows. Количество: {rlen}", rlen=len(rows))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Проблема при получении строк из БД по RecognitionJob. error_category=pollworker, error_type={error_type}, error={error}",
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
error=repr(exc),
|
||||||
|
)
|
||||||
|
rows=[]
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
row_id = row["id"]
|
||||||
|
try:
|
||||||
|
# await self.publish_to_nats(row["subject"], row["row_payload"])
|
||||||
|
|
||||||
|
if row['subject'] == 'file.img_uploaded':
|
||||||
|
last_part = row['row_payload']['s3_key'].split('/')[-1]
|
||||||
|
with smbclient.open_file(f'{settings.OPENBAO.SMB.SMB_PATH}/Файлы/{last_part}', 'wb') as f:
|
||||||
|
data = await self.storage.get_object(row['row_payload']['s3_key'])
|
||||||
|
content = await data.read()
|
||||||
|
f.write(content)
|
||||||
|
await self.cache.add(row['row_payload']['job_id'], row['row_payload']['file_id'],
|
||||||
|
row['row_payload']['s3_key'], row['row_payload']['legal_entity_id'])
|
||||||
|
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
# logger.exception(
|
||||||
|
# "Failed to publish row id={row_id}. error_category=pollworker, error_type={error_type}, error={error}",
|
||||||
|
# row_id=row_id,
|
||||||
|
# error_type=type(exc).__name__,
|
||||||
|
# error=repr(exc),
|
||||||
|
# )
|
||||||
|
logger.exception(
|
||||||
|
"Ошибка при попытке положить сообщение в кэш. error_category=pdfpollworker, error_type={error_type}, error={error}",
|
||||||
|
row_id=row_id,
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
error=repr(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.mark_queued(row_id)
|
||||||
|
|
||||||
|
async def claim_pending_rows(self):
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = (
|
||||||
|
select(RecognitionJob, StoredFile)
|
||||||
|
.join(StoredFile, StoredFile.id == RecognitionJob.file_id)
|
||||||
|
.where(and_(RecognitionJob.status == RecognitionStatus.QUEUED, StoredFile.status.is_(None)))
|
||||||
|
.with_for_update(skip_locked=True, read=True)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
result_scalars = result.all()
|
||||||
|
|
||||||
|
for (job, file) in result_scalars:
|
||||||
|
job.status = RecognitionStatus.PROCESSING
|
||||||
|
file.status = FileStatus.PROCESSING
|
||||||
|
job.requested_at = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"id": job.id,
|
||||||
|
"subject": ext_to_subject[file.file_type],
|
||||||
|
"row_payload": {
|
||||||
|
"legal_entity_id": str(job.legal_entity_id),
|
||||||
|
"file_id": str(file.id),
|
||||||
|
"s3_key": str(file.s3_key),
|
||||||
|
"job_id": str(job.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
} for (job, file) in result_scalars]
|
||||||
|
|
||||||
|
async def publish_to_nats(self, subject, payload):
|
||||||
|
if not settings.DEBUG:
|
||||||
|
# logger.info("Попытка отправить сообщение в subject={subject}", subject=subject)
|
||||||
|
await self.nats_client.publish(subject, json.dumps(payload).encode("utf-8"))
|
||||||
|
else:
|
||||||
|
print("PUBLISHING TO NATS", subject, payload) #print достаточен, поскеольку работает локально
|
||||||
|
print()
|
||||||
|
|
||||||
|
async def mark_queued(self, row_id):
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = update(RecognitionJob).where(RecognitionJob.id == row_id).values(status=RecognitionStatus.QUEUED)
|
||||||
|
file_stmt = update(StoredFile).where(StoredFile.current_recognition_job_id == row_id).values(status=None)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.execute(file_stmt)
|
||||||
|
await session.commit()
|
||||||
50
api/src/core/config.py
Normal file
50
api/src/core/config.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
class NATSSettings(BaseSettings):
|
||||||
|
NATS_URL: str
|
||||||
|
NATS_SEED: str
|
||||||
|
NATS_JWT: str
|
||||||
|
|
||||||
|
class SMBSettings(BaseSettings):
|
||||||
|
SMB_HOST: str
|
||||||
|
SMB_PATH: str
|
||||||
|
SMB_LOGIN: str
|
||||||
|
SMB_PASSWORD: str
|
||||||
|
|
||||||
|
class OpenBAOSettings(BaseSettings):
|
||||||
|
DATABASE: DBSettings
|
||||||
|
MINIO: MinioSettings
|
||||||
|
NATS: NATSSettings
|
||||||
|
SMB: SMBSettings
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
||||||
|
env_file = ".env"
|
||||||
|
env_nested_delimiter = "__"
|
||||||
|
nested_model_default_partial_update = False
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
43
api/src/core/exception_handlers.py
Normal file
43
api/src/core/exception_handlers.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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:
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def request_validation_exception_handler(_: Request, exc: RequestValidationError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
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.
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"detail": exc.detail},
|
||||||
|
headers=exc.headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(_: Request, exc: Exception):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={"detail": "внутренняя ошибка сервера"},
|
||||||
|
)
|
||||||
98
api/src/core/logger.py
Normal file
98
api/src/core/logger.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
# Периодические пробы мониторинга/оркестратора и служебные doc-эндпоинты.
|
||||||
|
NOISE_PATHS: tuple[str, ...] = (
|
||||||
|
"/healthcheck",
|
||||||
|
"/uptime",
|
||||||
|
"/metrics",
|
||||||
|
"/docs",
|
||||||
|
"/redoc",
|
||||||
|
"/openapi.json",
|
||||||
|
"/favicon.ico",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Маршруты статики (mount'ы StaticFiles) — контейнеры любой статики.
|
||||||
|
_NOISE_MARKERS: tuple[str, ...] = ("/static/", "/static-swagger/")
|
||||||
|
|
||||||
|
# Расширения статических ассетов (картинки, стили, скрипты, шрифты).
|
||||||
|
_STATIC_SUFFIXES: tuple[str, ...] = (
|
||||||
|
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp",
|
||||||
|
".css", ".js", ".map", ".woff", ".woff2", ".ttf", ".eot",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_noise_path(path: str) -> bool:
|
||||||
|
"""True для служебных путей, чьи УСПЕШНЫЕ обращения не логируем.
|
||||||
|
|
||||||
|
Пробы (healthcheck/uptime/metrics), doc-эндпоинты и статика — чистый шум.
|
||||||
|
Ошибки (>=400) по этим путям логируются: это решает RequestLoggingMiddleware.
|
||||||
|
"""
|
||||||
|
if any(path.endswith(p) for p in NOISE_PATHS):
|
||||||
|
return True
|
||||||
|
if any(marker in path for marker in _NOISE_MARKERS):
|
||||||
|
return True
|
||||||
|
if any(path.endswith(suffix) for suffix in _STATIC_SUFFIXES):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logger(
|
||||||
|
*,
|
||||||
|
level: str | int = "INFO",
|
||||||
|
fmt: str | None = None,
|
||||||
|
intercept_std_logging: bool = True,
|
||||||
|
) -> None:
|
||||||
|
logger.remove()
|
||||||
|
logger.configure(extra={"request_id": "-"})
|
||||||
|
|
||||||
|
if fmt is None:
|
||||||
|
fmt = (
|
||||||
|
"{time:YYYY-MM-DD HH:mm:ss.SSS} | "
|
||||||
|
"{level:<8} | "
|
||||||
|
"req={extra[request_id]} | "
|
||||||
|
"{name}:{function}:{line} - {message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.add(
|
||||||
|
sys.stdout,
|
||||||
|
level=level,
|
||||||
|
format=fmt,
|
||||||
|
enqueue=True,
|
||||||
|
backtrace=False,
|
||||||
|
diagnose=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not intercept_std_logging:
|
||||||
|
return
|
||||||
|
|
||||||
|
class InterceptHandler(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
try:
|
||||||
|
lvl = logger.level(record.levelname).name
|
||||||
|
except ValueError:
|
||||||
|
lvl = record.levelno
|
||||||
|
|
||||||
|
logger.opt(
|
||||||
|
depth=6,
|
||||||
|
exception=record.exc_info,
|
||||||
|
).log(lvl, record.getMessage())
|
||||||
|
|
||||||
|
logging.root.handlers = [InterceptHandler()]
|
||||||
|
logging.root.setLevel(0)
|
||||||
|
|
||||||
|
for name in ("uvicorn", "uvicorn.error"):
|
||||||
|
lg = logging.getLogger(name)
|
||||||
|
lg.handlers = [InterceptHandler()]
|
||||||
|
lg.setLevel(0)
|
||||||
|
lg.propagate = False
|
||||||
|
|
||||||
|
access = logging.getLogger("uvicorn.access")
|
||||||
|
access.handlers = []
|
||||||
|
access.propagate = False
|
||||||
|
access.disabled = True
|
||||||
123
api/src/core/nats_callback.py
Normal file
123
api/src/core/nats_callback.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
from .clients import S3Storage
|
||||||
|
from nats.aio.client import Client as NATS
|
||||||
|
from src.db.db import async_session_maker
|
||||||
|
from sqlalchemy import select, update, insert
|
||||||
|
from src.models import StoredFile, RecognitionJob, ChartOfAccount, OsvEntry
|
||||||
|
from src.schemas.enums import RecognitionStatus, FileStatus, DocumentType
|
||||||
|
import json
|
||||||
|
import copy
|
||||||
|
|
||||||
|
|
||||||
|
def json_to_db_rows(json_dict: dict, codes_mapping: dict) -> list:
|
||||||
|
# {
|
||||||
|
# recognition_job_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("recognition_job.id"), nullable=False)
|
||||||
|
# legal_entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("legal_entity.id"))
|
||||||
|
# account_code: Mapped[str] = mapped_column(String)
|
||||||
|
# account_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('chart_of_account.id'))
|
||||||
|
# account_root: Mapped[str] = mapped_column(String)
|
||||||
|
# counterparty_name: Mapped[str] = mapped_column(String)
|
||||||
|
# counterparty_inn: Mapped[str] = mapped_column(String)
|
||||||
|
# counterparty_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("legal_entity.id"), nullable=True)
|
||||||
|
# opening_debit: Mapped[Decimal] = mapped_column(Numeric)
|
||||||
|
# opening_credit: Mapped[Decimal] = mapped_column(Numeric)
|
||||||
|
# turnover_debit: Mapped[Decimal] = mapped_column(Numeric)
|
||||||
|
# turnover_credit: Mapped[Decimal] = mapped_column(Numeric)
|
||||||
|
# closing_debit: Mapped[Decimal] = mapped_column(Numeric)
|
||||||
|
# closing_credit: Mapped[Decimal] = mapped_column(Numeric)
|
||||||
|
# }
|
||||||
|
rows = copy.deepcopy(json_dict['rows'])
|
||||||
|
for row in rows:
|
||||||
|
row['recognition_job_id'] = json_dict['recognition_job_id']
|
||||||
|
row['legal_entity_id'] = json_dict['legal_entity_id']
|
||||||
|
normalized_account_code = normalize_account_code(row['account_code'])
|
||||||
|
row['account_id'] = codes_mapping[normalized_account_code]
|
||||||
|
row['stored_file_id'] = json_dict['stored_file_id']
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_account_code(acc_code: str):
|
||||||
|
split_parts = acc_code.split('.')
|
||||||
|
for i in range(len(split_parts)):
|
||||||
|
if len(split_parts[i]) == 1:
|
||||||
|
split_parts[i] = '0' + split_parts[i]
|
||||||
|
|
||||||
|
return '.'.join(split_parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_account_codes_mapping(session):
|
||||||
|
stmt = select(ChartOfAccount)
|
||||||
|
scalars: list[ChartOfAccount] = (await session.execute(stmt)).scalars().all()
|
||||||
|
result = {}
|
||||||
|
for i in range(len(scalars)):
|
||||||
|
result[scalars[i].code] = scalars[i].id
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_file_handler(nc: NATS, storage: S3Storage):
|
||||||
|
session_factory = async_session_maker
|
||||||
|
|
||||||
|
async def handler(msg):
|
||||||
|
msg_json = json.loads(msg.data.decode('utf-8'))
|
||||||
|
payload = msg_json['data']
|
||||||
|
job_id = payload['job_id']
|
||||||
|
file_id = payload['file_id']
|
||||||
|
legal_entity_id = payload['legal_entity_id']
|
||||||
|
status = msg_json['status']
|
||||||
|
if status != 'success':
|
||||||
|
async with session_factory() as session:
|
||||||
|
stmt = update(RecognitionJob).where(RecognitionJob.id == job_id).values(status = RecognitionStatus.ERROR)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
result_s3_path = payload.get('result_s3_path')
|
||||||
|
source_file_ext = payload['s3_key'].split(".")[-1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
file_upd_values = {}
|
||||||
|
if result_s3_path is None:
|
||||||
|
file_upd_values = {
|
||||||
|
"status": FileStatus.UPLOADED,
|
||||||
|
"document_type": DocumentType.OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
file_response = await storage.get_object(result_s3_path)
|
||||||
|
file = await file_response.content.read()
|
||||||
|
|
||||||
|
json_data = json.loads(file)
|
||||||
|
period_year = json_data.get('period_year')
|
||||||
|
period_quarter = json_data.get('period_quarter')
|
||||||
|
file_upd_values = {
|
||||||
|
"status": FileStatus.UPLOADED,
|
||||||
|
"period_quarter": period_quarter,
|
||||||
|
"period_year": period_year,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source_file_ext in ["xlsx", "xls"]):
|
||||||
|
codes_mapping = await get_account_codes_mapping(session)
|
||||||
|
db_rows = json_to_db_rows(json_data, codes_mapping)
|
||||||
|
await session.execute(insert(OsvEntry).values(db_rows))
|
||||||
|
|
||||||
|
file_upd_values['document_type'] = DocumentType.OSV
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
file_upd_values['document_type'] = DocumentType.BUH_OT
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
stmt = update(StoredFile).where(StoredFile.id == file_id).values(**file_upd_values).returning(StoredFile)
|
||||||
|
job_stmt = update(RecognitionJob).where(RecognitionJob.id == job_id).values(status=RecognitionStatus.DONE, raw_result_s3_key = result_s3_path).returning(RecognitionJob)
|
||||||
|
|
||||||
|
first_res = await session.execute(stmt)
|
||||||
|
sec_res = await session.execute(job_stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return handler
|
||||||
1
api/src/db/__init__.py
Normal file
1
api/src/db/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .db import get_async_session
|
||||||
16
api/src/db/db.py
Normal file
16
api/src/db/db.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession
|
||||||
|
from fastapi import Depends
|
||||||
|
# from fastapi_users.db import SQLAlchemyUserDatabase, SQLAlchemyBaseUserTableUUID
|
||||||
|
from src.core.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": 'dkb'}})
|
||||||
|
async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
# @asynccontextmanager
|
||||||
|
async def get_async_session():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
0
api/src/models/__init__.py
Normal file
0
api/src/models/__init__.py
Normal file
0
api/src/repositories/__init__.py
Normal file
0
api/src/repositories/__init__.py
Normal file
0
api/src/schemas/__init__.py
Normal file
0
api/src/schemas/__init__.py
Normal file
0
api/src/services/__init__.py
Normal file
0
api/src/services/__init__.py
Normal file
0
api/tests/conftest.py
Normal file
0
api/tests/conftest.py
Normal file
8
raisa_config.yml
Normal file
8
raisa_config.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Конфигурация ресурсов приложения
|
||||||
|
# Доступные профили: Малый (0.25CPU/1GB), Средний (0.5CPU/2GB), Большой (1CPU/4GB), custom
|
||||||
|
# CPU Cores указывается, только с шагом 0.25
|
||||||
|
# RAM GB указывается, только с шагом 1
|
||||||
|
profile:
|
||||||
|
# Последние данные по наименованию профиля: Малый
|
||||||
|
cpu: 0.25 # значение Cores
|
||||||
|
ram: 1.0 # значение GB
|
||||||
2
setup/.npmrc
Normal file
2
setup/.npmrc
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
registry=http://nexus.dev.raisa.go.rshbank.ru/repository/npm/
|
||||||
|
strict-ssl=false
|
||||||
8
setup/pip.conf
Normal file
8
setup/pip.conf
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[global]
|
||||||
|
disable-pip-version-check = True
|
||||||
|
trusted-host = nexus.dev.raisa.go.rshbank.ru
|
||||||
|
index = http://nexus.dev.raisa.go.rshbank.ru/repository/pypi/pypi
|
||||||
|
index-url = http://nexus.dev.raisa.go.rshbank.ru/repository/pypi/simple
|
||||||
|
|
||||||
|
[easy_install]
|
||||||
|
index-url = http://nexus.dev.raisa.go.rshbank.ru/repository/pypi/simple
|
||||||
23
setup/setup.sh
Executable file
23
setup/setup.sh
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
echo "setup project to development"
|
||||||
|
|
||||||
|
echo "create virtual environment to api"
|
||||||
|
|
||||||
|
python -m venv ./../api/.venv
|
||||||
|
cp ./pip.conf ./../api/.venv/pip.conf
|
||||||
|
source ./../api/.venv/bin/activate
|
||||||
|
pip install -r ./../api/requirements.txt
|
||||||
|
|
||||||
|
echo "complete setup development api"
|
||||||
|
|
||||||
|
echo "setup web application"
|
||||||
|
|
||||||
|
cp ./.npmrc ./../web/.npmrc
|
||||||
|
cd ./../web
|
||||||
|
|
||||||
|
npm i
|
||||||
|
|
||||||
|
echo "complete setup web application"
|
||||||
|
echo "To start api, cd to api directory, and use venv, start run_server.py"
|
||||||
|
echo "To start api, cd to web directory, and use command npm run start!"
|
||||||
|
echo "Happy Hacking! :)"
|
||||||
91
web/biome.json
Normal file
91
web/biome.json
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": true,
|
||||||
|
"ignore": ["node_modules"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"lineWidth": 140,
|
||||||
|
"formatWithErrors": true
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"ignore": ["*.json"],
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"useButtonType": "off",
|
||||||
|
"noSvgWithoutTitle": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"noForEach": "off",
|
||||||
|
"noUselessTypeConstraint": "warn"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "error",
|
||||||
|
"noUnusedImports": "error",
|
||||||
|
"useExhaustiveDependencies": "info"
|
||||||
|
},
|
||||||
|
"nursery": {
|
||||||
|
"noCommonJs": "error",
|
||||||
|
"noDuplicateElseIf": "error",
|
||||||
|
"noDuplicateProperties": "error",
|
||||||
|
"noNestedTernary": "error",
|
||||||
|
"useAtIndex": "error",
|
||||||
|
"useCollapsedIf": "error",
|
||||||
|
"useConsistentCurlyBraces": "error"
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"noDelete": "off",
|
||||||
|
"noAccumulatingSpread": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"useDefaultSwitchClause": "error",
|
||||||
|
"useForOf": "error",
|
||||||
|
"useFragmentSyntax": "error",
|
||||||
|
"useConsistentArrayType": "error",
|
||||||
|
"useShorthandAssign": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noConsole": "warn",
|
||||||
|
"noEmptyBlockStatements": "error",
|
||||||
|
"noEvolvingTypes": "error",
|
||||||
|
"noExplicitAny": "warn",
|
||||||
|
"noSkippedTests": "warn",
|
||||||
|
"useAwait": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"jsxQuoteStyle": "single",
|
||||||
|
"bracketSameLine": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"parser": {
|
||||||
|
"allowComments": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"parser": {
|
||||||
|
"cssModules": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
web/package.json
Normal file
36
web/package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rsbuild build",
|
||||||
|
"start": "rsbuild dev --open",
|
||||||
|
"preview": "rsbuild preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.0.0",
|
||||||
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
|
"antd": "^6.5.0",
|
||||||
|
"markdown-to-jsx": "^9.1.2",
|
||||||
|
"pdfjs-dist": "^5.7.284",
|
||||||
|
"raisa-fastapi-protected-web": "^1.0.0",
|
||||||
|
"raisa-ui": "^1.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-daisyui": "^5.0.5",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"react-router-dom": "^7.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "1.9.4",
|
||||||
|
"@biomejs/cli-linux-x64-musl": "^1.9.4",
|
||||||
|
"@rsbuild/core": "1.6.7",
|
||||||
|
"@rsbuild/plugin-react": "^1.1.0",
|
||||||
|
"@types/react": "^18.3.1",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"daisyui": "^4.12.10",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
web/postcss.config.cjs
Normal file
5
web/postcss.config.cjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
0
web/public/.gitkeep
Normal file
0
web/public/.gitkeep
Normal file
13
web/public/index.html
Normal file
13
web/public/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru-RU">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<title>Пример React-приложения</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
web/rsbuild.config.ts
Normal file
32
web/rsbuild.config.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig, loadEnv } from '@rsbuild/core';
|
||||||
|
import { pluginReact } from '@rsbuild/plugin-react';
|
||||||
|
|
||||||
|
const { publicVars, rawPublicVars } = loadEnv({prefixes: ['REACT_APP_']});
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
base: (rawPublicVars['REACT_APP_ROOT_PATH'] !== undefined) ? rawPublicVars['REACT_APP_ROOT_PATH'] : '/'
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
filename: {
|
||||||
|
js: '[name]_script.[contenthash:8].js',
|
||||||
|
css: '[name]_style.[contenthash:8].css',
|
||||||
|
},
|
||||||
|
distPath: {
|
||||||
|
favicon: 'static/image',
|
||||||
|
},
|
||||||
|
assetPrefix: (rawPublicVars['REACT_APP_ROOT_PATH'] !== undefined) ? rawPublicVars['REACT_APP_ROOT_PATH'] : './'
|
||||||
|
},
|
||||||
|
html: {
|
||||||
|
template: './public/index.html',
|
||||||
|
favicon: './src/assets/favicon.ico',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
include: [{ not: /[\\/]core-js[\\/]/ }],
|
||||||
|
define: {
|
||||||
|
...publicVars,
|
||||||
|
'process.env': JSON.stringify(rawPublicVars),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [pluginReact()],
|
||||||
|
});
|
||||||
0
web/src/App.css
Normal file
0
web/src/App.css
Normal file
60
web/src/App.tsx
Normal file
60
web/src/App.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import "./App.css";
|
||||||
|
import RfView from "./components/RfView";
|
||||||
|
import { Button, Layout } from "antd";
|
||||||
|
// import FileUploaderModal from "./components/FileUploaderModel";
|
||||||
|
// import { FileItem } from "./components/FilesList";
|
||||||
|
import { Sidebar } from "./widgets/Sidebar/Sidebar";
|
||||||
|
import { Main } from "./layouts/Main";
|
||||||
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { Mono } from "./layouts/Monozaemchiki";
|
||||||
|
import { Catalogue } from "./layouts/Catalogue";
|
||||||
|
import { FileViewer } from "./layouts/FileViewer";
|
||||||
|
import { Provider } from "react-redux";
|
||||||
|
import { store } from "./store/store";
|
||||||
|
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
||||||
|
import { AuditLogging } from "./layouts/AuditLogging";
|
||||||
|
import { CatalogueGroup } from "./layouts/CatalogueGroups";
|
||||||
|
import { DocStorage } from "./layouts/DocStorage";
|
||||||
|
import { CatalogueJur } from "./layouts/CatalogueJur";
|
||||||
|
import { Config } from "./conf/config";
|
||||||
|
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [companyGroups, setCompanyGroups] = useState([]);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchGroups() {
|
||||||
|
const res = await fetch(`${Config.API_URL}/company-groups`);
|
||||||
|
const json = await res.json();
|
||||||
|
setCompanyGroups(() =>
|
||||||
|
json.map((c) => ({ id: c.id, name: c.name, isActive: false })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fetchGroups();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<BrowserRouter basename={Config.APP_ADDRESS}>
|
||||||
|
<Layout hasSider={true} style={{ height: "100vh" }}>
|
||||||
|
<Sidebar companyGroups={companyGroups} />
|
||||||
|
<Layout.Content>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/groups/:group_id" element={<Main />} />
|
||||||
|
<Route path="/groups/monozaemchiki" element={<Mono />} />
|
||||||
|
<Route path="/catalogue/groups" element={<CatalogueGroup />} />
|
||||||
|
<Route path="/catalogue/jur_l" element={<CatalogueJur />} />
|
||||||
|
<Route path="/files/:file_id" element={<FileViewer />} />
|
||||||
|
<Route path="/docstorage" element={<DocStorage />} />
|
||||||
|
<Route path="/logging" element={<AuditLogging />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</BrowserRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
web/src/assets/favicon.ico
Executable file
BIN
web/src/assets/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
0
web/src/components/.gitkeep
Normal file
0
web/src/components/.gitkeep
Normal file
7
web/src/conf/config.ts
Normal file
7
web/src/conf/config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
//@ts-ignore
|
||||||
|
const environment = process.env;
|
||||||
|
|
||||||
|
export class Config {
|
||||||
|
static API_URL: string = environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? '';
|
||||||
|
static APP_ADDRESS: string = `${environment.REACT_APP_ROOT_PATH || '/aurora/apps/fastapi-dkb-armka'}`
|
||||||
|
}
|
||||||
116
web/src/index.css
Normal file
116
web/src/index.css
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/* :root {
|
||||||
|
--text: #6b6375;
|
||||||
|
--text-h: #08060d;
|
||||||
|
--bg: #fff;
|
||||||
|
--border: #e5e4e7;
|
||||||
|
--code-bg: #f4f3ec;
|
||||||
|
--accent: #aa3bff;
|
||||||
|
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||||
|
--accent-border: rgba(170, 59, 255, 0.5);
|
||||||
|
--social-bg: rgba(244, 243, 236, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||||
|
|
||||||
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
font: 18px/145% var(--sans);
|
||||||
|
letter-spacing: 0.18px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--text: #9ca3af;
|
||||||
|
--text-h: #f3f4f6;
|
||||||
|
--bg: #16171d;
|
||||||
|
--border: #2e303a;
|
||||||
|
--code-bg: #1f2028;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||||
|
--accent-border: rgba(192, 132, 252, 0.5);
|
||||||
|
--social-bg: rgba(47, 48, 58, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#social .button-icon {
|
||||||
|
filter: invert(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 1126px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
border-inline: 1px solid var(--border);
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: var(--heading);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
letter-spacing: -1.68px;
|
||||||
|
margin: 32px 0;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 118%;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.counter {
|
||||||
|
font-family: var(--mono);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
} */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
37
web/src/index.tsx
Normal file
37
web/src/index.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./App";
|
||||||
|
import { ConfigProvider } from "antd";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
// <StrictMode>
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: "rgba(0, 255, 0, 0.5)",
|
||||||
|
colorPrimaryHover: "rgba(0, 255, 0, 1)",
|
||||||
|
colorBgLayout: "rgb(247, 248, 250)",
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Layout: {
|
||||||
|
siderBg: "#fff",
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
// textHoverBg: "green",
|
||||||
|
colorBorder: "rgba(0,0,0,0.5)",
|
||||||
|
defaultActiveBorderColor: "rgba(0,0,0,0.5)",
|
||||||
|
defaultBorderColor: "rgba(0,0,0,0.5)",
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
activeBorderColor: "rgba(46, 166, 75, 0.35)",
|
||||||
|
activeShadow: "0 0 0 1px #2ea64b59,0 1px 2px #0000000f",
|
||||||
|
hoverBorderColor: "rgba(46, 166, 75, 0.35)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>,
|
||||||
|
// </StrictMode>,
|
||||||
|
);
|
||||||
131
web/src/layouts/Main.tsx
Normal file
131
web/src/layouts/Main.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Drawer,
|
||||||
|
Splitter,
|
||||||
|
Table,
|
||||||
|
Tooltip,
|
||||||
|
type TableProps,
|
||||||
|
} from "antd";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { FileItem } from "../components/FilesList";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import FileUploaderModal from "../components/FileUploaderModel";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../store/store";
|
||||||
|
import { getFilesThunk } from "../store/slices/fileSlice";
|
||||||
|
import { FileOutlined } from "@ant-design/icons";
|
||||||
|
import { ConsoleLegalEntities } from "../widgets/ConsoleLegalEntities/ConsoleLegalEntities";
|
||||||
|
import { Config } from "../conf/config";
|
||||||
|
|
||||||
|
async function getByLEId(legalEntityId: string) {
|
||||||
|
const filesRes = await fetch(`${Config.API_URL}/files/${legalEntityId}`);
|
||||||
|
const json = await filesRes.json();
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({ drawerContent, leid, onReloadCall }) {
|
||||||
|
// if (!drawerContent.length) return;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FileUploaderModal legal_entity_id={leid} trigger_sse_cb={() => {}} />
|
||||||
|
{drawerContent.map((f) => (
|
||||||
|
<FileItem key={f.id} file={f} />
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onReloadCall(leid);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Перезагрузить список файлов
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Main() {
|
||||||
|
const [legalEntities, setLegalEntities] = useState([]);
|
||||||
|
const [legEntId, setLegEntId] = useState(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
// const [drawerContent, setDrawerContent] = useState([]);
|
||||||
|
const { group_id } = useParams();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const drawerContent = useAppSelector((state) => state.files.currentOpenFiles);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function getLegEntitites() {
|
||||||
|
const res = await fetch(
|
||||||
|
`${Config.API_URL}/legal-entities/by-company-group?company_group_id=${group_id}`,
|
||||||
|
);
|
||||||
|
let data;
|
||||||
|
if (!res.ok) {
|
||||||
|
data = [];
|
||||||
|
} else {
|
||||||
|
data = await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLegalEntities(data);
|
||||||
|
}
|
||||||
|
getLegEntitites();
|
||||||
|
}, [group_id]);
|
||||||
|
|
||||||
|
const onReloadCall = useCallback(
|
||||||
|
async (id) => {
|
||||||
|
// const files = await getByLEId(id);
|
||||||
|
|
||||||
|
// setDrawerContent(files);
|
||||||
|
await dispatch(getFilesThunk(id));
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
const handleLEChoice = useCallback(
|
||||||
|
async (id) => {
|
||||||
|
await dispatch(getFilesThunk(id));
|
||||||
|
|
||||||
|
setDrawerOpen(true);
|
||||||
|
setLegEntId(id);
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "10px",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
height: "calc(100% - 20px)",
|
||||||
|
alignItems: "center",
|
||||||
|
margin: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
borderRadius: "10px",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>Консолидации</h3>
|
||||||
|
</div>
|
||||||
|
<ConsoleLegalEntities
|
||||||
|
handleLEChoice={handleLEChoice}
|
||||||
|
legalEntities={legalEntities}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Drawer
|
||||||
|
size="large"
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerContent
|
||||||
|
drawerContent={drawerContent}
|
||||||
|
leid={legEntId}
|
||||||
|
onReloadCall={onReloadCall}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
web/src/store/slices/.gitkeep
Normal file
0
web/src/store/slices/.gitkeep
Normal file
14
web/src/store/store.ts
Normal file
14
web/src/store/store.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
|
import filesReducer from "./slices/fileSlice";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
files: filesReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||||
|
export const useAppSelector = useSelector.withTypes<RootState>();
|
||||||
0
web/src/utils.ts
Normal file
0
web/src/utils.ts
Normal file
325
web/src/widgets/Sidebar/Sidebar.tsx
Normal file
325
web/src/widgets/Sidebar/Sidebar.tsx
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import styles from "./styles.module.css";
|
||||||
|
import {
|
||||||
|
Divider,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
Layout,
|
||||||
|
Tooltip,
|
||||||
|
type MenuProps,
|
||||||
|
Modal,
|
||||||
|
} from "antd";
|
||||||
|
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
BankOutlined,
|
||||||
|
ContainerOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
LeftOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SolutionOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
|
type CompanyGroupType = {
|
||||||
|
id: str;
|
||||||
|
name: str;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MenuItem = Required<MenuProps>["items"][number];
|
||||||
|
|
||||||
|
function CollapsedIcon({ icon, onClick, title, to, cn = "" }) {
|
||||||
|
if (to === null) {
|
||||||
|
return (
|
||||||
|
<Tooltip placement="right" title={title}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn || `${styles.iconBtn} ${styles.iconBtnNav}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip placement="right" title={title}>
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn ||
|
||||||
|
`${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</NavLink>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
link: string;
|
||||||
|
label: string;
|
||||||
|
icon: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
title: string;
|
||||||
|
key: string;
|
||||||
|
children: NavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: Group[] = [
|
||||||
|
{
|
||||||
|
title: "Справочники",
|
||||||
|
key: "catalogues",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
link: "/catalogue/groups",
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
label: "ГК и организации",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
link: "/catalogue/jur_l",
|
||||||
|
icon: <SolutionOutlined />,
|
||||||
|
label: "ЮЛ вне ГК",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Документы",
|
||||||
|
key: "docs",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
link: "/docstorage",
|
||||||
|
icon: <InboxOutlined />,
|
||||||
|
label: "Единое хранилище",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Система",
|
||||||
|
key: "system",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
link: "/logging",
|
||||||
|
icon: <ContainerOutlined />,
|
||||||
|
label: "Журнал действий",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function NavLinkStyled({ to, children }) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${styles.linkBase} ${isActive ? styles.linkActive : ""}`
|
||||||
|
}
|
||||||
|
to={to}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Section = memo(function Section({
|
||||||
|
isCollapsed,
|
||||||
|
group,
|
||||||
|
}: {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
group: Group;
|
||||||
|
}) {
|
||||||
|
return !isCollapsed ? (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>{group.title}</div>
|
||||||
|
{group.children.map((i) => {
|
||||||
|
return (
|
||||||
|
<NavLinkStyled key={i.link} to={i.link}>
|
||||||
|
{i.icon}
|
||||||
|
{i.label}
|
||||||
|
</NavLinkStyled>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Divider size="small" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{group.children.map((i) => (
|
||||||
|
<CollapsedIcon
|
||||||
|
key={i.link}
|
||||||
|
icon={i.icon}
|
||||||
|
// onClick={() => navigate(i.link)}
|
||||||
|
onClick={() => {}}
|
||||||
|
// isActive={isGroupActive(i.link)}
|
||||||
|
// isActive={false}
|
||||||
|
to={i.link}
|
||||||
|
title="Единое хранилище"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
companyGroups,
|
||||||
|
}: {
|
||||||
|
companyGroups: CompanyGroupType[];
|
||||||
|
}) {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [subgroupOpen, setSubgroupOpen] = useState(true);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const onCollapsedSearchClick = useCallback(() => {
|
||||||
|
setIsCollapsed(false);
|
||||||
|
const cb = requestIdleCallback(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
} else {
|
||||||
|
console.log("NO INPUT REF");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredSubgroups = useMemo(() => {
|
||||||
|
const query = search.toLowerCase().trim();
|
||||||
|
if (!query) return companyGroups;
|
||||||
|
return companyGroups.filter((cg) => {
|
||||||
|
return cg.name.toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}, [search, companyGroups]);
|
||||||
|
|
||||||
|
const isGroupCompanyGroup = useCallback(() => {
|
||||||
|
if (
|
||||||
|
location.pathname.startsWith("/groups/") &&
|
||||||
|
!location.pathname.endsWith("monozaemchiki")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const groupsBaseRef =
|
||||||
|
companyGroups.length > 0 ? `/groups/${companyGroups[0].id}` : "/groups";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Sider
|
||||||
|
collapsed={isCollapsed}
|
||||||
|
style={{ height: "auto", margin: "10px", borderRadius: 10 }}
|
||||||
|
width={280}
|
||||||
|
collapsedWidth={64}
|
||||||
|
collapsible
|
||||||
|
trigger={null}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ""}`}
|
||||||
|
>
|
||||||
|
<div className={styles.topbar}>
|
||||||
|
<button
|
||||||
|
className={styles.collapseBtn}
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
>
|
||||||
|
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<div>
|
||||||
|
<CollapsedIcon
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
cn={`${styles.iconBtn} ${styles.iconBtnAction}`}
|
||||||
|
to={null}
|
||||||
|
// isActive={false}
|
||||||
|
title="Новая консолидация"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CollapsedIcon
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
to={null}
|
||||||
|
title="Поиск ГК"
|
||||||
|
onClick={onCollapsedSearchClick}
|
||||||
|
/>
|
||||||
|
<CollapsedIcon
|
||||||
|
icon={<BankOutlined />}
|
||||||
|
onClick={() => navigate(groupsBaseRef)}
|
||||||
|
to={groupsBaseRef}
|
||||||
|
title="Группы компаний"
|
||||||
|
/>
|
||||||
|
<CollapsedIcon
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
onClick={() => navigate("/groups/monozaemchiki")}
|
||||||
|
to="/groups/monozaemchiki"
|
||||||
|
title="Монозаёмщики"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Отчётность</div>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
ref={inputRef}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
prefix={<SearchOutlined style={{ color: "#8c8c8c" }} />}
|
||||||
|
placeholder="Поиск ГК"
|
||||||
|
className={styles.search}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSubgroupOpen((v) => !v)}
|
||||||
|
className={styles.subgroupToggle}
|
||||||
|
>
|
||||||
|
<span>Группы компаний</span>
|
||||||
|
{subgroupOpen ? <DownOutlined /> : <RightOutlined />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{subgroupOpen && (
|
||||||
|
<div className={styles.subgroup}>
|
||||||
|
{filteredSubgroups.length === 0 ? (
|
||||||
|
<div className={styles.emptyWrap}>
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="Не найдено"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSubgroups.map((item) => (
|
||||||
|
<NavLinkStyled key={item.id} to={`/groups/${item.id}`}>
|
||||||
|
{item.name}
|
||||||
|
</NavLinkStyled>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Divider size="small" style={{ margin: 10 }} />
|
||||||
|
<NavLinkStyled to="/groups/monozaemchiki">
|
||||||
|
Монозаёмщики
|
||||||
|
</NavLinkStyled>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{config.map((g) => (
|
||||||
|
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Modal
|
||||||
|
open={modalOpen}
|
||||||
|
title="Форма создания консолидации"
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Layout.Sider>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
web/src/widgets/Sidebar/styles.module.css
Normal file
191
web/src/widgets/Sidebar/styles.module.css
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
.company_group_item {
|
||||||
|
border: 2px solid grey;
|
||||||
|
border-radius: 2px;
|
||||||
|
height: 50px;
|
||||||
|
width: 90%;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: lightgray;
|
||||||
|
}
|
||||||
|
.active_cg {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #8c8c8c;
|
||||||
|
text-transform: uppercase ;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subgroupToggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subgroup {
|
||||||
|
/* padding-left: 8px; */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* gap: 10px; */
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkBase {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(55, 65, 81, 0.88);
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .linkActive:hover {
|
||||||
|
color: rgb(31, 114, 55);
|
||||||
|
} */
|
||||||
|
|
||||||
|
.linkBase:hover {
|
||||||
|
color: rgba(55, 65, 81, 0.88);
|
||||||
|
background: rgb(249, 250, 251);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkBase.linkActive {
|
||||||
|
color: rgb(31, 114, 55);
|
||||||
|
background: #edf8ee;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyWrap {
|
||||||
|
padding-top: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapperCollapsed {
|
||||||
|
padding: 12px 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(0, 0, 0, 0.75);
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.iconBtnActive,
|
||||||
|
.iconBtnActive:hover {
|
||||||
|
background: rgba(46, 166, 75, 0.16);
|
||||||
|
color: rgb(31, 114, 55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtnNav:hover {
|
||||||
|
color: #101828;
|
||||||
|
background: #f2f4f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtnAction {
|
||||||
|
background: linear-gradient(160deg,#2ea64b,#1f7237);
|
||||||
|
font-size: small;
|
||||||
|
box-shadow: 0 1px 3px #1f723740;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtnAction:hover {
|
||||||
|
background: linear-gradient(160deg,#268f40,#185c2b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .iconBtn. */
|
||||||
|
|
||||||
|
|
||||||
|
.collapseBtn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(0, 0, 0, 0.75);
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
43
web/tailwind.config.js
Normal file
43
web/tailwind.config.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{html,js,jsx,ts,tsx}",
|
||||||
|
"node_modules/daisyui/dist/**/*.js",
|
||||||
|
"node_modules/react-daisyui/dist/**/*.js",
|
||||||
|
"node_modules/raisa-ui/dist/**/*.js"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [require('daisyui')],
|
||||||
|
daisyui: {
|
||||||
|
themes: [
|
||||||
|
{
|
||||||
|
rshb: {
|
||||||
|
...require('daisyui/src/theming/themes')['light'],
|
||||||
|
|
||||||
|
"primary": "#171A1C",
|
||||||
|
"primary-content": "#fcfcfd",
|
||||||
|
"secondary": "#ECEDEF",
|
||||||
|
"secondary-content": "#687178",
|
||||||
|
"accent": "#8ecaff",
|
||||||
|
"accent-content": "#fcfcfd",
|
||||||
|
"neutral": "#ecedef",
|
||||||
|
"neutral-content": "#171A1C",
|
||||||
|
"base-100": "#FCFCFD",
|
||||||
|
"base-200": "#F6F7F8",
|
||||||
|
"base-300": "#ECEDEF",
|
||||||
|
"base-content": "#171A1C",
|
||||||
|
"info": "#0894FF",
|
||||||
|
"info-content": "#E3E5E8",
|
||||||
|
"success": "#39B33D",
|
||||||
|
"success-content": "#E3E5E8",
|
||||||
|
"warning": "#E6B200",
|
||||||
|
"warning-content": "#fcfcfd",
|
||||||
|
"error": "#FF371A",
|
||||||
|
"error-content": "#fcfcfd",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
23
web/tsconfig.json
Normal file
23
web/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "ES2020"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"target": "ES2020",
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
|
||||||
|
/* modules */
|
||||||
|
"module": "ESNext",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
|
||||||
|
/* type checking */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user