Compare commits
1 Commits
main
...
test-tsyga
| Author | SHA1 | Date | |
|---|---|---|---|
| 60939a2cf2 |
164
README.md
164
README.md
@ -1,163 +1 @@
|
|||||||
[](https://github.com/psf/black) [](https://pycqa.github.io/isort/) [](https://github.com/pylint-dev/pylint)
|
# Дока
|
||||||
__________________________________________________________________________________________________________
|
|
||||||
# test_for_know_empt_repo, v 0.0.1
|
|
||||||
__test__
|
|
||||||
|
|
||||||
| | Исполнитель | Заказчик |
|
|
||||||
|-----------|:----------------------------------------:|:----------------------------------------------:|
|
|
||||||
| Сотрудник | raykov-mse | test1 |
|
|
||||||
| Отдел | aurora | aurora |
|
|
||||||
| Контакты | raykovmse@rshb.ru | test1@g.co |
|
|
||||||
-----
|
|
||||||
# Шаблон 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
|
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
pythonpath = .
|
|
||||||
testpaths = tests
|
|
||||||
python_files = test_*.py
|
|
||||||
python_classes = Test*
|
|
||||||
python_functions = test_*
|
|
||||||
asyncio_mode = auto
|
|
||||||
addopts = --verbose --tb=short --capture=no --strict-markers
|
|
||||||
norecursedirs = .git __pycache__ .venv env build dist
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
fastapi==0.111.0
|
|
||||||
uvicorn[standard]==0.30.1
|
|
||||||
pydantic==2.11.7
|
|
||||||
pydantic[email]
|
|
||||||
pydantic-settings==2.10.1
|
|
||||||
sqlalchemy>=2.0.32,<2.1
|
|
||||||
python-jose[cryptography]==3.3.0
|
|
||||||
argon2-cffi==23.1.0
|
|
||||||
python-multipart==0.0.9
|
|
||||||
python-dotenv==1.0.0
|
|
||||||
httpx==0.27.0
|
|
||||||
alembic==1.16.1
|
|
||||||
aiosqlite==0.20.0
|
|
||||||
asyncpg==0.30.0
|
|
||||||
pytest==8.3.2
|
|
||||||
pytest-asyncio==0.24.0
|
|
||||||
Binary file not shown.
@ -1,11 +1,11 @@
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
|
from src import app, get_settings
|
||||||
from src.main import app, settings
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
settings = get_settings()
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
host=settings.HOST,
|
host=settings.server.HOST,
|
||||||
port=settings.PORT,
|
port=settings.server.PORT,
|
||||||
lifespan="on",
|
lifespan="on"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1 +1,7 @@
|
|||||||
|
from .app import app
|
||||||
|
from .conf import get_settings
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"app",
|
||||||
|
"get_settings"
|
||||||
|
]
|
||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.core.config import settings
|
|
||||||
from src.db.session import get_db
|
|
||||||
from src.domain.schemas import LoginRequest, RefreshRequest, Token
|
|
||||||
from src.services.auth_service import AuthService
|
|
||||||
|
|
||||||
if not settings.DEBUG:
|
|
||||||
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
||||||
|
|
||||||
|
|
||||||
if settings.DEBUG:
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
|
||||||
async def login(
|
|
||||||
login_data: LoginRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
auth_service = AuthService(db)
|
|
||||||
token = await auth_service.authenticate_user(login_data.username, login_data.password)
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Неверное имя пользователя или пароль",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
return token
|
|
||||||
|
|
||||||
else:
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
|
||||||
async def login(
|
|
||||||
login_data: LoginRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: UserInfo = Depends(get_user_dependency),
|
|
||||||
):
|
|
||||||
auth_service = AuthService(db)
|
|
||||||
token = await auth_service.authenticate_user_via_email(user.email)
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Неверное имя пользователя или пароль",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login-form", response_model=Token)
|
|
||||||
async def login_form(request: Request, db: AsyncSession = Depends(get_db)):
|
|
||||||
form = await request.form()
|
|
||||||
username = form.get("username")
|
|
||||||
password = form.get("password")
|
|
||||||
if not username or not password:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
detail="username/password required",
|
|
||||||
)
|
|
||||||
auth_service = AuthService(db)
|
|
||||||
token = await auth_service.authenticate_user(username, password)
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Неверное имя пользователя или пароль",
|
|
||||||
)
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=Token)
|
|
||||||
async def refresh_token(
|
|
||||||
refresh_data: RefreshRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
auth_service = AuthService(db)
|
|
||||||
token = await auth_service.refresh_token(refresh_data.refresh_token)
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Недействительный refresh токен",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
return token
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.core.config import settings
|
|
||||||
from src.core.security import verify_token
|
|
||||||
from src.db.session import get_db
|
|
||||||
from src.domain.models import UserRole, Users
|
|
||||||
from src.repository.user_repository import UserRepository
|
|
||||||
|
|
||||||
security = HTTPBearer()
|
|
||||||
|
|
||||||
|
|
||||||
if settings.DEBUG:
|
|
||||||
|
|
||||||
async def get_current_user(
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> Users:
|
|
||||||
token = credentials.credentials
|
|
||||||
payload = verify_token(token)
|
|
||||||
if payload is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Недействительный токен",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
username: str | None = payload.get("sub")
|
|
||||||
if username is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Недействительный токен",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
user_repo = UserRepository(db)
|
|
||||||
user = await user_repo.get_by_username(username)
|
|
||||||
if user is None:
|
|
||||||
user = await user_repo.get_by_email(username)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Пользователь не найден",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
else:
|
|
||||||
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
|
||||||
|
|
||||||
async def get_current_user(
|
|
||||||
user: UserInfo = Depends(get_user_dependency),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> Users:
|
|
||||||
user_repo = UserRepository(db)
|
|
||||||
db_user = await user_repo.get_by_email(user.email)
|
|
||||||
if db_user is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Пользователь не найден",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
return db_user
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_active_user(current_user: Users = Depends(get_current_user)) -> Users:
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users:
|
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Недостаточно прав",
|
|
||||||
)
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
def require_executor(current_user: Users = Depends(get_current_active_user)) -> Users:
|
|
||||||
if current_user.role_id not in [
|
|
||||||
UserRole.ADMIN,
|
|
||||||
UserRole.EXECUTOR_DFIP,
|
|
||||||
UserRole.EXECUTOR_RF,
|
|
||||||
]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Недостаточно прав",
|
|
||||||
)
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
def require_executor_dfip(current_user: Users = Depends(get_current_active_user)) -> Users:
|
|
||||||
if current_user.role_id not in [UserRole.ADMIN, UserRole.EXECUTOR_DFIP]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Недостаточно прав",
|
|
||||||
)
|
|
||||||
return current_user
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from src.api.v1 import auth, users
|
|
||||||
|
|
||||||
api_router = APIRouter()
|
|
||||||
api_router.include_router(auth.router)
|
|
||||||
api_router.include_router(users.router)
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.api.v1.deps import get_current_active_user, require_admin, require_executor_dfip
|
|
||||||
from src.db.session import get_db
|
|
||||||
from src.domain.models import Users
|
|
||||||
from src.domain.schemas import User as UserSchema
|
|
||||||
from src.domain.schemas import (
|
|
||||||
SSPIDList,
|
|
||||||
ResponseBase,
|
|
||||||
UserCreate,
|
|
||||||
UserListResponse,
|
|
||||||
UserResponse,
|
|
||||||
UserSSPLinkBase,
|
|
||||||
UserSSPLinkResponse,
|
|
||||||
UserUpdate,
|
|
||||||
)
|
|
||||||
from src.services.user_service import UserService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserSchema)
|
|
||||||
async def get_current_user_info(current_user: Users = Depends(get_current_active_user)):
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=UserListResponse)
|
|
||||||
async def get_users(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: Users = Depends(require_admin),
|
|
||||||
):
|
|
||||||
user_service = UserService(db)
|
|
||||||
users = await user_service.get_all(current_user, skip=skip, limit=limit)
|
|
||||||
return UserListResponse(success=True, message="Список пользователей", result=users)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dfip-many-ssp/", response_model=UserSSPLinkResponse)
|
|
||||||
async def get_many_ssp(
|
|
||||||
user_id: int,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: Users = Depends(require_executor_dfip),
|
|
||||||
):
|
|
||||||
user_service = UserService(db)
|
|
||||||
ssp_list = await user_service.get_many_ssp(user_id, current_user)
|
|
||||||
return UserSSPLinkResponse(
|
|
||||||
success=True,
|
|
||||||
message="OK",
|
|
||||||
result=UserSSPLinkBase(user_id=user_id, ssp=ssp_list),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/dfip-many-ssp/", response_model=ResponseBase)
|
|
||||||
async def set_many_ssp(
|
|
||||||
user_id: int,
|
|
||||||
ssp_ids_data: SSPIDList,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: Users = Depends(require_admin),
|
|
||||||
):
|
|
||||||
user_service = UserService(db)
|
|
||||||
success = await user_service.set_many_ssp(user_id, ssp_ids_data.ssp_ids, current_user)
|
|
||||||
return ResponseBase(
|
|
||||||
success=success,
|
|
||||||
message="OK" if success else "Ошибка при прикреплении ССП/РФ",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/dfip-many-ssp/", response_model=ResponseBase)
|
|
||||||
async def unset_many_ssp(
|
|
||||||
user_id: int,
|
|
||||||
ssp_ids_data: SSPIDList,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: Users = Depends(require_admin),
|
|
||||||
):
|
|
||||||
user_service = UserService(db)
|
|
||||||
success = await user_service.unset_many_ssp(
|
|
||||||
user_id, ssp_ids_data.ssp_ids, current_user
|
|
||||||
)
|
|
||||||
return ResponseBase(
|
|
||||||
success=success,
|
|
||||||
message="OK" if success else "Ошибка при откреплении ССП/РФ",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserSchema)
|
|
||||||
async def get_user(
|
|
||||||
user_id: int,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: Users = Depends(require_admin),
|
|
||||||
):
|
|
||||||
user_service = UserService(db)
|
|
||||||
user = await user_service.get(user_id, current_user)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Пользователь не найден",
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/", response_model=UserResponse)
|
|
||||||
async def create_user(
|
|
||||||
user_data: UserCreate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: Users = Depends(require_admin),
|
|
||||||
):
|
|
||||||
user_service = UserService(db)
|
|
||||||
user = await user_service.create_user(user_data=user_data, creator=current_user)
|
|
||||||
return UserResponse(success=True, message="Пользователь создан", result=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{user_id}", response_model=UserResponse)
|
|
||||||
async def update_user(
|
|
||||||
user_id: int,
|
|
||||||
user_data: UserUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: Users = Depends(require_admin),
|
|
||||||
):
|
|
||||||
user_service = UserService(db)
|
|
||||||
user = await user_service.update_user(
|
|
||||||
user_id=user_id,
|
|
||||||
user_data=user_data,
|
|
||||||
user=current_user,
|
|
||||||
)
|
|
||||||
return UserResponse(success=True, message="Пользователь обновлен", result=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", response_model=UserResponse)
|
|
||||||
async def delete_user(
|
|
||||||
user_id: int,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: Users = Depends(require_admin),
|
|
||||||
):
|
|
||||||
user_service = UserService(db)
|
|
||||||
await user_service.delete_user(user_id=user_id, user=current_user)
|
|
||||||
return UserResponse(success=True, message="Пользователь удалён")
|
|
||||||
|
|
||||||
68
api/src/app.py
Normal file
68
api/src/app.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .conf import get_settings
|
||||||
|
from .exceptions import NotFound, PermissionDenied, ValidationError
|
||||||
|
from .routes import api_route
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
UP_TIME = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Fast API",
|
||||||
|
version=VERSION,
|
||||||
|
root_path=settings.ROOT_PATH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(NotFound)
|
||||||
|
async def not_found_handler(request: Request, exc: NotFound):
|
||||||
|
return JSONResponse(status_code=404, content={"detail": exc.message})
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(PermissionDenied)
|
||||||
|
async def permission_denied_handler(request: Request, exc: PermissionDenied):
|
||||||
|
return JSONResponse(status_code=403, content={"detail": exc.message})
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(ValidationError)
|
||||||
|
async def validation_error_handler(request: Request, exc: ValidationError):
|
||||||
|
return JSONResponse(status_code=422, content={"detail": exc.message})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/healthcheck",
|
||||||
|
status_code=200
|
||||||
|
)
|
||||||
|
async def healthcheck():
|
||||||
|
return {
|
||||||
|
"uptime": UP_TIME,
|
||||||
|
"version": VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
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(api_route)
|
||||||
|
|
||||||
|
#Подключение собранного web приложения
|
||||||
|
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
||||||
5
api/src/conf/__init__.py
Normal file
5
api/src/conf/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from .settings import get_settings
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_settings"
|
||||||
|
]
|
||||||
9
api/src/conf/models.py
Normal file
9
api/src/conf/models.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ServerSettings(BaseModel):
|
||||||
|
"""
|
||||||
|
Настройки сервера uvicorn
|
||||||
|
"""
|
||||||
|
HOST: str = '0.0.0.0'
|
||||||
|
PORT: int = 8000
|
||||||
34
api/src/conf/settings.py
Normal file
34
api/src/conf/settings.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from fastapi.logger import logger
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from .models import ServerSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
""" Настройки приложения """
|
||||||
|
ROOT_PATH: str = '/'
|
||||||
|
DATABASE_URL: str = ''
|
||||||
|
|
||||||
|
server: ServerSettings = ServerSettings()
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = 'ignore'
|
||||||
|
env_file = ".env"
|
||||||
|
env_nested_delimiter = '__'
|
||||||
|
nested_model_default_partial_update = False
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""
|
||||||
|
Получение настроек приложения
|
||||||
|
:return: Настройки приложения
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_ = load_dotenv()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error("Error load settings", exc_info=err)
|
||||||
|
return Settings()
|
||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import os
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pydantic import AliasChoices, Field
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
if os.environ.get("APP_ENV", "dev") == "test":
|
|
||||||
prefix = "OPENBAO__SETTINGS_TEST"
|
|
||||||
else:
|
|
||||||
prefix = "OPENBAO__SETTINGS"
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Настройки backend для DFiP Budget Planing."""
|
|
||||||
|
|
||||||
ROOT_PATH: str = Field(default="", description="Префикс API", alias="ROOT_PATH")
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL: str = Field(
|
|
||||||
default="sqlite+aiosqlite:///./dfip_budget_planing.db",
|
|
||||||
description="URL подключения к базе данных",
|
|
||||||
validation_alias=AliasChoices(f"{prefix}__DATABASE_URL", "DATABASE_URL"),
|
|
||||||
)
|
|
||||||
DATABASE_SCHEMA: str = Field(
|
|
||||||
default="",
|
|
||||||
description="Схема БД (для Postgres search_path)",
|
|
||||||
validation_alias=AliasChoices(f"{prefix}__DATABASE_SCHEMA", "DATABASE_SCHEMA"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET: str = Field(
|
|
||||||
default="change-me-in-production",
|
|
||||||
description="Секретный ключ для JWT",
|
|
||||||
)
|
|
||||||
JWT_ALGORITHM: str = Field(default="HS256", description="Алгоритм JWT")
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(
|
|
||||||
default=60,
|
|
||||||
description="Время жизни access-токена (минуты)",
|
|
||||||
)
|
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(
|
|
||||||
default=7,
|
|
||||||
description="Время жизни refresh-токена (дни)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ORIGINS: List[str] = Field(
|
|
||||||
default=[
|
|
||||||
"https://raisa.go.rshbank.ru",
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://localhost:8080",
|
|
||||||
"http://127.0.0.1:3000",
|
|
||||||
"http://127.0.0.1:8000",
|
|
||||||
],
|
|
||||||
description="Разрешенные origins для CORS",
|
|
||||||
)
|
|
||||||
|
|
||||||
HOST: str = Field(
|
|
||||||
default="0.0.0.0",
|
|
||||||
description="Хост API",
|
|
||||||
validation_alias=AliasChoices("SERVER__HOST", "HOST"),
|
|
||||||
)
|
|
||||||
PORT: int = Field(
|
|
||||||
default=8000,
|
|
||||||
description="Порт API",
|
|
||||||
validation_alias=AliasChoices("SERVER__PORT", "PORT"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
ENVIRONMENT: str = Field(
|
|
||||||
default="development",
|
|
||||||
description="Окружение приложения",
|
|
||||||
alias="APP_ENV",
|
|
||||||
)
|
|
||||||
DEBUG: bool = Field(
|
|
||||||
default=True,
|
|
||||||
description="Режим отладки",
|
|
||||||
validation_alias=AliasChoices(f"{prefix}__DEBUG", "DEBUG"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Authorization (prod branch)
|
|
||||||
JWKS_URL: str = Field(
|
|
||||||
default="https://keycloak.raisa.go.rshbank.ru/realms/datalab/protocol/openid-connect/certs",
|
|
||||||
description="URL для получения JWKS",
|
|
||||||
alias="JWKS_URL",
|
|
||||||
)
|
|
||||||
APP_NAMESPACE: str = Field(
|
|
||||||
default="dfip",
|
|
||||||
description="Namespace приложения для protected-api",
|
|
||||||
alias="APP_NAMESPACE",
|
|
||||||
)
|
|
||||||
APP_NAME: str = Field(
|
|
||||||
default="dfip_budget_planing_api",
|
|
||||||
description="Имя приложения для protected-api",
|
|
||||||
alias="APP_NAME",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log
|
|
||||||
AUDIT_LOG_RETENTION_DAYS: int = Field(
|
|
||||||
default=365,
|
|
||||||
description="Удалять записи аудита старше N дней",
|
|
||||||
alias="AUDIT_LOG_RETENTION_DAYS",
|
|
||||||
)
|
|
||||||
AUDIT_LOG_CLEANUP_BATCH_SIZE: int = Field(
|
|
||||||
default=5000,
|
|
||||||
description="Размер батча для очистки аудита",
|
|
||||||
alias="AUDIT_LOG_CLEANUP_BATCH_SIZE",
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".env",
|
|
||||||
extra="ignore",
|
|
||||||
env_nested_delimiter="__",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(BaseModel):
|
|
||||||
field: str = Field(...)
|
|
||||||
description: str = Field(...)
|
|
||||||
|
|
||||||
|
|
||||||
class BasicAppException(Exception):
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AccessDeniedException(BasicAppException):
|
|
||||||
description = "Доступ запрещен"
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationException(BasicAppException):
|
|
||||||
description = "Ошибка валидации"
|
|
||||||
|
|
||||||
def __init__(self, description: str | None = None, field: str | None = None, *args):
|
|
||||||
super().__init__(*args)
|
|
||||||
self.field = field
|
|
||||||
self.description = description or ValidationException.description
|
|
||||||
|
|
||||||
|
|
||||||
class UserNotFoundException(BasicAppException):
|
|
||||||
description = "Пользователь не найден"
|
|
||||||
|
|
||||||
|
|
||||||
class UsernameConflictException(BasicAppException):
|
|
||||||
description = "Пользователь с таким username уже существует"
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
from fastapi import FastAPI, status
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
from src.core.errors import (
|
|
||||||
AccessDeniedException,
|
|
||||||
BasicAppException,
|
|
||||||
UserNotFoundException,
|
|
||||||
UsernameConflictException,
|
|
||||||
ValidationException,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI) -> None:
|
|
||||||
@app.exception_handler(AccessDeniedException)
|
|
||||||
async def access_denied_exception_handler(request, exc: AccessDeniedException):
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
content={"detail": exc.description},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(ValidationException)
|
|
||||||
async def validation_exception_handler(request, exc: ValidationException):
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
content={"detail": exc.description},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(UserNotFoundException)
|
|
||||||
async def user_not_found_exception_handler(request, exc: UserNotFoundException):
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
content={"detail": exc.description},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(UsernameConflictException)
|
|
||||||
async def username_conflict_exception_handler(request, exc: UsernameConflictException):
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
content={"detail": exc.description},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(BasicAppException)
|
|
||||||
async def basic_app_exception_handler(request, exc: BasicAppException):
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
content={"detail": exc.description or "Application error"},
|
|
||||||
)
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
|
||||||
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
|
|
||||||
from src.core.config import settings
|
|
||||||
|
|
||||||
pwd_context = PasswordHasher()
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: Optional[str]) -> bool:
|
|
||||||
if not hashed_password:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return pwd_context.verify(hashed_password, plain_password)
|
|
||||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
|
||||||
return pwd_context.hash(password)
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
|
||||||
to_encode = data.copy()
|
|
||||||
if expires_delta:
|
|
||||||
expire = datetime.now(timezone.utc) + expires_delta
|
|
||||||
else:
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
|
||||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
|
||||||
)
|
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(data: dict) -> str:
|
|
||||||
to_encode = data.copy()
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
|
||||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
|
||||||
)
|
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_token(token: str) -> Optional[dict]:
|
|
||||||
try:
|
|
||||||
return jwt.decode(
|
|
||||||
token,
|
|
||||||
settings.JWT_SECRET,
|
|
||||||
algorithms=[settings.JWT_ALGORITHM],
|
|
||||||
)
|
|
||||||
except JWTError:
|
|
||||||
return None
|
|
||||||
0
api/src/data/__init__.py
Normal file
0
api/src/data/__init__.py
Normal file
66
api/src/data/budget_line.py
Normal file
66
api/src/data/budget_line.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db.models.budget_line import BudgetLine
|
||||||
|
|
||||||
|
|
||||||
|
async def update_budget_line(
|
||||||
|
db: AsyncSession,
|
||||||
|
line_id: int,
|
||||||
|
table_name: str,
|
||||||
|
field_name: str,
|
||||||
|
value: typing.Any,
|
||||||
|
quarter_field: str | None = None,
|
||||||
|
quarter: int | None = None,
|
||||||
|
) -> typing.Any:
|
||||||
|
where_text = f"line_id = :line_id"
|
||||||
|
if quarter and quarter_field:
|
||||||
|
where_text = f"{where_text} AND {quarter_field} = :quarter"
|
||||||
|
|
||||||
|
query_text = f"UPDATE {table_name} SET {field_name} = :value WHERE {where_text} RETURNING *"
|
||||||
|
query = text(query_text)
|
||||||
|
result = (
|
||||||
|
await db.execute(
|
||||||
|
query,
|
||||||
|
{"line_id": line_id, "value": value, "quarter": quarter}
|
||||||
|
)
|
||||||
|
).fetchone()
|
||||||
|
return tuple(result) if result else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_budget_line(
|
||||||
|
db: AsyncSession,
|
||||||
|
id: int | None = None,
|
||||||
|
form_id: int | None = None,
|
||||||
|
expense_item_id: int | None = None,
|
||||||
|
) -> BudgetLine | None:
|
||||||
|
assert id or (expense_item_id and form_id)
|
||||||
|
where_clause = []
|
||||||
|
if id:
|
||||||
|
where_clause.append(BudgetLine.id == id)
|
||||||
|
if expense_item_id:
|
||||||
|
where_clause.append(BudgetLine.expense_item_id == expense_item_id)
|
||||||
|
where_clause.append(BudgetLine.budget_form_id == form_id)
|
||||||
|
result = await db.execute(select(BudgetLine).where(*where_clause))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_budget_line(
|
||||||
|
db: AsyncSession,
|
||||||
|
form_id: int,
|
||||||
|
expense_item_id: int | None = None,
|
||||||
|
) -> BudgetLine:
|
||||||
|
result = BudgetLine(
|
||||||
|
expense_item_id=expense_item_id,
|
||||||
|
budget_form_id=form_id,
|
||||||
|
# internal_order: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
name="Test"
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
result
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
return result
|
||||||
17
api/src/data/expense_item.py
Normal file
17
api/src/data/expense_item.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db.models import ExpenseItem
|
||||||
|
from src.db.models.budget_line import BudgetLine
|
||||||
|
|
||||||
|
|
||||||
|
async def get_expense_item_by_budget_line(
|
||||||
|
db: AsyncSession,
|
||||||
|
budget_line_id: int,
|
||||||
|
) -> ExpenseItem | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ExpenseItem)
|
||||||
|
.join(BudgetLine, BudgetLine.expense_item_id == ExpenseItem.id)
|
||||||
|
.where(BudgetLine.id == budget_line_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
45
api/src/data/page.py
Normal file
45
api/src/data/page.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
async def get_page_with_counts(
|
||||||
|
db: AsyncSession,
|
||||||
|
form_id: int,
|
||||||
|
page: str,
|
||||||
|
offset: int | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> list:
|
||||||
|
query_text = "SELECT COUNT(*) OVER(), * FROM v_form1_sheet_jsonb(:form_id, :page)"
|
||||||
|
if offset is not None:
|
||||||
|
query_text += " OFFSET :offset"
|
||||||
|
if limit is not None:
|
||||||
|
query_text += " LIMIT :limit"
|
||||||
|
|
||||||
|
query = text(query_text)
|
||||||
|
|
||||||
|
return (
|
||||||
|
await db.execute(
|
||||||
|
query,
|
||||||
|
{"form_id": form_id, "page": page, "limit": limit, "offset": offset}
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def get_page_row(
|
||||||
|
db: AsyncSession,
|
||||||
|
form_id: int,
|
||||||
|
page: str,
|
||||||
|
budget_line_id: int
|
||||||
|
) -> list:
|
||||||
|
query_text = "SELECT * FROM v_form1_sheet_jsonb(:form_id, :page) as res WHERE line_id = :line_id"
|
||||||
|
|
||||||
|
query = text(query_text)
|
||||||
|
|
||||||
|
return (
|
||||||
|
await db.execute(
|
||||||
|
query,
|
||||||
|
{"form_id": form_id, "page": page, "line_id": budget_line_id}
|
||||||
|
)
|
||||||
|
).fetchone()
|
||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
@ -1,27 +1,42 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import (AsyncAttrs, async_sessionmaker,
|
||||||
|
create_async_engine)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from src.core.config import settings
|
from src.conf.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Создание движка базы данных
|
||||||
connect_args = {}
|
connect_args = {}
|
||||||
if settings.DATABASE_SCHEMA:
|
# if settings.DATABASE_SCHEMA:
|
||||||
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
|
# connect_args["server_settings"] = {
|
||||||
|
# "search_path": settings.DATABASE_SCHEMA,
|
||||||
|
# }
|
||||||
|
|
||||||
if "sqlite" in settings.DATABASE_URL:
|
if "sqlite" in settings.DATABASE_URL:
|
||||||
connect_args["check_same_thread"] = False
|
connect_args["check_same_thread"] = False
|
||||||
|
engine = create_async_engine( # create_engine(
|
||||||
engine = create_async_engine(
|
|
||||||
settings.DATABASE_URL,
|
settings.DATABASE_URL,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
# echo=settings.DEBUG,
|
||||||
connect_args=connect_args,
|
connect_args=connect_args,
|
||||||
|
# echo=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Создание фабрики сессий
|
||||||
|
# SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
SessionLocal = async_sessionmaker(
|
SessionLocal = async_sessionmaker(
|
||||||
|
# autocommit=False,
|
||||||
autoflush=False,
|
autoflush=False,
|
||||||
bind=engine,
|
bind=engine,
|
||||||
expire_on_commit=False,
|
expire_on_commit=False,
|
||||||
|
# class_=AsyncSession
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Базовый класс для моделей
|
||||||
class Base(AsyncAttrs, DeclarativeBase):
|
class Base(AsyncAttrs, DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
5
api/src/db/models/__init__.py
Normal file
5
api/src/db/models/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from src.db.models.budget_form import BudgetForm
|
||||||
|
from src.db.models.budget_line import BudgetLine
|
||||||
|
from src.db.models.expense_item import ExpenseItem
|
||||||
|
|
||||||
|
__all__ = ["BudgetForm", "BudgetLine", "ExpenseItem"]
|
||||||
24
api/src/db/models/budget_form.py
Normal file
24
api/src/db/models/budget_form.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import typing
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetForm(Base):
|
||||||
|
__tablename__ = "budget_form"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
form_type_code: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
year: Mapped[typing.Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
ssp_id: Mapped[typing.Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
branch_id: Mapped[typing.Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
created_by: Mapped[typing.Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
created_at: Mapped[typing.Optional[str]] = mapped_column(DateTime, server_default=func.now(), nullable=True)
|
||||||
|
updated_by: Mapped[typing.Optional[str]] = mapped_column(String, nullable=True)
|
||||||
|
updated_at: Mapped[typing.Optional[str]] = mapped_column(DateTime, server_default=func.now(), nullable=True)
|
||||||
|
|
||||||
|
budget_lines: Mapped[list["BudgetLine"]] = relationship( # type: ignore
|
||||||
|
"BudgetLine", back_populates="budget_form"
|
||||||
|
)
|
||||||
19
api/src/db/models/budget_line.py
Normal file
19
api/src/db/models/budget_line.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.models.budget_form import BudgetForm
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetLine(Base):
|
||||||
|
__tablename__ = "budget_line"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
expense_item_id: Mapped[int] = mapped_column(ForeignKey("expense_item.id"))
|
||||||
|
budget_form_id: Mapped[int] = mapped_column(ForeignKey("budget_form.id"))
|
||||||
|
internal_order: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
|
budget_form: Mapped["BudgetForm | None"] = relationship(
|
||||||
|
"BudgetForm", back_populates="budget_lines"
|
||||||
|
)
|
||||||
40
api/src/db/models/expense_item.py
Normal file
40
api/src/db/models/expense_item.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import typing
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseItem(Base):
|
||||||
|
__tablename__ = "expense_item"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
section_code: Mapped[typing.Optional[str]]
|
||||||
|
item_id: Mapped[typing.Optional[str]]
|
||||||
|
num_group_id: Mapped[typing.Optional[str]]
|
||||||
|
name: Mapped[typing.Optional[str]]
|
||||||
|
sheet: Mapped[typing.Optional[str]]
|
||||||
|
direction: Mapped[typing.Optional[str]]
|
||||||
|
parent_id: Mapped[typing.Optional[int]] = mapped_column(ForeignKey("expense_item.id"), nullable=True)
|
||||||
|
depth: Mapped[typing.Optional[int]] = mapped_column(Integer, default=0, nullable=True)
|
||||||
|
|
||||||
|
parent: Mapped["ExpenseItem | None"] = relationship(
|
||||||
|
"ExpenseItem", remote_side="ExpenseItem.id", back_populates="children"
|
||||||
|
)
|
||||||
|
children: Mapped[list["ExpenseItem"]] = relationship(
|
||||||
|
"ExpenseItem", back_populates="parent"
|
||||||
|
)
|
||||||
|
|
||||||
|
# __table_args__ = (
|
||||||
|
# CheckConstraint(
|
||||||
|
# "direction::text = ANY (ARRAY['Support'::character varying, 'Development'::character varying]::text[])",
|
||||||
|
# name="expense_item_direction_check",
|
||||||
|
# ),
|
||||||
|
# CheckConstraint(
|
||||||
|
# "sheet::text = ANY (ARRAY['AHR'::character varying, 'CAP'::character varying, 'OPER'::character varying]::text[])",
|
||||||
|
# name="expense_item_sheet_check",
|
||||||
|
# ),
|
||||||
|
# Index("ix_expense_item_parent", "parent_id"),
|
||||||
|
# Index("ix_expense_item_sheet", "sheet"),
|
||||||
|
# )
|
||||||
@ -1,26 +1,26 @@
|
|||||||
import asyncio
|
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
from alembic import command
|
|
||||||
from alembic.config import Config
|
|
||||||
|
|
||||||
from src.core.config import settings
|
|
||||||
from src.db.base import SessionLocal
|
from src.db.base import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator:
|
async def get_db() -> AsyncGenerator:
|
||||||
|
"""Dependency для получения сессии базы данных."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
|
await db.commit()
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
async def run_migrations() -> None:
|
# async def run_migrations():
|
||||||
alembic_cfg = Config("alembic.ini")
|
# alembic_cfg = Config("alembic.ini")
|
||||||
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
|
# await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
|
||||||
|
|
||||||
|
|
||||||
async def create_tables() -> None:
|
# async def create_tables():
|
||||||
if not settings.DEBUG:
|
# """Создание таблиц в базе данных."""
|
||||||
await run_migrations()
|
# # Исключаем устаревшую таблицу table_data из автосоздания,
|
||||||
|
# # чтобы она не пересоздавалась после миграций
|
||||||
|
# if not settings.DEBUG:
|
||||||
|
# await run_migrations()
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import enum
|
|
||||||
|
|
||||||
|
|
||||||
class AuditEventType(str, enum.Enum):
|
|
||||||
LOGIN = "LOGIN"
|
|
||||||
USER_CREATE = "USER_CREATE"
|
|
||||||
USER_UPDATE = "USER_UPDATE"
|
|
||||||
USER_DELETE = "USER_DELETE"
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
import enum
|
|
||||||
|
|
||||||
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String,
|
|
||||||
UniqueConstraint)
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
|
|
||||||
from src.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class UserRole(int, enum.Enum):
|
|
||||||
ADMIN = 1
|
|
||||||
EXECUTOR_DFIP = 2
|
|
||||||
EXECUTOR_RF = 3
|
|
||||||
|
|
||||||
|
|
||||||
class Roles(Base):
|
|
||||||
__tablename__ = "roles"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
role = Column(String, nullable=False)
|
|
||||||
|
|
||||||
user_role = relationship("Users", back_populates="role", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class Users(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
email = Column(String, unique=True, index=True, nullable=False)
|
|
||||||
username = Column(String, unique=True, index=True, nullable=False)
|
|
||||||
hashed_password = Column(String, nullable=True)
|
|
||||||
full_name = Column(String, nullable=True)
|
|
||||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
|
|
||||||
|
|
||||||
role = relationship(
|
|
||||||
"Roles",
|
|
||||||
foreign_keys=[role_id],
|
|
||||||
back_populates="user_role",
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
user_ssp_link = relationship("UserSSPLink", back_populates="users", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class SSP(Base):
|
|
||||||
__tablename__ = "ssp"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
title = Column(String, nullable=False)
|
|
||||||
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
|
|
||||||
is_ssp = Column(Boolean, default=True, nullable=False, server_default="true")
|
|
||||||
|
|
||||||
user_ssp_link = relationship("UserSSPLink", back_populates="ssp", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class UserSSPLink(Base):
|
|
||||||
__tablename__ = "user_ssp_link"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("user_id", "ssp_id", name="uq_user_ssp_link_user_id_ssp_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False)
|
|
||||||
|
|
||||||
users = relationship("Users", back_populates="user_ssp_link", lazy="select")
|
|
||||||
ssp = relationship("SSP", back_populates="user_ssp_link", lazy="select")
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ResponseBase(BaseModel):
|
|
||||||
success: Optional[bool] = Field(None)
|
|
||||||
message: Optional[str] = Field(None)
|
|
||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
username: str = Field(
|
|
||||||
...,
|
|
||||||
min_length=3,
|
|
||||||
max_length=64,
|
|
||||||
pattern=r"^[A-Za-z0-9_.-]+$",
|
|
||||||
)
|
|
||||||
full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$")
|
|
||||||
role_id: int = 3
|
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
|
||||||
password: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
|
||||||
email: Optional[EmailStr] = None
|
|
||||||
username: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
min_length=3,
|
|
||||||
max_length=64,
|
|
||||||
pattern=r"^[A-Za-z0-9_.-]+$",
|
|
||||||
)
|
|
||||||
full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$")
|
|
||||||
role_id: Optional[int] = None
|
|
||||||
is_active: Optional[bool] = True
|
|
||||||
|
|
||||||
|
|
||||||
class UserInDB(UserBase):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
is_active: bool
|
|
||||||
|
|
||||||
|
|
||||||
class User(UserInDB):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(ResponseBase):
|
|
||||||
result: Optional[UserInDB] = None
|
|
||||||
|
|
||||||
|
|
||||||
class UserListResponse(ResponseBase):
|
|
||||||
result: Optional[list[User]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class SSPBase(BaseModel):
|
|
||||||
title: str
|
|
||||||
is_ssp: bool = True
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class SSPInDB(SSPBase):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: int
|
|
||||||
|
|
||||||
|
|
||||||
class SSPIDList(BaseModel):
|
|
||||||
ssp_ids: list[int]
|
|
||||||
|
|
||||||
|
|
||||||
class UserSSPLinkBase(BaseModel):
|
|
||||||
user_id: int
|
|
||||||
ssp: list[SSPInDB]
|
|
||||||
|
|
||||||
|
|
||||||
class UserSSPLinkResponse(ResponseBase):
|
|
||||||
result: Optional[UserSSPLinkBase] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str = "bearer"
|
|
||||||
expires_in: Optional[int] = None
|
|
||||||
refresh_token: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
|
||||||
refresh_token: str
|
|
||||||
21
api/src/exceptions.py
Normal file
21
api/src/exceptions.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import dataclasses
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
message: str = "Ошибка"
|
||||||
|
|
||||||
|
def __init__(self, message: str | None = None, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.message = message or self.message
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionDenied(AppError):
|
||||||
|
message: str = "Нет доступа"
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(AppError):
|
||||||
|
message: str = "Не найдено"
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(AppError):
|
||||||
|
message: str = "Ошибка валидации"
|
||||||
111
api/src/main.py
111
api/src/main.py
@ -1,111 +0,0 @@
|
|||||||
import os
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from starlette.middleware.gzip import GZipMiddleware
|
|
||||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
||||||
|
|
||||||
from src.api.v1.router import api_router
|
|
||||||
from src.core.config import settings
|
|
||||||
from src.core.exception_handlers import register_exception_handlers
|
|
||||||
from src.db.base import engine
|
|
||||||
from src.db.session import create_tables
|
|
||||||
|
|
||||||
if not settings.DEBUG:
|
|
||||||
from raisa_fastapi_protected_api import (
|
|
||||||
AuthorizationMiddleware,
|
|
||||||
OpenEndpoint,
|
|
||||||
ProtectedOAuthSettings,
|
|
||||||
ProtectedRolesSettings,
|
|
||||||
ProtectedSettings,
|
|
||||||
SearchType,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
if not settings.DEBUG:
|
|
||||||
await create_tables()
|
|
||||||
ProtectedSettings(
|
|
||||||
ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
|
|
||||||
ProtectedRolesSettings(
|
|
||||||
APP_NAMESPACE=settings.APP_NAMESPACE,
|
|
||||||
APP_NAME=settings.APP_NAME,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="DFiP Budget Planning API",
|
|
||||||
version="1.0.0",
|
|
||||||
lifespan=lifespan,
|
|
||||||
root_path=settings.ROOT_PATH,
|
|
||||||
)
|
|
||||||
register_exception_handlers(app)
|
|
||||||
|
|
||||||
VERSION = "1.0.0"
|
|
||||||
UP_TIME = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthcheck", status_code=200)
|
|
||||||
async def healthcheck():
|
|
||||||
return {"uptime": UP_TIME, "version": VERSION}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthcheck2", status_code=200)
|
|
||||||
async def healthcheck2():
|
|
||||||
return {
|
|
||||||
"uptime": UP_TIME,
|
|
||||||
"version": VERSION,
|
|
||||||
"debug": settings.DEBUG,
|
|
||||||
"host": settings.HOST,
|
|
||||||
"port": settings.PORT,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if not settings.DEBUG:
|
|
||||||
app.add_middleware(
|
|
||||||
AuthorizationMiddleware,
|
|
||||||
open_endpoints=[
|
|
||||||
OpenEndpoint(path="", type_search=SearchType.ABSOLUTE),
|
|
||||||
OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE),
|
|
||||||
OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE),
|
|
||||||
OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE),
|
|
||||||
OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE),
|
|
||||||
OpenEndpoint(
|
|
||||||
path=settings.ROOT_PATH + "/healthcheck",
|
|
||||||
type_search=SearchType.ABSOLUTE,
|
|
||||||
),
|
|
||||||
OpenEndpoint(path="/openapi.json", type_search=SearchType.START),
|
|
||||||
OpenEndpoint(
|
|
||||||
path=settings.ROOT_PATH + "/openapi.json",
|
|
||||||
type_search=SearchType.START,
|
|
||||||
),
|
|
||||||
OpenEndpoint(path="/docs", type_search=SearchType.START),
|
|
||||||
OpenEndpoint(
|
|
||||||
path=settings.ROOT_PATH + "/docs",
|
|
||||||
type_search=SearchType.START,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
|
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
|
|
||||||
|
|
||||||
os.makedirs("web", exist_ok=True)
|
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
|
||||||
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
|
||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import delete, select, update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.core.security import get_password_hash, verify_password
|
|
||||||
from src.domain.models import Roles, SSP, UserSSPLink, Users
|
|
||||||
|
|
||||||
|
|
||||||
class UserRepository:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
async def get(self, user_id: int) -> Optional[Users]:
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await self.db.execute(
|
|
||||||
select(Users).where(Users.id == user_id).limit(1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_by_email(self, email: str) -> Optional[Users]:
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await self.db.execute(
|
|
||||||
select(Users).where(Users.email == email).limit(1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_by_username(self, username: str) -> Optional[Users]:
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await self.db.execute(
|
|
||||||
select(Users).where(Users.username == username).limit(1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]:
|
|
||||||
query = (
|
|
||||||
select(Users)
|
|
||||||
.where(Users.is_active.is_(True))
|
|
||||||
.offset(skip)
|
|
||||||
.limit(limit)
|
|
||||||
.order_by(Users.id)
|
|
||||||
)
|
|
||||||
return (await self.db.execute(query)).scalars().all()
|
|
||||||
|
|
||||||
async def create(self, user_data: dict) -> Users:
|
|
||||||
hashed_password = (
|
|
||||||
get_password_hash(user_data["password"]) if user_data.get("password") else None
|
|
||||||
)
|
|
||||||
db_user = Users(
|
|
||||||
email=user_data["email"],
|
|
||||||
username=user_data["username"],
|
|
||||||
hashed_password=hashed_password,
|
|
||||||
full_name=user_data.get("full_name"),
|
|
||||||
role_id=user_data.get("role_id"),
|
|
||||||
)
|
|
||||||
self.db.add(db_user)
|
|
||||||
await self.db.commit()
|
|
||||||
await self.db.refresh(db_user)
|
|
||||||
return db_user
|
|
||||||
|
|
||||||
async def update(self, user_id: int, user_data: dict) -> Optional[Users]:
|
|
||||||
user = await self.get(user_id)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for field, value in user_data.items():
|
|
||||||
if field == "password" and value:
|
|
||||||
value = get_password_hash(value)
|
|
||||||
setattr(user, field, value)
|
|
||||||
|
|
||||||
await self.db.commit()
|
|
||||||
await self.db.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
async def logical_delete(self, user_id: int) -> bool:
|
|
||||||
query = update(Users).where(Users.id == user_id).values(is_active=False)
|
|
||||||
result = await self.db.execute(query)
|
|
||||||
await self.db.commit()
|
|
||||||
return result.rowcount > 0
|
|
||||||
|
|
||||||
async def authenticate(self, username: str, password: str) -> Optional[Users]:
|
|
||||||
user = await self.get_by_username(username)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
if not verify_password(password, user.hashed_password):
|
|
||||||
return None
|
|
||||||
return user
|
|
||||||
|
|
||||||
async def authenticate_via_email(self, email: str) -> Optional[Users]:
|
|
||||||
return await self.get_by_email(email)
|
|
||||||
|
|
||||||
async def get_roles(self) -> list[Roles]:
|
|
||||||
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all()
|
|
||||||
|
|
||||||
async def get_many_ssp(self, user_id: int) -> list[SSP]:
|
|
||||||
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where(
|
|
||||||
UserSSPLink.user_id == user_id
|
|
||||||
)
|
|
||||||
return (await self.db.execute(query)).scalars().all()
|
|
||||||
|
|
||||||
async def get_many_ssp_ids(self, user_id: int) -> list[int]:
|
|
||||||
query = select(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id)
|
|
||||||
return (await self.db.execute(query)).scalars().all()
|
|
||||||
|
|
||||||
async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
|
|
||||||
try:
|
|
||||||
for ssp_id in ssp_ids:
|
|
||||||
link = UserSSPLink(user_id=user_id, ssp_id=ssp_id)
|
|
||||||
self.db.add(link)
|
|
||||||
await self.db.commit()
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
await self.db.rollback()
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
|
|
||||||
try:
|
|
||||||
for ssp_id in ssp_ids:
|
|
||||||
query = delete(UserSSPLink).where(
|
|
||||||
(UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id)
|
|
||||||
)
|
|
||||||
await self.db.execute(query)
|
|
||||||
await self.db.commit()
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
await self.db.rollback()
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def clear_many_ssp(self, user_id: int) -> bool:
|
|
||||||
try:
|
|
||||||
query = delete(UserSSPLink).where(UserSSPLink.user_id == user_id)
|
|
||||||
await self.db.execute(query)
|
|
||||||
await self.db.commit()
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
await self.db.rollback()
|
|
||||||
return False
|
|
||||||
5
api/src/routes/__init__.py
Normal file
5
api/src/routes/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from .api import api_route
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"api_route"
|
||||||
|
]
|
||||||
5
api/src/routes/api/__init__.py
Normal file
5
api/src/routes/api/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from .route import route as api_route
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"api_route"
|
||||||
|
]
|
||||||
73
api/src/routes/api/form.py
Normal file
73
api/src/routes/api/form.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.routes.api.schemas.base import BaseApiResponse
|
||||||
|
from src.routes.api.schemas.budget_line import CreateBudgetLineLSchema, UpdateLinkedBLStructureSchema
|
||||||
|
from src.routes.api.schemas import ListApiResponse
|
||||||
|
from src.services import page as page_service, budget_line as bl_service
|
||||||
|
from src.db.session import get_db
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/forms", tags=["forms"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{form_id}/{page}")
|
||||||
|
async def get_form_page(
|
||||||
|
form_id: int,
|
||||||
|
page: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
offset: int | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> ListApiResponse[list]:
|
||||||
|
count, result = await page_service.get_page(
|
||||||
|
db=db,
|
||||||
|
form_id=form_id,
|
||||||
|
page=page,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return ListApiResponse(
|
||||||
|
data=result,
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{form_id}/{page}/{line_id}")
|
||||||
|
async def update_form(
|
||||||
|
form_id: int,
|
||||||
|
page: str,
|
||||||
|
line_id: int,
|
||||||
|
field_info: UpdateLinkedBLStructureSchema,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await bl_service.update_budget_line(
|
||||||
|
db=db,
|
||||||
|
model_name=field_info.model_name,
|
||||||
|
form_id=form_id,
|
||||||
|
line_id=line_id,
|
||||||
|
field_name=field_info.field_name,
|
||||||
|
value=field_info.value,
|
||||||
|
quarter=field_info.quarter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{form_id}/{page}/")
|
||||||
|
async def insert_line(
|
||||||
|
form_id: int,
|
||||||
|
page: str,
|
||||||
|
budget_line: CreateBudgetLineLSchema,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> BaseApiResponse[tuple]:
|
||||||
|
return BaseApiResponse(
|
||||||
|
data=await bl_service.create_budget_line(
|
||||||
|
db=db,
|
||||||
|
form_id=form_id,
|
||||||
|
page=page,
|
||||||
|
expense_item_id=budget_line.expense_item_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
21
api/src/routes/api/route.py
Normal file
21
api/src/routes/api/route.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from src.routes.api import form
|
||||||
|
|
||||||
|
from .schemas import ApiResponse
|
||||||
|
|
||||||
|
route = APIRouter(prefix="/api",tags=['Base'])
|
||||||
|
|
||||||
|
|
||||||
|
@route.get(
|
||||||
|
"",
|
||||||
|
response_model=ApiResponse
|
||||||
|
)
|
||||||
|
async def get_data():
|
||||||
|
return ApiResponse(
|
||||||
|
date_with_time=datetime.now(timezone.utc),
|
||||||
|
text="Fast API приложение"
|
||||||
|
)
|
||||||
|
|
||||||
|
route.include_router(form.router)
|
||||||
1
api/src/routes/api/schemas/__init__.py
Normal file
1
api/src/routes/api/schemas/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from src.routes.api.schemas.base import *
|
||||||
22
api/src/routes/api/schemas/base.py
Normal file
22
api/src/routes/api/schemas/base.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiResponse(BaseModel):
|
||||||
|
date_with_time: datetime
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class ListApiResponse(BaseModel, Generic[T]):
|
||||||
|
data: list[T]
|
||||||
|
count: int
|
||||||
|
errors: list = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseApiResponse(BaseModel, Generic[T]):
|
||||||
|
data: T
|
||||||
|
errors: list = Field(default_factory=list)
|
||||||
40
api/src/routes/api/schemas/budget_line.py
Normal file
40
api/src/routes/api/schemas/budget_line.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LinkedBLStructureEnum(str, enum.Enum):
|
||||||
|
EXPENSE_ITEM = "expense_item"
|
||||||
|
PLAN = "plan"
|
||||||
|
CONTRACT_SUMMARY = "contract_summary"
|
||||||
|
ALLOCATION = "allocation"
|
||||||
|
SEQUESTRATION = "sequestration"
|
||||||
|
RESERVE = "reserve"
|
||||||
|
COLLEGIAL_APPROVAL = "collegial_approval"
|
||||||
|
CKK = "ckk"
|
||||||
|
CONTRACT_DETAIL = "contract_detail"
|
||||||
|
BUDGET_LINE_QUARTER = "budget_line_quarter"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateLinkedBLStructureSchema(BaseModel):
|
||||||
|
model_name: LinkedBLStructureEnum
|
||||||
|
field_name: str
|
||||||
|
value: typing.Any
|
||||||
|
quarter: typing.Optional[int] = Field(default=None, ge=1, le=4)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateBudgetLineLSchema(BaseModel):
|
||||||
|
expense_item_id: int
|
||||||
|
|
||||||
|
|
||||||
|
# class BudgetLineReadSchema(BaseModel):
|
||||||
|
# id: int
|
||||||
|
# expense_item_id: int = mapped_column(ForeignKey("expense_item.id"))
|
||||||
|
# budget_form_id: Mapped[int] = mapped_column(ForeignKey("budget_form.id"))
|
||||||
|
# internal_order: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
# name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
@ -1 +0,0 @@
|
|||||||
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.core.security import create_access_token, create_refresh_token, verify_token
|
|
||||||
from src.domain.schemas import Token
|
|
||||||
from src.repository.user_repository import UserRepository
|
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
self.user_repo = UserRepository(db)
|
|
||||||
|
|
||||||
async def authenticate_user(self, username: str, password: str) -> Optional[Token]:
|
|
||||||
user = await self.user_repo.authenticate(username, password)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
access_token = create_access_token(data={"sub": user.username})
|
|
||||||
refresh_token = create_refresh_token(data={"sub": user.username})
|
|
||||||
return Token(
|
|
||||||
access_token=access_token,
|
|
||||||
refresh_token=refresh_token,
|
|
||||||
token_type="bearer",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def authenticate_user_via_email(self, email: str) -> Optional[Token]:
|
|
||||||
user = await self.user_repo.authenticate_via_email(email)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
access_token = create_access_token(data={"sub": user.email})
|
|
||||||
refresh_token = create_refresh_token(data={"sub": user.email})
|
|
||||||
return Token(
|
|
||||||
access_token=access_token,
|
|
||||||
refresh_token=refresh_token,
|
|
||||||
token_type="bearer",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def refresh_token(self, refresh_token: str) -> Optional[Token]:
|
|
||||||
payload = verify_token(refresh_token)
|
|
||||||
if payload is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
username: str | None = payload.get("sub")
|
|
||||||
if username is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
user = await self.user_repo.get_by_username(username)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
|
|
||||||
access_token = create_access_token(data={"sub": user.username})
|
|
||||||
new_refresh_token = create_refresh_token(data={"sub": user.username})
|
|
||||||
return Token(
|
|
||||||
access_token=access_token,
|
|
||||||
refresh_token=new_refresh_token,
|
|
||||||
token_type="bearer",
|
|
||||||
)
|
|
||||||
187
api/src/services/budget_line.py
Normal file
187
api/src/services/budget_line.py
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import typing
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db.models.budget_line import BudgetLine
|
||||||
|
from src.exceptions import NotFound, PermissionDenied, ValidationError
|
||||||
|
from src.routes.api.schemas.budget_line import LinkedBLStructureEnum
|
||||||
|
from src.data import budget_line as bl_repo, page as page_repo
|
||||||
|
|
||||||
|
|
||||||
|
MODEL_FIELDS_TO_DB_MAPPING = {
|
||||||
|
|
||||||
|
LinkedBLStructureEnum.EXPENSE_ITEM: {
|
||||||
|
"name": "name",
|
||||||
|
"item_id": "item_id",
|
||||||
|
"section": "section_code",
|
||||||
|
"num_group": "num_group_id",
|
||||||
|
},
|
||||||
|
LinkedBLStructureEnum.PLAN: {
|
||||||
|
"q1": "plan_q1",
|
||||||
|
"q2": "plan_q2",
|
||||||
|
"q3": "plan_q3",
|
||||||
|
"q4": "plan_q4",
|
||||||
|
"comment": "comment"
|
||||||
|
},
|
||||||
|
LinkedBLStructureEnum.CONTRACT_SUMMARY: {
|
||||||
|
"total": "total_amount",
|
||||||
|
"comment": "comment",
|
||||||
|
"deadline": "deadline",
|
||||||
|
"future_y1": "future_payments_y1",
|
||||||
|
"future_y2": "future_payments_y2",
|
||||||
|
"other_ssp": "other_ssp_amount",
|
||||||
|
"counterparty": "counterparty"
|
||||||
|
},
|
||||||
|
LinkedBLStructureEnum.ALLOCATION: {
|
||||||
|
"order": "internal_order",
|
||||||
|
"property": "property_object",
|
||||||
|
},
|
||||||
|
LinkedBLStructureEnum.SEQUESTRATION: {
|
||||||
|
"q1": "adj_q1",
|
||||||
|
"q2": "adj_q2",
|
||||||
|
"q3": "adj_q3",
|
||||||
|
"q4": "adj_q4",
|
||||||
|
"justification": "justification"
|
||||||
|
},
|
||||||
|
LinkedBLStructureEnum.RESERVE: {
|
||||||
|
"q1": "amount_q1",
|
||||||
|
"q2": "amount_q2",
|
||||||
|
"q3": "amount_q3",
|
||||||
|
"q4": "amount_q4",
|
||||||
|
"justification": "justification"
|
||||||
|
},
|
||||||
|
LinkedBLStructureEnum.COLLEGIAL_APPROVAL: {
|
||||||
|
"note": "note",
|
||||||
|
"approved": "approved_amount",
|
||||||
|
"protocol": "protocol_reference",
|
||||||
|
},
|
||||||
|
LinkedBLStructureEnum.CKK: {
|
||||||
|
"q1": "expenses_q1",
|
||||||
|
"q2": "expenses_q2",
|
||||||
|
"q3": "expenses_q3",
|
||||||
|
"q4": "expenses_q4",
|
||||||
|
"ceiling": "ceiling_amount",
|
||||||
|
"comment": "comment",
|
||||||
|
"deadline": "delivery_deadline",
|
||||||
|
"proc_plan": "procurement_plan",
|
||||||
|
"proc_method": "procurement_method",
|
||||||
|
"rf_schedule": "rf_schedule",
|
||||||
|
},
|
||||||
|
LinkedBLStructureEnum.CONTRACT_DETAIL: {
|
||||||
|
"q1": "expenses_q1",
|
||||||
|
"q2": "expenses_q2",
|
||||||
|
"q3": "expenses_q3",
|
||||||
|
"q4": "expenses_q4",
|
||||||
|
"act": "act",
|
||||||
|
"addenda": "addenda",
|
||||||
|
"ceiling": "ceiling_amount",
|
||||||
|
"comment": "comment",
|
||||||
|
"subject": "subject",
|
||||||
|
"currency": "currency",
|
||||||
|
"deadline": "deadline",
|
||||||
|
"vat_rate": "vat_rate",
|
||||||
|
"reference": "reference",
|
||||||
|
"rf_schedule": "rf_schedule",
|
||||||
|
"counterparty": "counterparty",
|
||||||
|
"exchange_rate": "exchange_rate",
|
||||||
|
"amount_foreign": "amount_foreign",
|
||||||
|
"payment_scheme": "payment_scheme",
|
||||||
|
},
|
||||||
|
LinkedBLStructureEnum.BUDGET_LINE_QUARTER: {
|
||||||
|
"adj_rf": "adj_rf",
|
||||||
|
"payment_ho": "payment_amount_ho",
|
||||||
|
"pay_rf": "payment_amount_rf",
|
||||||
|
"adj_ssp": "adj_ssp",
|
||||||
|
"pay_act": "payment_act",
|
||||||
|
"pay_date": "payment_date",
|
||||||
|
"actual_m1": "actual_m1",
|
||||||
|
"actual_m2": "actual_m2",
|
||||||
|
"actual_m3": "actual_m3",
|
||||||
|
"pay_amount": "payment_amount",
|
||||||
|
"adj_comment": "adj_comment",
|
||||||
|
"adj_current": "adj_current",
|
||||||
|
"adj_reserve": "adj_reserve",
|
||||||
|
"pay_comment": "payment_comment",
|
||||||
|
"transfer_q2": "transfer_to_q2",
|
||||||
|
"transfer_q3": "transfer_to_q3",
|
||||||
|
"transfer_q4": "transfer_to_q4",
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_budget_line(
|
||||||
|
db: AsyncSession,
|
||||||
|
form_id: int,
|
||||||
|
line_id: int,
|
||||||
|
model_name: LinkedBLStructureEnum,
|
||||||
|
field_name: str,
|
||||||
|
value: typing.Any,
|
||||||
|
quarter: int | None = None,
|
||||||
|
) -> typing.Any:
|
||||||
|
quarter_field = None
|
||||||
|
|
||||||
|
line = await bl_repo.get_budget_line(
|
||||||
|
db=db,
|
||||||
|
id=line_id,
|
||||||
|
)
|
||||||
|
if not line or line.budget_form_id != form_id:
|
||||||
|
raise NotFound
|
||||||
|
|
||||||
|
# нужно рефрешить матвью и обновлять name у budget_line
|
||||||
|
# case LinkedBLStructureEnum.EXPENSE_ITEM:
|
||||||
|
# expense_item = await expense_item_repo.get_expense_item_by_budget_line(
|
||||||
|
# db=db,
|
||||||
|
# budget_line_id=line_id,
|
||||||
|
# )
|
||||||
|
# if expense_item.depth != 3:
|
||||||
|
# raise PermissionDenied(message="У вас нет прав на редактирование этой строки")
|
||||||
|
# setattr(
|
||||||
|
# expense_item,
|
||||||
|
# MODEL_FIELDS_TO_DB_MAPPING[model_name][field_name],
|
||||||
|
# value,
|
||||||
|
# )
|
||||||
|
# await db.flush()
|
||||||
|
if model_name == LinkedBLStructureEnum.BUDGET_LINE_QUARTER:
|
||||||
|
quarter_field = "quarter"
|
||||||
|
|
||||||
|
db_field = MODEL_FIELDS_TO_DB_MAPPING[model_name].get(field_name)
|
||||||
|
return await bl_repo.update_budget_line(
|
||||||
|
db=db,
|
||||||
|
table_name=model_name.value,
|
||||||
|
line_id=line_id,
|
||||||
|
field_name=db_field,
|
||||||
|
value=value,
|
||||||
|
quarter_field=quarter_field,
|
||||||
|
quarter=quarter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_budget_line(
|
||||||
|
db: AsyncSession,
|
||||||
|
form_id: int,
|
||||||
|
page: str,
|
||||||
|
# line_after_id: int,
|
||||||
|
expense_item_id: int,
|
||||||
|
) -> tuple:
|
||||||
|
|
||||||
|
line = await bl_repo.get_budget_line(
|
||||||
|
db=db,
|
||||||
|
form_id=form_id,
|
||||||
|
expense_item_id=expense_item_id,
|
||||||
|
)
|
||||||
|
if not line:
|
||||||
|
raise NotFound
|
||||||
|
budget_line = await bl_repo.create_budget_line(
|
||||||
|
db=db,
|
||||||
|
form_id=form_id,
|
||||||
|
expense_item_id=expense_item_id
|
||||||
|
)
|
||||||
|
result = await page_repo.get_page_row(
|
||||||
|
db=db,
|
||||||
|
form_id=form_id,
|
||||||
|
page=page,
|
||||||
|
budget_line_id=budget_line.id,
|
||||||
|
)
|
||||||
|
return tuple(result) if result else None
|
||||||
53
api/src/services/page.py
Normal file
53
api/src/services/page.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.data.page import get_page_with_counts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def get_page(
|
||||||
|
db: AsyncSession,
|
||||||
|
form_id: int,
|
||||||
|
page: str,
|
||||||
|
offset: int | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> tuple[int, list[tuple]]:
|
||||||
|
pre_result = await get_page_with_counts(
|
||||||
|
db=db,
|
||||||
|
form_id=form_id,
|
||||||
|
page=page,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not pre_result:
|
||||||
|
return 0, pre_result
|
||||||
|
count = pre_result[0][0]
|
||||||
|
return count, [tuple(x[1:]) for x in pre_result]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def update_line(
|
||||||
|
db: AsyncSession,
|
||||||
|
line_id: int,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
# query_text = "SELECT COUNT(*) OVER(), * FROM v_form1_sheet_jsonb(:form_id, :page)"
|
||||||
|
# if offset is not None:
|
||||||
|
# query_text += " OFFSET :offset"
|
||||||
|
# if limit is not None:
|
||||||
|
# query_text += " LIMIT :limit"
|
||||||
|
|
||||||
|
# query = text(query_text)
|
||||||
|
|
||||||
|
# result = (
|
||||||
|
# await db.execute(
|
||||||
|
# query,
|
||||||
|
# {"form_id": form_id, "page": page, "limit": limit, "offset": offset}
|
||||||
|
# )
|
||||||
|
# ).fetchall()
|
||||||
|
# if not result:
|
||||||
|
# return 0, result
|
||||||
|
# count = result[0][0]
|
||||||
|
# return count, [tuple(x[1:]) for x in result]
|
||||||
@ -1,167 +0,0 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.core.errors import (
|
|
||||||
AccessDeniedException,
|
|
||||||
UserNotFoundException,
|
|
||||||
ValidationException,
|
|
||||||
UsernameConflictException,
|
|
||||||
)
|
|
||||||
from src.domain.models import UserRole, Users
|
|
||||||
from src.domain.schemas import UserCreate, UserUpdate
|
|
||||||
from src.repository.user_repository import UserRepository
|
|
||||||
|
|
||||||
|
|
||||||
class UserService:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
self.user_repo = UserRepository(db)
|
|
||||||
|
|
||||||
async def get_all(self, current_user: Users, skip: int = 0, limit: int = 100):
|
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
|
||||||
raise AccessDeniedException()
|
|
||||||
return await self.user_repo.get_list(skip=skip, limit=limit)
|
|
||||||
|
|
||||||
async def get(self, user_id: int, current_user: Users):
|
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
|
||||||
raise AccessDeniedException()
|
|
||||||
return await self.user_repo.get(user_id)
|
|
||||||
|
|
||||||
async def create_user(self, user_data: UserCreate, creator: Users):
|
|
||||||
if creator.role_id != UserRole.ADMIN:
|
|
||||||
raise AccessDeniedException()
|
|
||||||
|
|
||||||
role_id = user_data.role_id or UserRole.EXECUTOR_RF.value
|
|
||||||
try:
|
|
||||||
role = UserRole(role_id)
|
|
||||||
except ValueError:
|
|
||||||
raise ValidationException("Роль не найдена", field="role_id")
|
|
||||||
user_data.role_id = role.value
|
|
||||||
|
|
||||||
if await self.user_repo.get_by_username(user_data.username):
|
|
||||||
raise UsernameConflictException()
|
|
||||||
|
|
||||||
payload = user_data.model_dump(mode="json")
|
|
||||||
return await self.user_repo.create(payload)
|
|
||||||
|
|
||||||
async def update_user(self, user_id: int, user_data: UserUpdate, user: Users):
|
|
||||||
if user.role_id != UserRole.ADMIN:
|
|
||||||
raise AccessDeniedException()
|
|
||||||
|
|
||||||
target = await self.user_repo.get(user_id)
|
|
||||||
if not target:
|
|
||||||
raise UserNotFoundException()
|
|
||||||
|
|
||||||
update_payload = user_data.model_dump(mode="json", exclude_unset=True)
|
|
||||||
previous_role_id = self._normalize_role_id(target.role_id)
|
|
||||||
role_id = update_payload.get("role_id")
|
|
||||||
role = None
|
|
||||||
if role_id is not None:
|
|
||||||
try:
|
|
||||||
role = UserRole(role_id)
|
|
||||||
except ValueError:
|
|
||||||
raise ValidationException("Роль не найдена", field="role_id")
|
|
||||||
|
|
||||||
if role_id != self._normalize_role_id(user.role_id) and user.id == user_id:
|
|
||||||
raise ValidationException(
|
|
||||||
"Нельзя изменить роль самому себе", field="role_id"
|
|
||||||
)
|
|
||||||
update_payload["role_id"] = role.value
|
|
||||||
|
|
||||||
if "username" in update_payload and update_payload["username"] != target.username:
|
|
||||||
conflict = await self.user_repo.get_by_username(update_payload["username"])
|
|
||||||
if conflict:
|
|
||||||
raise UsernameConflictException()
|
|
||||||
|
|
||||||
updated = await self.user_repo.update(user_id, update_payload)
|
|
||||||
|
|
||||||
if role_id is not None and role is not None:
|
|
||||||
had_many_ssp_role = previous_role_id in (
|
|
||||||
UserRole.ADMIN.value,
|
|
||||||
UserRole.EXECUTOR_DFIP.value,
|
|
||||||
)
|
|
||||||
has_many_ssp_role_now = role.value in (
|
|
||||||
UserRole.ADMIN.value,
|
|
||||||
UserRole.EXECUTOR_DFIP.value,
|
|
||||||
)
|
|
||||||
is_executor_to_dfip_transition = (
|
|
||||||
previous_role_id == UserRole.EXECUTOR_RF.value
|
|
||||||
and role.value == UserRole.EXECUTOR_DFIP.value
|
|
||||||
)
|
|
||||||
if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition:
|
|
||||||
await self.user_repo.clear_many_ssp(user_id)
|
|
||||||
|
|
||||||
return updated
|
|
||||||
|
|
||||||
async def delete_user(self, user_id: int, user: Users):
|
|
||||||
if user.role_id != UserRole.ADMIN:
|
|
||||||
raise AccessDeniedException()
|
|
||||||
deleted = await self.user_repo.logical_delete(user_id)
|
|
||||||
if not deleted:
|
|
||||||
raise UserNotFoundException()
|
|
||||||
|
|
||||||
async def get_roles(self, current_user: Users):
|
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
|
||||||
raise AccessDeniedException()
|
|
||||||
return await self.user_repo.get_roles()
|
|
||||||
|
|
||||||
async def get_many_ssp(self, user_id: int, current_user: Users):
|
|
||||||
if current_user.role_id == UserRole.ADMIN:
|
|
||||||
return await self.user_repo.get_many_ssp(user_id)
|
|
||||||
if current_user.id != user_id:
|
|
||||||
raise AccessDeniedException()
|
|
||||||
return await self.user_repo.get_many_ssp(user_id)
|
|
||||||
|
|
||||||
async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users):
|
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
|
||||||
raise AccessDeniedException()
|
|
||||||
|
|
||||||
user = await self.user_repo.get(user_id)
|
|
||||||
if not user:
|
|
||||||
raise UserNotFoundException()
|
|
||||||
|
|
||||||
target_role_id = self._normalize_role_id(user.role_id)
|
|
||||||
if target_role_id not in (
|
|
||||||
UserRole.ADMIN.value,
|
|
||||||
UserRole.EXECUTOR_DFIP.value,
|
|
||||||
UserRole.EXECUTOR_RF.value,
|
|
||||||
):
|
|
||||||
err = AccessDeniedException()
|
|
||||||
err.description = (
|
|
||||||
"Несколько ССП/РФ может быть только у пользовтелей с ролями EXECUTOR и ADMIN"
|
|
||||||
)
|
|
||||||
raise err
|
|
||||||
|
|
||||||
curr_ssp_ids = await self.user_repo.get_many_ssp_ids(user_id)
|
|
||||||
if target_role_id == UserRole.EXECUTOR_RF.value:
|
|
||||||
merged_ssp_ids = set(curr_ssp_ids) | set(ssp_ids)
|
|
||||||
if len(merged_ssp_ids) > 1:
|
|
||||||
raise ValidationException(
|
|
||||||
"Для пользователя с ролью EXECUTOR_RF можно назначить только один ССП/РФ"
|
|
||||||
)
|
|
||||||
new_ssp_ids = [ssp_id for ssp_id in ssp_ids if ssp_id not in curr_ssp_ids]
|
|
||||||
return await self.user_repo.set_many_ssp(user.id, new_ssp_ids)
|
|
||||||
|
|
||||||
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users):
|
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
|
||||||
raise AccessDeniedException()
|
|
||||||
|
|
||||||
user = await self.user_repo.get(user_id)
|
|
||||||
if not user:
|
|
||||||
raise UserNotFoundException()
|
|
||||||
|
|
||||||
target_role_id = self._normalize_role_id(user.role_id)
|
|
||||||
if target_role_id not in (
|
|
||||||
UserRole.ADMIN.value,
|
|
||||||
UserRole.EXECUTOR_DFIP.value,
|
|
||||||
UserRole.EXECUTOR_RF.value,
|
|
||||||
):
|
|
||||||
err = AccessDeniedException()
|
|
||||||
err.description = (
|
|
||||||
"Несколько ССП/РФ может быть только у пользовтелей с ролями EXECUTOR и ADMIN"
|
|
||||||
)
|
|
||||||
raise err
|
|
||||||
return await self.user_repo.unset_many_ssp(user_id, ssp_ids)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_role_id(role_id: int | UserRole) -> int:
|
|
||||||
return role_id.value if isinstance(role_id, UserRole) else int(role_id)
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from alembic import command
|
|
||||||
from alembic.config import Config
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
# Keep a single sqlite target for app + alembic during tests.
|
|
||||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///./app.db"
|
|
||||||
os.environ["DATABASE_URL"] = TEST_DATABASE_URL
|
|
||||||
os.environ["OPENBAO__SETTINGS__DATABASE_URL"] = TEST_DATABASE_URL
|
|
||||||
os.environ["OPENBAO__SETTINGS_TEST__DATABASE_URL"] = TEST_DATABASE_URL
|
|
||||||
os.environ["OPENBAO__SETTINGS__DEBUG"] = "true"
|
|
||||||
os.environ["OPENBAO__SETTINGS_TEST__DEBUG"] = "true"
|
|
||||||
|
|
||||||
from src.main import app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def migrate_db():
|
|
||||||
db_files = [Path("app.db"), Path("dfip_budget_planing.db"), Path("dfip_new_schema.db")]
|
|
||||||
for db_file in db_files:
|
|
||||||
if db_file.exists():
|
|
||||||
os.remove(db_file)
|
|
||||||
|
|
||||||
alembic_cfg = Config("alembic.ini")
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
yield
|
|
||||||
|
|
||||||
for db_file in db_files:
|
|
||||||
if db_file.exists():
|
|
||||||
os.remove(db_file)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
with TestClient(app) as test_client:
|
|
||||||
yield test_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def admin_tokens(client):
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1/auth/login",
|
|
||||||
json={"username": "admin", "password": "admin"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
return response.json()
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
def test_login_success(client):
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1/auth/login",
|
|
||||||
json={"username": "admin", "password": "admin"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "access_token" in data
|
|
||||||
assert "refresh_token" in data
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_invalid_password(client):
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1/auth/login",
|
|
||||||
json={"username": "admin", "password": "wrong"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_success(client, admin_tokens):
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1/auth/refresh",
|
|
||||||
json={"refresh_token": admin_tokens["refresh_token"]},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "access_token" in data
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
def _auth_headers(tokens: dict) -> dict:
|
|
||||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_users_me(client, admin_tokens):
|
|
||||||
response = client.get("/api/v1/users/me", headers=_auth_headers(admin_tokens))
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["username"] == "admin"
|
|
||||||
|
|
||||||
|
|
||||||
def test_users_create_and_list(client, admin_tokens):
|
|
||||||
create_payload = {
|
|
||||||
"email": "user1@example.com",
|
|
||||||
"username": "user1",
|
|
||||||
"password": "pass123",
|
|
||||||
"full_name": "User One",
|
|
||||||
"role_id": 2,
|
|
||||||
}
|
|
||||||
created = client.put(
|
|
||||||
"/api/v1/users/",
|
|
||||||
json=create_payload,
|
|
||||||
headers=_auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert created.status_code == 200
|
|
||||||
|
|
||||||
listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens))
|
|
||||||
assert listed.status_code == 200
|
|
||||||
usernames = [row["username"] for row in listed.json()["result"]]
|
|
||||||
assert "admin" in usernames
|
|
||||||
assert "user1" in usernames
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.services.auth_service import AuthService
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_authenticate_user_returns_token(monkeypatch):
|
|
||||||
db = AsyncMock()
|
|
||||||
service = AuthService(db)
|
|
||||||
service.user_repo = MagicMock()
|
|
||||||
service.user_repo.authenticate = AsyncMock(
|
|
||||||
return_value=MagicMock(username="admin", email="admin@example.com")
|
|
||||||
)
|
|
||||||
|
|
||||||
token = await service.authenticate_user("admin", "admin")
|
|
||||||
|
|
||||||
assert token is not None
|
|
||||||
assert token.access_token
|
|
||||||
assert token.refresh_token
|
|
||||||
assert token.token_type == "bearer"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_refresh_token_returns_none_for_invalid():
|
|
||||||
db = AsyncMock()
|
|
||||||
service = AuthService(db)
|
|
||||||
|
|
||||||
token = await service.refresh_token("bad-token")
|
|
||||||
|
|
||||||
assert token is None
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.core.errors import ValidationException
|
|
||||||
from src.domain.schemas import UserUpdate
|
|
||||||
from src.services.user_service import UserService
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_user_clears_ssp_links_on_dfip_to_rf_role_change():
|
|
||||||
db = AsyncMock()
|
|
||||||
service = UserService(db)
|
|
||||||
service.user_repo = MagicMock()
|
|
||||||
service.user_repo.get = AsyncMock(
|
|
||||||
return_value=SimpleNamespace(
|
|
||||||
id=10,
|
|
||||||
username="target",
|
|
||||||
role_id=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
service.user_repo.get_by_username = AsyncMock(return_value=None)
|
|
||||||
service.user_repo.update = AsyncMock(
|
|
||||||
return_value=SimpleNamespace(id=10, role_id=3, username="target")
|
|
||||||
)
|
|
||||||
service.user_repo.clear_many_ssp = AsyncMock(return_value=True)
|
|
||||||
|
|
||||||
admin = SimpleNamespace(id=1, role_id=1)
|
|
||||||
await service.update_user(10, UserUpdate(role_id=3), admin)
|
|
||||||
|
|
||||||
service.user_repo.clear_many_ssp.assert_awaited_once_with(10)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_user_prevents_self_role_change():
|
|
||||||
db = AsyncMock()
|
|
||||||
service = UserService(db)
|
|
||||||
service.user_repo = MagicMock()
|
|
||||||
service.user_repo.get = AsyncMock(
|
|
||||||
return_value=SimpleNamespace(
|
|
||||||
id=1,
|
|
||||||
username="admin",
|
|
||||||
role_id=1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
service.user_repo.get_by_username = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
admin = SimpleNamespace(id=1, role_id=1)
|
|
||||||
with pytest.raises(ValidationException):
|
|
||||||
await service.update_user(1, UserUpdate(role_id=2), admin)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_set_many_ssp_blocks_multiple_links_for_rf():
|
|
||||||
db = AsyncMock()
|
|
||||||
service = UserService(db)
|
|
||||||
service.user_repo = MagicMock()
|
|
||||||
service.user_repo.get = AsyncMock(return_value=SimpleNamespace(id=7, role_id=3))
|
|
||||||
service.user_repo.get_many_ssp_ids = AsyncMock(return_value=[1])
|
|
||||||
|
|
||||||
admin = SimpleNamespace(id=1, role_id=1)
|
|
||||||
with pytest.raises(ValidationException):
|
|
||||||
await service.set_many_ssp(7, [2], admin)
|
|
||||||
Loading…
x
Reference in New Issue
Block a user