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:
commit
e267d2d631
9
api/pytest.ini
Normal file
9
api/pytest.ini
Normal 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
16
api/requirements-dev.txt
Normal 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.
@ -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",
|
||||
)
|
||||
|
||||
@ -1,7 +1 @@
|
||||
from .app import app
|
||||
from .conf import get_settings
|
||||
|
||||
__all__ = [
|
||||
"app",
|
||||
"get_settings"
|
||||
]
|
||||
1
api/src/api/__init__.py
Normal file
1
api/src/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
api/src/api/v1/__init__.py
Normal file
1
api/src/api/v1/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
84
api/src/api/v1/auth.py
Normal file
84
api/src/api/v1/auth.py
Normal 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
99
api/src/api/v1/deps.py
Normal 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
7
api/src/api/v1/router.py
Normal 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
139
api/src/api/v1/users.py
Normal 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="Пользователь удалён")
|
||||
|
||||
@ -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")
|
||||
@ -1,5 +0,0 @@
|
||||
from .settings import get_settings
|
||||
|
||||
__all__ = [
|
||||
"get_settings"
|
||||
]
|
||||
@ -1,9 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ServerSettings(BaseModel):
|
||||
"""
|
||||
Настройки сервера uvicorn
|
||||
"""
|
||||
HOST: str = '0.0.0.0'
|
||||
PORT: int = 8000
|
||||
@ -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
1
api/src/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
116
api/src/core/config.py
Normal file
116
api/src/core/config.py
Normal 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
31
api/src/core/errors.py
Normal 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 уже существует"
|
||||
47
api/src/core/exception_handlers.py
Normal file
47
api/src/core/exception_handlers.py
Normal 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
55
api/src/core/security.py
Normal 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
1
api/src/db/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
27
api/src/db/base.py
Normal file
27
api/src/db/base.py
Normal 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
26
api/src/db/session.py
Normal 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()
|
||||
1
api/src/domain/__init__.py
Normal file
1
api/src/domain/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
8
api/src/domain/enums.py
Normal file
8
api/src/domain/enums.py
Normal 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
70
api/src/domain/models.py
Normal 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
99
api/src/domain/schemas.py
Normal 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
111
api/src/main.py
Normal 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")
|
||||
1
api/src/repository/__init__.py
Normal file
1
api/src/repository/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
149
api/src/repository/user_repository.py
Normal file
149
api/src/repository/user_repository.py
Normal 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
|
||||
@ -1,5 +0,0 @@
|
||||
from .api import api_route
|
||||
|
||||
__all__ = [
|
||||
"api_route"
|
||||
]
|
||||
@ -1,5 +0,0 @@
|
||||
from .route import route as api_route
|
||||
|
||||
__all__ = [
|
||||
"api_route"
|
||||
]
|
||||
@ -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 приложение"
|
||||
)
|
||||
@ -1,7 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ApiResponse(BaseModel):
|
||||
date_with_time: datetime
|
||||
text: str
|
||||
1
api/src/services/__init__.py
Normal file
1
api/src/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
58
api/src/services/auth_service.py
Normal file
58
api/src/services/auth_service.py
Normal 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",
|
||||
)
|
||||
167
api/src/services/user_service.py
Normal file
167
api/src/services/user_service.py
Normal 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)
|
||||
49
api/tests/integration/conftest.py
Normal file
49
api/tests/integration/conftest.py
Normal 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()
|
||||
27
api/tests/integration/test_auth_api_smoke.py
Normal file
27
api/tests/integration/test_auth_api_smoke.py
Normal 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
|
||||
30
api/tests/integration/test_users_api_smoke.py
Normal file
30
api/tests/integration/test_users_api_smoke.py
Normal 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
|
||||
32
api/tests/unit/test_auth_service.py
Normal file
32
api/tests/unit/test_auth_service.py
Normal 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
|
||||
64
api/tests/unit/test_user_service_role_ssp_logic.py
Normal file
64
api/tests/unit/test_user_service_role_ssp_logic.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user