Add based code
This commit is contained in:
parent
455fcd1240
commit
1a92df79ac
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
|
import uvicorn
|
||||||
from src import app, get_settings
|
|
||||||
|
from src.main import app, settings
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
settings = get_settings()
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
host=settings.server.HOST,
|
host=settings.HOST,
|
||||||
port=settings.server.PORT,
|
port=settings.PORT,
|
||||||
lifespan="on"
|
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)
|
||||||
138
api/src/api/v1/users.py
Normal file
138
api/src/api/v1/users.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
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 @@
|
|||||||
|
|
||||||
137
api/src/repository/user_repository.py
Normal file
137
api/src/repository/user_repository.py
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
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)))
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_by_email(self, email: str) -> Optional[Users]:
|
||||||
|
return (
|
||||||
|
(await self.db.execute(select(Users).where(Users.email == email)))
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_by_username(self, username: str) -> Optional[Users]:
|
||||||
|
return (
|
||||||
|
(await self.db.execute(select(Users).where(Users.username == username)))
|
||||||
|
.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