Merge pull request 'Добавил базу из фомичева' (#1) from add_base_code into main

Reviewed-on: #1
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
This commit is contained in:
Raykov-MS 2026-05-07 11:08:01 +03:00
commit e267d2d631
42 changed files with 1532 additions and 142 deletions

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,50 +0,0 @@
import os
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from datetime import datetime, timezone
from .conf import get_settings
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.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,33 +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 = '/'
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

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

@ -0,0 +1 @@

27
api/src/db/base.py Normal file
View File

@ -0,0 +1,27 @@
from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from src.core.config import settings
connect_args = {}
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(
settings.DATABASE_URL,
pool_pre_ping=True,
connect_args=connect_args,
)
SessionLocal = async_sessionmaker(
autoflush=False,
bind=engine,
expire_on_commit=False,
)
class Base(AsyncAttrs, DeclarativeBase):
pass

26
api/src/db/session.py Normal file
View File

@ -0,0 +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:
db = SessionLocal()
try:
yield db
finally:
await db.close()
async def run_migrations() -> None:
alembic_cfg = Config("alembic.ini")
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
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

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,17 +0,0 @@
from fastapi import APIRouter
from datetime import datetime, timezone
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 приложение"
)

View File

@ -1,7 +0,0 @@
from pydantic import BaseModel
from datetime import datetime
class ApiResponse(BaseModel):
date_with_time: datetime
text: str

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

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