Compare commits

..

1 Commits

Author SHA1 Message Date
60939a2cf2 test-tsygankov 2026-05-06 13:16:26 +03:00
57 changed files with 804 additions and 1659 deletions

164
README.md
View File

@ -1,163 +1 @@
[![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

View File

@ -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

View File

@ -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.

View File

@ -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"
) )

View File

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

View File

@ -1 +0,0 @@

View File

@ -1 +0,0 @@

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
View 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
View File

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

9
api/src/conf/models.py Normal file
View 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
View 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()

View File

@ -1 +0,0 @@

View File

@ -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()

View File

@ -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 уже существует"

View File

@ -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"},
)

View File

@ -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
View File

View 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

View 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
View 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()

View File

@ -1 +0,0 @@

View File

@ -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

View 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"]

View 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"
)

View 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"
)

View 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"),
# )

View File

@ -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()

View File

@ -1 +0,0 @@

View File

@ -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"

View File

@ -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")

View File

@ -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
View 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 = "Ошибка валидации"

View File

@ -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")

View File

@ -1 +0,0 @@

View File

@ -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

View File

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

View File

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

View 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,
)
)

View 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)

View File

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

View 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)

View 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)

View File

@ -1 +0,0 @@

View File

@ -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",
)

View 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
View 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]

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)