Compare commits

..

5 Commits

Author SHA1 Message Date
e267d2d631 Merge pull request 'Добавил базу из фомичева' (#1) from add_base_code into main
Reviewed-on: #1
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-07 11:08:01 +03:00
Raykov-MS
4c9649a8fb add limit(1) for performance 2026-05-05 18:16:37 +03:00
Raykov-MS
9e87b75a24 . 2026-05-05 18:11:13 +03:00
Raykov-MS
1a92df79ac Add based code 2026-05-05 18:07:16 +03:00
6e2627ea6e Update ReadMe.md for template docs 2026-04-23 12:48:48 +03:00
57 changed files with 1659 additions and 804 deletions

164
README.md
View File

@ -1 +1,163 @@
# Дока
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](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

9
api/pytest.ini Normal file
View File

@ -0,0 +1,9 @@
[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

16
api/requirements-dev.txt Normal file
View File

@ -0,0 +1,16 @@
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.

View File

@ -1,11 +1,11 @@
import uvicorn
from src import app, get_settings
from src.main import app, settings
if __name__ == "__main__":
settings = get_settings()
uvicorn.run(
app,
host=settings.server.HOST,
port=settings.server.PORT,
lifespan="on"
host=settings.HOST,
port=settings.PORT,
lifespan="on",
)

View File

@ -1,7 +1 @@
from .app import app
from .conf import get_settings
__all__ = [
"app",
"get_settings"
]

1
api/src/api/__init__.py Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

84
api/src/api/v1/auth.py Normal file
View File

@ -0,0 +1,84 @@
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

99
api/src/api/v1/deps.py Normal file
View File

@ -0,0 +1,99 @@
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

7
api/src/api/v1/router.py Normal file
View File

@ -0,0 +1,7 @@
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)

139
api/src/api/v1/users.py Normal file
View File

@ -0,0 +1,139 @@
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="Пользователь удалён")

View File

@ -1,68 +0,0 @@
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")

View File

@ -1,5 +0,0 @@
from .settings import get_settings
__all__ = [
"get_settings"
]

View File

@ -1,9 +0,0 @@
from pydantic import BaseModel
class ServerSettings(BaseModel):
"""
Настройки сервера uvicorn
"""
HOST: str = '0.0.0.0'
PORT: int = 8000

View File

@ -1,34 +0,0 @@
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
api/src/core/__init__.py Normal file
View File

@ -0,0 +1 @@

116
api/src/core/config.py Normal file
View File

@ -0,0 +1,116 @@
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()

31
api/src/core/errors.py Normal file
View File

@ -0,0 +1,31 @@
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 уже существует"

View File

@ -0,0 +1,47 @@
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"},
)

55
api/src/core/security.py Normal file
View File

@ -0,0 +1,55 @@
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

View File

@ -1,66 +0,0 @@
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

View File

@ -1,17 +0,0 @@
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()

View File

@ -1,45 +0,0 @@
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()

View File

@ -0,0 +1 @@

View File

@ -1,42 +1,27 @@
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 src.conf.settings import get_settings
from src.core.config import settings
settings = get_settings()
# Создание движка базы данных
connect_args = {}
# if settings.DATABASE_SCHEMA:
# connect_args["server_settings"] = {
# "search_path": settings.DATABASE_SCHEMA,
# }
if settings.DATABASE_SCHEMA:
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
if "sqlite" in settings.DATABASE_URL:
connect_args["check_same_thread"] = False
engine = create_async_engine( # create_engine(
engine = create_async_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
# echo=settings.DEBUG,
connect_args=connect_args,
# echo=True,
)
# Создание фабрики сессий
# SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
SessionLocal = async_sessionmaker(
# autocommit=False,
autoflush=False,
bind=engine,
expire_on_commit=False,
# class_=AsyncSession
)
# Базовый класс для моделей
class Base(AsyncAttrs, DeclarativeBase):
pass

View File

@ -1,5 +0,0 @@
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"]

View File

@ -1,24 +0,0 @@
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"
)

View File

@ -1,19 +0,0 @@
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"
)

View File

@ -1,40 +0,0 @@
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"),
# )

View File

@ -1,26 +1,26 @@
import asyncio
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
async def get_db() -> AsyncGenerator:
"""Dependency для получения сессии базы данных."""
db = SessionLocal()
try:
yield db
await db.commit()
finally:
await db.close()
# async def run_migrations():
# alembic_cfg = Config("alembic.ini")
# await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
async def run_migrations() -> None:
alembic_cfg = Config("alembic.ini")
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
# async def create_tables():
# """Создание таблиц в базе данных."""
# # Исключаем устаревшую таблицу table_data из автосоздания,
# # чтобы она не пересоздавалась после миграций
# if not settings.DEBUG:
# await run_migrations()
async def create_tables() -> None:
if not settings.DEBUG:
await run_migrations()

View File

@ -0,0 +1 @@

8
api/src/domain/enums.py Normal file
View File

@ -0,0 +1,8 @@
import enum
class AuditEventType(str, enum.Enum):
LOGIN = "LOGIN"
USER_CREATE = "USER_CREATE"
USER_UPDATE = "USER_UPDATE"
USER_DELETE = "USER_DELETE"

70
api/src/domain/models.py Normal file
View File

@ -0,0 +1,70 @@
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")

99
api/src/domain/schemas.py Normal file
View File

@ -0,0 +1,99 @@
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

View File

@ -1,21 +0,0 @@
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 Normal file
View File

@ -0,0 +1,111 @@
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")

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,149 @@
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

View File

@ -1,5 +0,0 @@
from .api import api_route
__all__ = [
"api_route"
]

View File

@ -1,5 +0,0 @@
from .route import route as api_route
__all__ = [
"api_route"
]

View File

@ -1,73 +0,0 @@
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,
)
)

View File

@ -1,21 +0,0 @@
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)

View File

@ -1 +0,0 @@
from src.routes.api.schemas.base import *

View File

@ -1,22 +0,0 @@
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)

View File

@ -1,40 +0,0 @@
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)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,58 @@
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",
)

View File

@ -1,187 +0,0 @@
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

View File

@ -1,53 +0,0 @@
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]

View File

@ -0,0 +1,167 @@
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)

View File

@ -0,0 +1,49 @@
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()

View File

@ -0,0 +1,27 @@
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

View File

@ -0,0 +1,30 @@
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

View File

@ -0,0 +1,32 @@
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

View File

@ -0,0 +1,64 @@
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)