Merge branch 'test' into front

This commit is contained in:
PotapovaA 2026-05-21 11:10:01 +03:00
commit b580e38117
24 changed files with 1501 additions and 274 deletions

42
api/src/api/v1/audit.py Normal file
View File

@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import require_admin
from src.db.session import get_db
# from src.domain.models import Users
from src.db.models.app_user import AppUser
from src.domain.schemas import AuditLog, AuditLogQueryParams, BaseListResponse
from src.services.auditlog_service import AuditLogService
router = APIRouter(tags=["audit"])
@router.get(
"/audit-logs",
response_model=BaseListResponse[AuditLog],
status_code=status.HTTP_200_OK,
summary="Получение журнала аудита",
description="Возвращает список записей аудита с возможностью фильтрации. Доступно только администраторам.",
)
async def get_audit_logs(
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
params: AuditLogQueryParams = Depends(),
):
offset = (params.page - 1) * params.limit
audit_service = AuditLogService(db)
logs, count = await audit_service.get_all(
user=current_user,
limit=params.limit,
offset=offset,
user_id=params.user_id,
org_unit_id=params.org_unit_id,
task_id=params.task_id,
form_id=params.form_id,
event_type=params.event_type,
date_from=params.date_from,
date_to=params.date_to,
)
result = [audit_service.orm_log_to_response(log) for log in logs]
return BaseListResponse(result=result, count=count)

View File

@ -14,11 +14,10 @@ security = HTTPBearer()
if settings.DEBUG: if settings.DEBUG:
async def get_current_user( async def get_user_by_token(
credentials: HTTPAuthorizationCredentials = Depends(security), token: str,
db: AsyncSession = Depends(get_db), db: AsyncSession,
) -> AppUser: ) -> AppUser:
token = credentials.credentials
payload = verify_token(token) payload = verify_token(token)
if payload is None: if payload is None:
raise HTTPException( raise HTTPException(
@ -47,6 +46,12 @@ if settings.DEBUG:
) )
return user return user
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> AppUser:
return await get_user_by_token(token=credentials.credentials, db=db)
else: else:
from raisa_fastapi_protected_api import UserInfo, get_user_dependency from raisa_fastapi_protected_api import UserInfo, get_user_dependency

View File

@ -69,6 +69,7 @@ async def get_forms(
with_count=True, with_count=True,
offset=offset, offset=offset,
limit=limit, limit=limit,
load_org=True,
) )
return BaseListResponse( return BaseListResponse(
result = [ result = [

View File

@ -1,12 +1,15 @@
from fastapi import APIRouter from fastapi import APIRouter
from src.api.v1 import auth, users, admin, forms, form_phases, projects from src.api.v1 import auth, users, admin, audit, forms, form_phases, projects
from src.api.v1 import websocket
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(admin.router) api_router.include_router(admin.router)
api_router.include_router(audit.router)
api_router.include_router(forms.router) api_router.include_router(forms.router)
api_router.include_router(projects.router) api_router.include_router(projects.router)
api_router.include_router(form_phases.router) api_router.include_router(form_phases.router)
api_router.include_router(websocket.router)

607
api/src/api/v1/websocket.py Normal file
View File

@ -0,0 +1,607 @@
from contextlib import asynccontextmanager
from dataclasses import asdict
import dataclasses
import enum
from json import dumps, loads
from typing import Any, Optional
#
from asyncpg import UniqueViolationError
from sqlalchemy.exc import IntegrityError
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_user_by_token
from src.db.models.app_user import AppUser
from src.db.models.form_type import FormTypeEnum
from src.services.budget_form_service import BudgetFormService
from src.services.project_service import ProjectService
from src.services.sheet_service import SheetService
from src.core.errors import BasicAppException, ValidationsError
from src.db.session import SessionLocal
from src.services.user_service import UserService
@asynccontextmanager
async def get_db_session():
"""Контекстный менеджер для получения сессии базы данных."""
db = SessionLocal()
try:
yield db
finally:
await db.close()
router = APIRouter(prefix="/ws", tags=["websocket"])
@dataclasses.dataclass
class ConnectionInfo:
ws: WebSocket
user_id: int | None
@dataclasses.dataclass
class FormConnectionInfo(ConnectionInfo):
form_id: int | None = None
sheet: str | None = None
direction: str | None = None
@dataclasses.dataclass
class ProjectConnectionInfo(ConnectionInfo):
project_id: int | None = None
year: int | None = None
report_type: str | None = None
class ConnectionKeyEnum(str, enum.Enum):
FORM = "FORM"
PROJECT = "PROJECT"
class ConnectionManager:
con_info_mapping = {
ConnectionKeyEnum.FORM: FormConnectionInfo,
ConnectionKeyEnum.PROJECT: ProjectConnectionInfo,
}
def __init__(self):
self.connections: dict[int, dict[ConnectionKeyEnum, list[ConnectionInfo]]] = {
ConnectionKeyEnum.FORM: {},
ConnectionKeyEnum.PROJECT: {},
}
async def connect(
self,
websocket: WebSocket,
con_key: ConnectionKeyEnum,
**kwargs,
# form_id: int,
# sheet: str,
# direction: str | None = None,
):
await websocket.accept()
key = frozenset(kwargs.items())
cls = self.con_info_mapping[con_key]
if key not in self.connections[con_key]:
self.connections[con_key][key] = [
cls(
ws=websocket,
user_id=None,
**kwargs,
)
]
else:
self.connections[con_key][key].append(
cls(
ws=websocket,
user_id=None,
**kwargs,
)
)
def set_user(
self,
websocket: WebSocket,
user_id: int,
con_key: ConnectionKeyEnum,
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
for con_info in self.connections[con_key][key]:
if con_info.ws == websocket:
con_info.user_id = user_id
async def disconnect(
self,
websocket: WebSocket,
con_key: ConnectionKeyEnum,
code: int = status.WS_1008_POLICY_VIOLATION,
reason: str = "Ошибка",
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
for con_info in self.connections[con_key][key]:
if con_info.ws == websocket:
self.connections[con_key][key].remove(con_info)
if not len(self.connections[con_key][key]):
del self.connections[con_key][key]
await websocket.close(
code=code, reason=reason,
)
async def broadcast_to_other(
self,
message: str,
user_id: int,
con_key: ConnectionKeyEnum,
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections:
return
for con_info in self.connections[con_key][key]:
if con_info.user_id != user_id:
try:
await con_info.ws.send_text(message)
except:
pass # Игнорируем недоступные соединения
async def broadcast_to_all(
self,
message: str,
con_key: ConnectionKeyEnum,
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
for con_info in self.connections[con_key][key]:
try:
await con_info.ws.send_text(message)
except:
pass # Игнорируем недоступные соединения
async def send_back(self, message: str, websocket: WebSocket):
await websocket.send_text(message)
async def get_data(self, websocket: WebSocket) -> dict:
return loads(await websocket.receive_text())
class FormEventProcess:
def __init__(self, db: AsyncSession):
self.sheet_service: SheetService = SheetService(db)
self.bf_service: BudgetFormService = BudgetFormService(db)
self.user_service: UserService = UserService(db)
async def process(
self,
event_data: dict,
form_id: int,
user_id: int,
sheet: str,
direction: str | None = None,
) -> int | bool | dict:
curr_user: AppUser = await self.user_service.get(user_id)
match event_data["event"]:
case "cell_updated":
return await self.__update_cell(
event_data=event_data["data"],
form_id=form_id,
user=curr_user,
sheet=sheet,
direction=direction,
)
case "row_added":
return await self.__add_row(
event_data=event_data["data"],
form_id=form_id,
user=curr_user,
sheet=sheet,
direction=direction,
)
case "row_deleted":
return await self.__del_row(
event_data=event_data["data"],
form_id=form_id,
user=curr_user,
sheet=sheet,
direction=direction,
)
case _:
pass
async def __update_cell(
self,
event_data: dict,
form_id: int,
sheet: str,
user: AppUser,
direction: str | None = None,
) -> list[tuple]:
"""
event_data: {
"line_id": int,
"column": str,
"value": any,
}
"""
form = await self.bf_service.get(budget_form_id=form_id, user=user)
if not form:
return None
return await self.sheet_service.update_cell(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=None,
line_id=event_data["line_id"],
column=event_data["column"],
value=event_data["value"],
user=user,
)
async def __add_row(
self,
event_data: dict,
form_id: int,
sheet: str,
user: AppUser,
direction: str | None,
) -> list[tuple]:
"""
data = {
expense_item_id: Optional[int] = None
item_id: Optional[str] = None
section_code: Optional[str] = None
name: Optional[str] = None
internal_order: Optional[str] = None
vsp_id: Optional[int] = None
project_id: Optional[int] = None
justification: Optional[str] = None
contract_number: Optional[str] = None
contract_end_date: Optional[datetime] = None
}
"""
form = await self.bf_service.get(budget_form_id=form_id, user=user)
if not form:
return None
return await self.sheet_service.add_line(
form_id=form_id,
sheet=sheet,
expense_item_id=event_data.get("expense_item_id"),
item_id=event_data.get("item_id"),
section_code=event_data.get("section_code"),
direction=direction,
name=event_data.get("name"),
internal_order=event_data.get("internal_order"),
vsp_id=event_data.get("vsp_id"),
project_id=event_data.get("project_id"),
justification=event_data.get("justification"),
contract_number=event_data.get("contract_number"),
contract_end_date=event_data.get("contract_end_date"),
user=user,
)
async def __del_row(
self,
event_data: dict,
form_id: int,
sheet: str,
user: AppUser,
direction: str | None,
) -> list[tuple]:
"""
data = {
"row_id": int,
}
"""
form = await self.bf_service.get(budget_form_id=form_id, user=user)
if not form:
return None
result = await self.sheet_service.delete_line(
form_id=form_id,
sheet=sheet,
row_id=event_data["row_id"],
direction=direction,
user=user,
)
return result
class ProjectEventProcess:
def __init__(self, db: AsyncSession):
self.project_service: ProjectService = ProjectService(db)
self.bf_service: BudgetFormService = BudgetFormService(db)
self.user_service: UserService = UserService(db)
async def process(
self,
event_data: dict,
project_id: int,
user_id: int,
year: int,
report_type: str,
) -> int | bool | dict:
curr_user: AppUser = await self.user_service.get(user_id)
match event_data["event"]:
case "cell_updated":
return await self.__update_cell(
event_data=event_data["data"],
project_id=project_id,
year=year,
report_type=report_type,
user=curr_user,
)
case "row_added":
return await self.__add_row(
event_data=event_data["data"],
project_id=project_id,
year=year,
report_type=report_type,
user=curr_user,
)
case "row_deleted":
return await self.__del_row(
event_data=event_data["data"],
project_id=project_id,
year=year,
report_type=report_type,
user=curr_user,
)
case _:
pass
async def __update_cell(
self,
event_data: dict,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> list[tuple]:
"""
event_data: {
"line_id": int,
"column": str,
"value": any,
}
"""
project = await self.project_service.get(project_id=project_id, user=user)
if not project:
return None
return await self.project_service.upd_form3_cell(
project_id=project_id,
year=year,
report_type=report_type,
line_id=event_data["line_id"],
column=event_data["column"],
value=event_data["value"],
user=user,
)
async def __add_row(
self,
event_data: dict,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> list[tuple]:
"""
data = {
expense_item_id: Optional[int] = None
}
"""
project = await self.project_service.get(project_id=project_id, user=user)
if not project:
return None
return await self.project_service.add_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
expense_item_id=event_data["expense_item_id"],
user=user,
)
async def __del_row(
self,
event_data: dict,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> list[tuple]:
"""
data = {
"line_id": int,
}
"""
project = await self.project_service.get(project_id=project_id, user=user)
if not project:
return None
return await self.project_service.del_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
line_id=event_data["line_id"],
user=user,
)
manager = ConnectionManager()
def _convert_error(o):
try:
return asdict(o)
except TypeError:
return o
async def login(websocket: WebSocket, **kwargs) -> int:
user_data = await manager.get_data(websocket=websocket)
if user_data.get("event") != "user_login":
return None
async with get_db_session() as db:
user = await get_user_by_token(token=user_data["data"].get("token"), db=db)
if not user:
return None
manager.set_user(
websocket=websocket,
user_id=user.id,
**kwargs,
)
return user.id
async def process_websocket(
websocket: WebSocket,
processor_cls,
**kwargs,
):
await manager.connect(
websocket=websocket,
**kwargs,
)
try:
user_id = await login(
websocket=websocket,
**kwargs,
)
if user_id is None:
await manager.disconnect(
websocket=websocket,
reason="Ошибка авторизации",
**kwargs,
)
return
while True:
data = loads(await websocket.receive_text())
kwargs_process = kwargs.copy()
if "con_key" in kwargs_process:
kwargs_process.pop("con_key")
try:
async with get_db_session() as db:
processor = processor_cls(db)
data["result"] = await processor.process(
event_data=data,
user_id=user_id,
**kwargs_process
)
await db.commit()
if data.get("event") in [
"cell_updated",
"row_added",
"row_deleted",
]:
await manager.broadcast_to_all(
message=dumps(data, default=_convert_error),
**kwargs
)
else:
await manager.broadcast_to_other(
message=dumps(data, default=_convert_error),
user_id=user_id,
**kwargs,
)
except BasicAppException as e:
data["error"] = e.description or "Неизвестная ошибка"
await manager.send_back(
message=dumps(data, default=_convert_error),
websocket=websocket,
)
except IntegrityError as e:
data["error"] = str(e)
await manager.send_back(
message=dumps(data, default=_convert_error),
websocket=websocket,
)
except Exception as e:
tp = type(e)
handlers = websocket.app.exception_handlers
if tp in handlers:
data["error"] = loads((await handlers[tp](request=None, exc=e)).body)
await manager.send_back(
message=dumps(data, default=_convert_error),
websocket=websocket,
)
else:
raise e
except WebSocketDisconnect:
await manager.disconnect(
websocket=websocket,
**kwargs
)
except Exception as e:
# Логируем ошибку, но не бросаем HTTPException — это WebSocket
print(f"Error: {e}")
await manager.disconnect(
websocket=websocket,
**kwargs,
)
@router.websocket("/form/{form_id}/sheet/{sheet}")
async def websocket_form(
websocket: WebSocket,
form_id: int,
sheet: str,
direction: Optional[str] = None,
):
await process_websocket(
websocket=websocket,
form_id=form_id,
sheet=sheet,
direction=direction,
processor_cls=FormEventProcess,
con_key=ConnectionKeyEnum.FORM,
)
@router.websocket("/projects/{project_id}/report/{year}/{report_type}")
async def websocket_project(
websocket: WebSocket,
project_id: int,
year: int,
report_type: str,
):
await process_websocket(
websocket=websocket,
project_id=project_id,
year=year,
report_type=report_type,
processor_cls=ProjectEventProcess,
con_key=ConnectionKeyEnum.PROJECT,
)

View File

@ -105,6 +105,11 @@ class Settings(BaseSettings):
description="Размер батча для очистки аудита", description="Размер батча для очистки аудита",
alias="AUDIT_LOG_CLEANUP_BATCH_SIZE", alias="AUDIT_LOG_CLEANUP_BATCH_SIZE",
) )
AUDIT_LOG_CLEANUP_INTERVAL_SECONDS: int = Field(
default=86400,
description="Интервал автозапуска очистки аудита в секундах",
alias="AUDIT_LOG_CLEANUP_INTERVAL_SECONDS",
)
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View File

@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
import typing
from sqlalchemy import DateTime, ForeignKey, Integer, String, func from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -39,9 +40,9 @@ class BudgetForm(Base):
# updater: Mapped[typing.Optional["AppUser"]] = relationship( # updater: Mapped[typing.Optional["AppUser"]] = relationship(
# "AppUser", back_populates="updated_budget_forms", foreign_keys=[updated_by] # "AppUser", back_populates="updated_budget_forms", foreign_keys=[updated_by]
# ) # )
# org_unit: Mapped[typing.Optional["OrgUnit"]] = relationship( org_unit: Mapped[typing.Optional["OrgUnit"]] = relationship(
# "OrgUnit", back_populates="budget_forms" "OrgUnit", #back_populates="budget_forms"
# ) )
# budget_lines: Mapped[list["BudgetLine"]] = relationship( # budget_lines: Mapped[list["BudgetLine"]] = relationship(
# "BudgetLine", back_populates="budget_form" # "BudgetLine", back_populates="budget_form"
# ) # )

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
import enum import enum
from typing import Any, Generic, Literal, Optional, TypeVar from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"] FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"]
@ -172,6 +172,15 @@ class FormTypeSchemaEnum(str, enum.Enum):
FORM_4 = "FORM_4" FORM_4 = "FORM_4"
class OrgUnitSchema(BaseModel):
id: int
title: str
is_active: bool
is_ssp: bool
class Config:
from_attributes = True
class BudgetFormResponse(BaseModel): class BudgetFormResponse(BaseModel):
id: int id: int
created_at: datetime created_at: datetime
@ -180,6 +189,7 @@ class BudgetFormResponse(BaseModel):
form_type_code: FormTypeSchemaEnum form_type_code: FormTypeSchemaEnum
year: int year: int
org_unit_id: int org_unit_id: int
org_unit: OrgUnitSchema | None = None
class Config: class Config:
from_attributes = True from_attributes = True
@ -280,3 +290,49 @@ class FormPhaseUpdate(BaseModel):
column_keys: list[str] | None = None column_keys: list[str] | None = None
opens_at: datetime | None = None opens_at: datetime | None = None
closes_at: datetime | None = None closes_at: datetime | None = None
class AuditLogBase(BaseModel):
"""Базовая схема записи аудита (единый формат вывода как у auditlog)."""
entity: str = Field(..., description="Тип сущности")
entity_id: Optional[int] = Field(None, description="ID сущности")
action: str = Field(..., description="Действие")
payload_json: Optional[Dict[str, Any]] = None
class AuditLogInDB(AuditLogBase):
"""Схема записи аудита в базе данных."""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="ID записи")
user_id: Optional[int] = Field(None, description="ID пользователя")
at: datetime = Field(..., description="Дата/время события")
class AuditLog(AuditLogInDB):
"""Схема записи аудита для ответа API."""
user: Optional[User] = None
class AuditLogListResponse(ResponseBase):
"""Схема всех записей аудита для ответа API."""
result: List[AuditLog] = Field(..., description="Вывод записей аудита")
class AuditLogQueryParams(BaseModel):
"""Query-параметры для фильтрации журнала аудита."""
page: int = Field(1, ge=1, description="Номер страницы")
limit: int = Field(
20, ge=1, le=100, description="Количество записей на странице"
)
user_id: Optional[int] = Field(None, description="ID пользователя")
org_unit_id: Optional[int] = Field(None, description="ID ССП")
task_id: Optional[int] = Field(None, description="ID задачи")
form_id: Optional[int] = Field(None, description="ID формы")
event_type: Optional[str] = Field(None, description="Тип события")
date_from: Optional[datetime] = Field(None, description="Дата начала (ISO 8601)")
date_to: Optional[datetime] = Field(None, description="Дата окончания (ISO 8601)")

View File

@ -1,6 +1,8 @@
import asyncio
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from contextlib import suppress
from fastapi import FastAPI, Response from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -27,8 +29,72 @@ if not settings.DEBUG:
) )
_AUDIT_CLEANUP_LOCK_KEY = 21987431
async def _cleanup_audit_log_once() -> int:
if "postgresql" not in settings.DATABASE_URL:
return 0
total_deleted = 0
async with engine.begin() as conn:
lock_ok = (
await conn.execute(
text("SELECT pg_try_advisory_lock(:k)"),
{"k": _AUDIT_CLEANUP_LOCK_KEY},
)
).scalar_one()
if not lock_ok:
return 0
try:
while True:
deleted = (
await conn.execute(
text(
"""
WITH doomed AS (
SELECT id
FROM v3.audit_log
WHERE event_dt < now() - make_interval(days => :retention_days)
ORDER BY id
LIMIT :batch_size
)
DELETE FROM v3.audit_log a
USING doomed d
WHERE a.id = d.id
RETURNING a.id
"""
),
{
"retention_days": settings.AUDIT_LOG_RETENTION_DAYS,
"batch_size": settings.AUDIT_LOG_CLEANUP_BATCH_SIZE,
},
)
).rowcount
if not deleted:
break
total_deleted += deleted
finally:
await conn.execute(
text("SELECT pg_advisory_unlock(:k)"),
{"k": _AUDIT_CLEANUP_LOCK_KEY},
)
return total_deleted
async def _audit_cleanup_loop() -> None:
# Первый прогон сразу после старта, дальше — по интервалу.
while True:
try:
await _cleanup_audit_log_once()
except Exception:
pass
await asyncio.sleep(max(60, settings.AUDIT_LOG_CLEANUP_INTERVAL_SECONDS))
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
cleanup_task: asyncio.Task | None = None
if not settings.DEBUG: if not settings.DEBUG:
await create_tables() await create_tables()
ProtectedSettings( ProtectedSettings(
@ -38,9 +104,14 @@ async def lifespan(app: FastAPI):
APP_NAME=settings.APP_NAME, APP_NAME=settings.APP_NAME,
), ),
) )
cleanup_task = asyncio.create_task(_audit_cleanup_loop())
try: try:
yield yield
finally: finally:
if cleanup_task:
cleanup_task.cancel()
with suppress(asyncio.CancelledError):
await cleanup_task
await engine.dispose() await engine.dispose()

View File

@ -0,0 +1,79 @@
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.functions import func
# from sqlalchemy.orm import selectinload
# from app.domain.models import AuditLog
from src.db.models.audit_log import AuditLog
class AuditLogRepository:
"""Репозиторий для работы с журналом аудита."""
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, audit_log_id: int) -> Optional[AuditLog]:
"""Получение записи аудита по ID."""
return (
(
await self.db.execute(
select(AuditLog).where(AuditLog.id == audit_log_id).limit(1)
)
)
.scalars()
.first()
)
async def get_all(
self,
limit: int | None = None,
offset: int | None = None,
user_id: int | None = None,
org_unit_id: int | None = None,
task_id: int | None = None,
form_id: int | None = None,
event_type: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> tuple[Iterable[AuditLog], int]:
"""Получение всех записей аудита с опциональной фильтрацией."""
query = select(AuditLog)
query_count = select(func.count(AuditLog.id))
if user_id is not None:
query = query.where(AuditLog.user_id == user_id)
query_count = query_count.where(AuditLog.user_id == user_id)
if org_unit_id is not None:
query = query.where(AuditLog.org_unit_id == org_unit_id)
query_count = query_count.where(AuditLog.org_unit_id == org_unit_id)
if task_id is not None:
query = query.where(AuditLog.task_id == task_id)
query_count = query_count.where(AuditLog.task_id == task_id)
if form_id is not None:
query = query.where(AuditLog.form_id == form_id)
query_count = query_count.where(AuditLog.form_id == form_id)
if event_type is not None:
query = query.where(AuditLog.event_type == event_type)
query_count = query_count.where(AuditLog.event_type == event_type)
if date_from is not None:
query = query.where(AuditLog.event_dt >= date_from)
query_count = query_count.where(AuditLog.event_dt >= date_from)
if date_to is not None:
query = query.where(AuditLog.event_dt <= date_to)
query_count = query_count.where(AuditLog.event_dt <= date_to)
query = query.order_by(AuditLog.event_dt.desc())
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
return (await self.db.execute(query)).scalars().all(), (
await self.db.execute(query_count)
).scalar_one()

View File

@ -16,6 +16,7 @@ class BudgetFormRepository:
limit: int | None = None, limit: int | None = None,
org_unit: int | list[int] | None = None, org_unit: int | list[int] | None = None,
with_count: bool = False, with_count: bool = False,
load_org: bool = False,
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]: ) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if with_count: if with_count:
query = select(func.count().over().label("total_count"), BudgetForm) query = select(func.count().over().label("total_count"), BudgetForm)
@ -28,7 +29,13 @@ class BudgetFormRepository:
else: else:
where.append(BudgetForm.org_unit_id.in_(org_unit)) where.append(BudgetForm.org_unit_id.in_(org_unit))
query = query.where(*where).order_by(BudgetForm.id)
query = query.where(*where)
if load_org:
query = query.options(
joinedload(BudgetForm.org_unit)
)
query = query.order_by(BudgetForm.id)
if offset is not None: if offset is not None:
query = query.offset(offset) query = query.offset(offset)

View File

@ -235,8 +235,7 @@ class ProjectRepository:
}, },
) )
).all() ).all()
await self.db.commit() return [tuple(r) for r in rows]
return rows
async def upd_form3_cells(self, report_id: int, changes: list[dict]) -> list[tuple]: async def upd_form3_cells(self, report_id: int, changes: list[dict]) -> list[tuple]:
query = text( query = text(
@ -254,8 +253,7 @@ class ProjectRepository:
}, },
) )
).all() ).all()
await self.db.commit() return [tuple(r) for r in rows]
return rows
async def add_form3_line(self, report_id: int, expense_item_id: int) -> list[tuple]: async def add_form3_line(self, report_id: int, expense_item_id: int) -> list[tuple]:
query = text( query = text(
@ -273,8 +271,7 @@ class ProjectRepository:
}, },
) )
).all() ).all()
await self.db.commit() return [tuple(r) for r in rows]
return rows
async def del_form3_line(self, line_id: int) -> list[tuple]: async def del_form3_line(self, line_id: int) -> list[tuple]:
query = text( query = text(
@ -284,8 +281,7 @@ class ProjectRepository:
""" """
) )
rows = (await self.db.execute(query, {"line_id": line_id})).all() rows = (await self.db.execute(query, {"line_id": line_id})).all()
await self.db.commit() return [tuple(r) for r in rows]
return rows
async def upd_project(self, project_id: int, column: str, value): async def upd_project(self, project_id: int, column: str, value):
query = text( query = text(
@ -303,7 +299,6 @@ class ProjectRepository:
}, },
) )
).scalar_one() ).scalar_one()
await self.db.commit()
return result return result
async def add_project( async def add_project(
@ -356,5 +351,4 @@ class ProjectRepository:
}, },
) )
).first() ).first()
await self.db.commit()
return int(row[0]), int(row[1]), int(row[2]) return int(row[0]), int(row[1]), int(row[2])

View File

@ -93,7 +93,8 @@ class SheetRepository:
else: else:
func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :direction, :sections)" func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :direction, :sections)"
return ( return [
tuple(r) for r in (
await self.db.execute( await self.db.execute(
text( text(
f""" f"""
@ -113,6 +114,7 @@ class SheetRepository:
} }
) )
).all() ).all()
]
async def update_cells( async def update_cells(
self, self,
@ -138,7 +140,8 @@ class SheetRepository:
else: else:
func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :direction, :sections)" func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :direction, :sections)"
return ( return [
tuple(r) for r in (
await self.db.execute( await self.db.execute(
text( text(
f""" f"""
@ -156,6 +159,7 @@ class SheetRepository:
} }
) )
).all() ).all()
]
async def add_line( async def add_line(
self, self,
@ -174,7 +178,8 @@ class SheetRepository:
contract_end_date: str | None = None, contract_end_date: str | None = None,
user_id: int | None = None, user_id: int | None = None,
) -> list[tuple]: ) -> list[tuple]:
return ( return [
tuple(r) for r in (
await self.db.execute( await self.db.execute(
text( text(
""" """
@ -213,6 +218,7 @@ class SheetRepository:
} }
) )
).all() ).all()
]
async def delete_line( async def delete_line(
self, self,
@ -220,7 +226,9 @@ class SheetRepository:
row_id: int, row_id: int,
direction: str | None = None, direction: str | None = None,
) -> list[tuple]: ) -> list[tuple]:
return (
return [
tuple(r) for r in (
await self.db.execute( await self.db.execute(
text( text(
"SELECT row_type, depth, sort_order, data " "SELECT row_type, depth, sort_order, data "
@ -233,3 +241,5 @@ class SheetRepository:
} }
) )
).all() ).all()
]

View File

@ -0,0 +1,96 @@
from datetime import datetime
from typing import Any, Dict, Iterable, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.errors import AccessDeniedException
# from src.domain.models import AuditLog, UserRole, Users
from src.db.models.audit_log import AuditLog
from src.db.models.role import UserRoleEnum
from src.db.models.app_user import AppUser
from src.repository.auditlog_repository import AuditLogRepository
from src.domain.schemas import AuditLog as AuditLogSchema
class AuditLogService:
"""Сервис для работы с журналом аудита."""
def __init__(self, db: AsyncSession):
self.db = db
self.audit_repo = AuditLogRepository(db)
async def get(self, audit_log_id: int, user: AppUser) -> Optional[AuditLog]:
"""Получение записи аудита по ID."""
if not self._can_view_audit_logs(user):
raise AccessDeniedException(
"Недостаточно прав для просмотра журнала аудита"
)
return await self.audit_repo.get(audit_log_id)
async def get_all(
self,
user: AppUser,
limit: int | None = None,
offset: int | None = None,
user_id: int | None = None,
org_unit_id: int | None = None,
task_id: int | None = None,
form_id: int | None = None,
event_type: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> tuple[Iterable[AuditLog], int]:
"""Получение всех записей аудита с опциональной фильтрацией."""
if not self._can_view_audit_logs(user):
raise AccessDeniedException(
"Недостаточно прав для просмотра журнала аудита"
)
return await self.audit_repo.get_all(
limit=limit,
offset=offset,
user_id=user_id,
org_unit_id=org_unit_id,
task_id=task_id,
form_id=form_id,
event_type=event_type,
date_from=date_from,
date_to=date_to,
)
def _can_view_audit_logs(self, user: AppUser) -> bool:
"""Проверяет, может ли пользователь просматривать журнал аудита."""
return user.role_id == UserRoleEnum.ADMIN.value
def orm_log_to_response(self, log: AuditLog) -> AuditLogSchema:
"""Маппинг записи ORM audit_log в формат ответа API (entity, entity_id, action, at, payload_json).
model_validate(orm) не подходит: в БД поля event_dt/event/event_type/event_data, в API at/action/entity/entity_id; entity и entity_id из event_data JSON.
"""
event_data: Optional[Dict[str, Any]] = getattr(log, "event_data", None) or {}
entity = (
event_data.get("entity_type") if isinstance(event_data, dict) else None
) or getattr(log, "event_type", "unknown")
entity_id = (
event_data.get("entity_id") if isinstance(event_data, dict) else None
)
if entity_id is not None and not isinstance(entity_id, int):
try:
entity_id = int(entity_id)
except (TypeError, ValueError):
entity_id = None
action = getattr(log, "event", "")
at = getattr(log, "event_dt", None)
return AuditLogSchema(
entity=entity,
entity_id=entity_id,
action=action,
payload_json=event_data if isinstance(event_data, dict) else None,
id=log.id,
user_id=getattr(log, "user_id", None),
at=at,
user=getattr(log, "user", None),
)

View File

@ -20,13 +20,15 @@ class BudgetFormService:
user: AppUser, user: AppUser,
offset: int | None = None, offset: int | None = None,
limit: int | None = None, limit: int | None = None,
with_count: bool = False with_count: bool = False,
load_org: bool = False,
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]: ) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if user.role_id == UserRoleEnum.ADMIN: if user.role_id == UserRoleEnum.ADMIN:
return await self.bf_repo.get_list( return await self.bf_repo.get_list(
offset=offset, offset=offset,
limit=limit, limit=limit,
with_count=with_count, with_count=with_count,
load_org=load_org,
) )
user = await self.user_repo.get( user = await self.user_repo.get(
user_id=user.id, user_id=user.id,
@ -37,6 +39,7 @@ class BudgetFormService:
limit=limit, limit=limit,
org_unit=[ou.id for ou in user.org_units], org_unit=[ou.id for ou in user.org_units],
with_count=with_count, with_count=with_count,
load_org=load_org,
) )
async def get( async def get(

View File

@ -22,8 +22,8 @@ class UserService:
raise AccessDeniedException() raise AccessDeniedException()
return await self.user_repo.get_list(skip=skip, limit=limit) return await self.user_repo.get_list(skip=skip, limit=limit)
async def get(self, user_id: int, current_user: AppUser): async def get(self, user_id: int, current_user: AppUser | None = None):
if current_user.role_id != UserRoleEnum.ADMIN: if current_user is not None and current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
return await self.user_repo.get(user_id) return await self.user_repo.get(user_id)

View File

@ -55,6 +55,13 @@ def admin_tokens(client, admin_password: str):
return response.json() return response.json()
@pytest.fixture
def auth_headers():
def _build(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
return _build
@pytest.fixture @pytest.fixture
def isp_tokens(client, isp_password: str): def isp_tokens(client, isp_password: str):
response = client.post( response = client.post(

View File

@ -0,0 +1,67 @@
# import uuid
#
#
# def _create_executor_and_tokens(client, admin_tokens, auth_headers) -> tuple[int, dict]:
# suffix = uuid.uuid4().hex[:8]
# username = f"audit_exec_{suffix}"
# password = "pass123"
# created = client.put(
# "/api/v1/users/",
# json={
# "email": f"{username}@example.com",
# "username": username,
# "password": password,
# "full_name": "Audit Executor",
# "role_id": 2,
# },
# headers=auth_headers(admin_tokens),
# )
# assert created.status_code == 200
# user_id = created.json()["result"]["id"]
#
# login = client.post(
# "/api/v1/auth/login",
# json={"username": username, "password": password},
# )
# assert login.status_code == 200
# return user_id, login.json()
def test_audit_logs_admin_smoke(client, admin_tokens, auth_headers):
response = client.get("/api/v1/audit-logs", headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert isinstance(payload["result"], list)
def test_audit_logs_requires_auth(client):
response = client.get("/api/v1/audit-logs")
assert response.status_code == 403
# Тест временно отключён: внутри создаётся пользователь.
# def test_audit_logs_forbidden_for_non_admin(client, admin_tokens, auth_headers):
# user_id, executor_tokens = _create_executor_and_tokens(
# client, admin_tokens, auth_headers
# )
# try:
# response = client.get(
# "/api/v1/audit-logs",
# headers=auth_headers(executor_tokens),
# )
# assert response.status_code == 403
# finally:
# client.delete(
# f"/api/v1/users/{user_id}",
# headers=auth_headers(admin_tokens),
# )
def test_audit_logs_query_validation(client, admin_tokens, auth_headers):
response = client.get(
"/api/v1/audit-logs?limit=101",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 422

View File

@ -3,12 +3,8 @@ import uuid
import pytest import pytest
def _auth_headers(tokens: dict) -> dict: def _pick_form_and_sheet(client, admin_tokens, auth_headers) -> tuple[int, str, str | None]:
return {"Authorization": f"Bearer {tokens['access_token']}"} response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
def _pick_form_and_sheet(client, admin_tokens) -> tuple[int, str, str | None]:
response = client.get("/api/v1/form/", headers=_auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
forms = response.json().get("result") or [] forms = response.json().get("result") or []
@ -20,7 +16,7 @@ def _pick_form_and_sheet(client, admin_tokens) -> tuple[int, str, str | None]:
sheets_response = client.get( sheets_response = client.get(
f"/api/v1/form/{form_id}/sheets", f"/api/v1/form/{form_id}/sheets",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
if sheets_response.status_code != 200: if sheets_response.status_code != 200:
continue continue
@ -36,9 +32,9 @@ def _pick_form_and_sheet(client, admin_tokens) -> tuple[int, str, str | None]:
def _pick_form_with_sheet( def _pick_form_with_sheet(
client, admin_tokens, target_sheet: str, form_type_code: str | None = None client, admin_tokens, auth_headers, target_sheet: str, form_type_code: str | None = None
) -> tuple[int, str]: ) -> tuple[int, str]:
response = client.get("/api/v1/form/", headers=_auth_headers(admin_tokens)) response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
forms = response.json().get("result") or [] forms = response.json().get("result") or []
for form in forms: for form in forms:
@ -49,7 +45,7 @@ def _pick_form_with_sheet(
continue continue
sheets_response = client.get( sheets_response = client.get(
f"/api/v1/form/{form_id}/sheets", f"/api/v1/form/{form_id}/sheets",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
if sheets_response.status_code != 200: if sheets_response.status_code != 200:
continue continue
@ -59,7 +55,9 @@ def _pick_form_with_sheet(
pytest.skip(f"Не найдена форма с листом {target_sheet}") pytest.skip(f"Не найдена форма с листом {target_sheet}")
def _pick_input_line_id(client, admin_tokens, form_id: int, sheet: str, direction: str | None) -> int: def _pick_input_line_id(
client, admin_tokens, auth_headers, form_id: int, sheet: str, direction: str | None
) -> int:
url = f"/api/v1/form/{form_id}/sheet/{sheet}" url = f"/api/v1/form/{form_id}/sheet/{sheet}"
params = [] params = []
if direction: if direction:
@ -67,7 +65,7 @@ def _pick_input_line_id(client, admin_tokens, form_id: int, sheet: str, directio
if params: if params:
url = f"{url}?{'&'.join(params)}" url = f"{url}?{'&'.join(params)}"
response = client.get(url, headers=_auth_headers(admin_tokens)) response = client.get(url, headers=auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
rows = response.json().get("result") or [] rows = response.json().get("result") or []
for row in rows: for row in rows:
@ -78,13 +76,13 @@ def _pick_input_line_id(client, admin_tokens, form_id: int, sheet: str, directio
pytest.skip("Не найден INPUT line_id для проверки upd_form_cell") pytest.skip("Не найден INPUT line_id для проверки upd_form_cell")
def test_backend_calls_v_form_view_via_sheet_endpoint(client, admin_tokens): def test_backend_calls_v_form_view_via_sheet_endpoint(client, admin_tokens, auth_headers):
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens) form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
url = f"/api/v1/form/{form_id}/sheet/{sheet}" url = f"/api/v1/form/{form_id}/sheet/{sheet}"
if direction: if direction:
url = f"{url}?direction={direction}" url = f"{url}?direction={direction}"
response = client.get(url, headers=_auth_headers(admin_tokens)) response = client.get(url, headers=auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert isinstance(payload.get("result"), list) assert isinstance(payload.get("result"), list)
@ -94,9 +92,13 @@ def test_backend_calls_v_form_view_via_sheet_endpoint(client, admin_tokens):
assert "data" in first_row assert "data" in first_row
def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(client, admin_tokens): def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens) client, admin_tokens, auth_headers
line_id = _pick_input_line_id(client, admin_tokens, form_id, sheet, direction) ):
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
line_id = _pick_input_line_id(
client, admin_tokens, auth_headers, form_id, sheet, direction
)
url = f"/api/v1/form/{form_id}/sheet/{sheet}/cell" url = f"/api/v1/form/{form_id}/sheet/{sheet}/cell"
if direction: if direction:
@ -105,7 +107,7 @@ def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(client, admin
response = client.patch( response = client.patch(
url, url,
json={"line_id": line_id, "column": "bad.scope", "value": 1}, json={"line_id": line_id, "column": "bad.scope", "value": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
# SQL-функция отдает prefixed validation error. # SQL-функция отдает prefixed validation error.
@ -116,11 +118,13 @@ def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(client, admin
assert isinstance(payload["message"], str) assert isinstance(payload["message"], str)
def test_backend_calls_form3_add_project_and_add_line_functions(client, admin_tokens): def test_backend_calls_form3_add_project_and_add_line_functions(
client, admin_tokens, auth_headers
):
create = client.post( create = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, json={"name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert create.status_code == 200 assert create.status_code == 200
project_payload = create.json() project_payload = create.json()
@ -132,18 +136,18 @@ def test_backend_calls_form3_add_project_and_add_line_functions(client, admin_to
add_line = client.post( add_line = client.post(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line", f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
json={"expense_item_id": 1}, json={"expense_item_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert add_line.status_code == 200 assert add_line.status_code == 200
rows = add_line.json() rows = add_line.json()
assert isinstance(rows, list) assert isinstance(rows, list)
def test_forms_validation_invalid_sections_returns_400(client, admin_tokens): def test_forms_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers):
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR") form_id, sheet = _pick_form_with_sheet(client, admin_tokens, auth_headers, "AHR")
response = client.get( response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}?sections=bad_section", f"/api/v1/form/{form_id}/sheet/{sheet}?sections=bad_section",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()
@ -151,22 +155,26 @@ def test_forms_validation_invalid_sections_returns_400(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_forms_validation_empty_sections_csv_is_ignored(client, admin_tokens): def test_forms_validation_empty_sections_csv_is_ignored(client, admin_tokens, auth_headers):
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens) form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
url = f"/api/v1/form/{form_id}/sheet/{sheet}?sections= , , " url = f"/api/v1/form/{form_id}/sheet/{sheet}?sections= , , "
if direction: if direction:
url += f"&direction={direction}" url += f"&direction={direction}"
response = client.get(url, headers=_auth_headers(admin_tokens)) response = client.get(url, headers=auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert isinstance(payload.get("result"), list) assert isinstance(payload.get("result"), list)
def test_forms_validation_direction_required_for_form1_ahr(client, admin_tokens): def test_forms_validation_direction_required_for_form1_ahr(
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_1") client, admin_tokens, auth_headers
):
form_id, sheet = _pick_form_with_sheet(
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_1"
)
response = client.get( response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}", f"/api/v1/form/{form_id}/sheet/{sheet}",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()
@ -175,20 +183,26 @@ def test_forms_validation_direction_required_for_form1_ahr(client, admin_tokens)
assert "direction" in payload["message"].lower() assert "direction" in payload["message"].lower()
def test_forms_validation_direction_case_sensitive(client, admin_tokens): def test_forms_validation_direction_case_sensitive(client, admin_tokens, auth_headers):
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_1") form_id, sheet = _pick_form_with_sheet(
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_1"
)
response = client.get( response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}?direction=support", f"/api/v1/form/{form_id}/sheet/{sheet}?direction=support",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 422 assert response.status_code == 422
def test_forms_validation_direction_forbidden_on_non_form1(client, admin_tokens): def test_forms_validation_direction_forbidden_on_non_form1(
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_2") client, admin_tokens, auth_headers
):
form_id, sheet = _pick_form_with_sheet(
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_2"
)
response = client.get( response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}?direction=Support", f"/api/v1/form/{form_id}/sheet/{sheet}?direction=Support",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()
@ -197,17 +211,17 @@ def test_forms_validation_direction_forbidden_on_non_form1(client, admin_tokens)
assert "direction" in payload["message"].lower() assert "direction" in payload["message"].lower()
def test_form3_validation_invalid_sections_returns_400(client, admin_tokens): def test_form3_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers):
create = client.post( create = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, json={"name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert create.status_code == 200 assert create.status_code == 200
project_id = int(create.json()["project_id"]) project_id = int(create.json()["project_id"])
response = client.get( response = client.get(
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q5", f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q5",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()
@ -215,44 +229,44 @@ def test_form3_validation_invalid_sections_returns_400(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens): def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens, auth_headers):
create = client.post( create = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, json={"name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert create.status_code == 200 assert create.status_code == 200
project_id = int(create.json()["project_id"]) project_id = int(create.json()["project_id"])
response = client.get( response = client.get(
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections= , , ", f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections= , , ",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
assert isinstance(response.json().get("result"), list) assert isinstance(response.json().get("result"), list)
def test_form3_validation_duplicate_sections_is_allowed(client, admin_tokens): def test_form3_validation_duplicate_sections_is_allowed(client, admin_tokens, auth_headers):
create = client.post( create = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, json={"name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert create.status_code == 200 assert create.status_code == 200
project_id = int(create.json()["project_id"]) project_id = int(create.json()["project_id"])
response = client.get( response = client.get(
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q1", f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q1",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
assert isinstance(response.json().get("result"), list) assert isinstance(response.json().get("result"), list)
def test_form3_add_project_invalid_name_returns_422(client, admin_tokens): def test_form3_add_project_invalid_name_returns_422(client, admin_tokens, auth_headers):
# Пробел запрещён regex-правилом AddProjectBody. # Пробел запрещён regex-правилом AddProjectBody.
response = client.post( response = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": "INVALID NAME", "year": 2026, "branch_id": 1}, json={"name": "INVALID NAME", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 422 assert response.status_code == 422
@ -271,38 +285,46 @@ def test_health_and_ready_endpoints(client):
assert "mv_expense_item_tree" in ready_payload assert "mv_expense_item_tree" in ready_payload
def test_admin_refresh_tree_requires_admin_role(client, admin_tokens): # Тест временно отключён: внутри создаётся пользователь.
admin_resp = client.post( # def test_admin_refresh_tree_requires_admin_role(client, admin_tokens, auth_headers):
"/api/v1/admin/refresh-tree", # admin_resp = client.post(
headers=_auth_headers(admin_tokens), # "/api/v1/admin/refresh-tree",
) # headers=auth_headers(admin_tokens),
assert admin_resp.status_code in (200, 503) # )
# assert admin_resp.status_code in (200, 503)
uname = f"itest_non_admin_{uuid.uuid4().hex[:6]}" #
create_user = client.put( # uname = f"itest_non_admin_{uuid.uuid4().hex[:6]}"
"/api/v1/users/", # create_user = client.put(
json={ # "/api/v1/users/",
"email": f"{uname}@example.com", # json={
"username": uname, # "email": f"{uname}@example.com",
"password": "pass123", # "username": uname,
"full_name": "Integration User", # "password": "pass123",
"role_id": 2, # "full_name": "Integration User",
}, # "role_id": 2,
headers=_auth_headers(admin_tokens), # },
) # headers=auth_headers(admin_tokens),
if create_user.status_code != 200: # )
pytest.skip("Не удалось создать non-admin пользователя в текущей БД") # if create_user.status_code != 200:
# pytest.skip("Не удалось создать non-admin пользователя в текущей БД")
login_resp = client.post("/api/v1/auth/login", json={"username": uname, "password": "pass123"}) #
if login_resp.status_code != 200: # login_resp = client.post("/api/v1/auth/login", json={"username": uname, "password": "pass123"})
pytest.skip("Не удалось залогинить non-admin пользователя в текущей БД") # if login_resp.status_code != 200:
user_tokens = login_resp.json() # pytest.skip("Не удалось залогинить non-admin пользователя в текущей БД")
# user_tokens = login_resp.json()
non_admin_resp = client.post( #
"/api/v1/admin/refresh-tree", # non_admin_resp = client.post(
headers=_auth_headers(user_tokens), # "/api/v1/admin/refresh-tree",
) # headers=auth_headers(user_tokens),
assert non_admin_resp.status_code == 403 # )
# assert non_admin_resp.status_code == 403
#
# # Что бы не было переполнения бд
# deleted = client.delete(
# f"/api/v1/users/{create_user.json()['result']['id']}",
# headers=auth_headers(admin_tokens),
# )
# assert deleted.status_code == 200
def test_admin_refresh_tree_requires_auth(client): def test_admin_refresh_tree_requires_auth(client):

View File

@ -1,12 +1,8 @@
import pytest import pytest
def _auth_headers(tokens: dict) -> dict: def test_forms_list_smoke(client, admin_tokens, auth_headers):
return {"Authorization": f"Bearer {tokens['access_token']}"} response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
def test_forms_list_smoke(client, admin_tokens):
response = client.get("/api/v1/form/", headers=_auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert "result" in payload assert "result" in payload
@ -14,9 +10,9 @@ def test_forms_list_smoke(client, admin_tokens):
assert isinstance(payload["result"], list) assert isinstance(payload["result"], list)
def test_forms_sheets_not_found_error_shape(client, admin_tokens): def test_forms_sheets_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.get( response = client.get(
"/api/v1/form/999/sheets", headers=_auth_headers(admin_tokens) "/api/v1/form/999/sheets", headers=auth_headers(admin_tokens)
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()
@ -25,9 +21,9 @@ def test_forms_sheets_not_found_error_shape(client, admin_tokens):
assert isinstance(payload["message"], str) assert isinstance(payload["message"], str)
def test_forms_sheet_get_not_found_error_shape(client, admin_tokens): def test_forms_sheet_get_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.get( response = client.get(
"/api/v1/form/999/sheet/AHR", headers=_auth_headers(admin_tokens) "/api/v1/form/999/sheet/AHR", headers=auth_headers(admin_tokens)
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()
@ -35,11 +31,11 @@ def test_forms_sheet_get_not_found_error_shape(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_forms_cell_update_not_found_error_shape(client, admin_tokens): def test_forms_cell_update_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.patch( response = client.patch(
"/api/v1/form/999/sheet/AHR/cell", "/api/v1/form/999/sheet/AHR/cell",
json={"line_id": 1, "column": "q1.m1", "value": 100}, json={"line_id": 1, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()
@ -47,7 +43,7 @@ def test_forms_cell_update_not_found_error_shape(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_forms_cell_update(client, admin_tokens): def test_forms_cell_update(client, admin_tokens, auth_headers):
response = client.patch( response = client.patch(
"/api/v1/form/1/sheet/AHR/cell?direction=Support&sections=plan,contract_summary", "/api/v1/form/1/sheet/AHR/cell?direction=Support&sections=plan,contract_summary",
json={ json={
@ -55,7 +51,7 @@ def test_forms_cell_update(client, admin_tokens):
"column": "plan.q1", "column": "plan.q1",
"value": 225 "value": 225
}, },
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
@ -63,11 +59,11 @@ def test_forms_cell_update(client, admin_tokens):
assert "result" in payload assert "result" in payload
def test_forms_cells_update_not_found_error_shape(client, admin_tokens): def test_forms_cells_update_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.patch( response = client.patch(
"/api/v1/form/999/sheet/AHR/cells", "/api/v1/form/999/sheet/AHR/cells",
json={"changes": [{"line_id": 1, "column": "q1.m1", "value": 100}]}, json={"changes": [{"line_id": 1, "column": "q1.m1", "value": 100}]},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()
@ -75,7 +71,7 @@ def test_forms_cells_update_not_found_error_shape(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_forms_cells_update(client, admin_tokens): def test_forms_cells_update(client, admin_tokens, auth_headers):
response = client.patch( response = client.patch(
"/api/v1/form/1/sheet/AHR/cells?direction=Support&sections=plan,contract_summary", "/api/v1/form/1/sheet/AHR/cells?direction=Support&sections=plan,contract_summary",
json={ json={
@ -87,7 +83,7 @@ def test_forms_cells_update(client, admin_tokens):
} }
], ],
}, },
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
@ -95,11 +91,11 @@ def test_forms_cells_update(client, admin_tokens):
assert "result" in payload assert "result" in payload
def test_forms_add_line_not_found_error_shape(client, admin_tokens): def test_forms_add_line_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.post( response = client.post(
"/api/v1/form/999/sheet/AHR/line", "/api/v1/form/999/sheet/AHR/line",
json={}, json={},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()
@ -107,14 +103,14 @@ def test_forms_add_line_not_found_error_shape(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_forms_add_line(client, admin_tokens): def test_forms_add_line(client, admin_tokens, auth_headers):
response = client.post( response = client.post(
"/api/v1/form/1/sheet/AHR/line?direction=Support&sections=plan,contract_summary", "/api/v1/form/1/sheet/AHR/line?direction=Support&sections=plan,contract_summary",
json={ json={
"expense_item_id": 120, "expense_item_id": 120,
"direction": "Support" "direction": "Support"
}, },
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
@ -122,10 +118,10 @@ def test_forms_add_line(client, admin_tokens):
assert "result" in payload assert "result" in payload
def test_forms_delete_line_not_found_error_shape(client, admin_tokens): def test_forms_delete_line_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.delete( response = client.delete(
"/api/v1/form/999/sheet/AHR/line/1", "/api/v1/form/999/sheet/AHR/line/1",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()

View File

@ -3,12 +3,8 @@ import uuid
import pytest import pytest
def _auth_headers(tokens: dict) -> dict: def test_projects_list_smoke(client, admin_tokens, auth_headers):
return {"Authorization": f"Bearer {tokens['access_token']}"} response = client.get("/api/v1/projects", headers=auth_headers(admin_tokens))
def test_projects_list_smoke(client, admin_tokens):
response = client.get("/api/v1/projects", headers=_auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert "result" in payload assert "result" in payload
@ -16,24 +12,24 @@ def test_projects_list_smoke(client, admin_tokens):
assert isinstance(payload["result"], list) assert isinstance(payload["result"], list)
def _create_project(client, admin_tokens) -> tuple[int, str]: def _create_project(client, admin_tokens, auth_headers) -> tuple[int, str]:
name = f"PT_{uuid.uuid4().hex[:10]}" name = f"PT_{uuid.uuid4().hex[:10]}"
response = client.post( response = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": name, "year": 2026, "branch_id": 1}, json={"name": name, "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
return payload["project_id"], name return payload["project_id"], name
def _add_line(client, admin_tokens, project_id: int) -> int: def _add_line(client, admin_tokens, auth_headers, project_id: int) -> int:
for expense_item_id in range(1, 80): for expense_item_id in range(1, 80):
response = client.post( response = client.post(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line", f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
json={"expense_item_id": expense_item_id}, json={"expense_item_id": expense_item_id},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
if response.status_code != 200: if response.status_code != 200:
continue continue
@ -52,22 +48,22 @@ def _add_line(client, admin_tokens, project_id: int) -> int:
pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД") pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД")
def test_projects_write_smoke(client, admin_tokens): def test_projects_write_smoke(client, admin_tokens, auth_headers):
project_id, name = _create_project(client, admin_tokens) project_id, name = _create_project(client, admin_tokens, auth_headers)
upd_project_response = client.patch( upd_project_response = client.patch(
f"/api/v1/project/{project_id}", f"/api/v1/project/{project_id}",
json={"column": "name", "value": f"{name}_U"}, json={"column": "name", "value": f"{name}_U"},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert upd_project_response.status_code == 200 assert upd_project_response.status_code == 200
line_id = _add_line(client, admin_tokens, project_id) line_id = _add_line(client, admin_tokens, auth_headers, project_id)
upd_cell_response = client.patch( upd_cell_response = client.patch(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/cell", f"/api/v1/projects/{project_id}/report/2026/LIMIT/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100}, json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert upd_cell_response.status_code == 200 assert upd_cell_response.status_code == 200
assert isinstance(upd_cell_response.json(), list) assert isinstance(upd_cell_response.json(), list)
@ -80,26 +76,26 @@ def test_projects_write_smoke(client, admin_tokens):
{"line_id": line_id, "column": "q1.m3", "value": 300}, {"line_id": line_id, "column": "q1.m3", "value": 300},
] ]
}, },
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert upd_cells_response.status_code == 200 assert upd_cells_response.status_code == 200
assert isinstance(upd_cells_response.json(), list) assert isinstance(upd_cells_response.json(), list)
del_line_response = client.delete( del_line_response = client.delete(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}", f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert del_line_response.status_code == 200 assert del_line_response.status_code == 200
def test_projects_write_invalid_report_type_error_shape(client, admin_tokens): def test_projects_write_invalid_report_type_error_shape(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens) project_id, _ = _create_project(client, admin_tokens, auth_headers)
line_id = _add_line(client, admin_tokens, project_id) line_id = _add_line(client, admin_tokens, auth_headers, project_id)
response = client.patch( response = client.patch(
f"/api/v1/projects/{project_id}/report/2026/BAD_TYPE/cell", f"/api/v1/projects/{project_id}/report/2026/BAD_TYPE/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100}, json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()
@ -108,14 +104,14 @@ def test_projects_write_invalid_report_type_error_shape(client, admin_tokens):
assert isinstance(payload["message"], str) assert isinstance(payload["message"], str)
def test_projects_write_report_not_found_error_shape(client, admin_tokens): def test_projects_write_report_not_found_error_shape(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens) project_id, _ = _create_project(client, admin_tokens, auth_headers)
line_id = _add_line(client, admin_tokens, project_id) line_id = _add_line(client, admin_tokens, auth_headers, project_id)
response = client.patch( response = client.patch(
f"/api/v1/projects/{project_id}/report/2099/LIMIT/cell", f"/api/v1/projects/{project_id}/report/2099/LIMIT/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100}, json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()

View File

@ -1,39 +1,39 @@
import uuid import uuid
import uuid def test_users_me(client, admin_tokens, auth_headers):
response = client.get("/api/v1/users/me", headers=auth_headers(admin_tokens))
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.status_code == 200
assert response.json()["username"] == "admin" assert response.json()["username"] == "admin"
# Задокуменировано что бы не было переполнения бд, так как юзер удаляется только логически
# def test_users_create_smoke(client, admin_tokens, auth_headers):
# suffix = uuid.uuid4().hex[:8]
# username = f"user_{suffix}"
# # create_payload = {
# # "email": f"{username}@example.com",
# # "username": username,
# # "password": "pass123",
# # "full_name": "User One",
# # "role_id": 2,
# # }
# created = client.put(
# "/api/v1/users/",
# json={
# "email": f"{username}@example.com",
# "username": username,
# "password": "pass123",
# "full_name": "User One",
# "role_id": 2,
# },
# headers=auth_headers(admin_tokens),
# )
# assert created.status_code == 200
# assert created.json()["result"]["username"] == username
def test_users_create_smoke(client, admin_tokens): # # Что бы не было переполнения бд
suffix = uuid.uuid4().hex[:8] # deleted = client.delete(
username = f"user_{suffix}" # f"/api/v1/users/{created.json()['result']['id']}",
create_payload = { # headers=auth_headers(admin_tokens),
"email": f"{username}@example.com", # )
"username": username, # assert deleted.status_code == 200
"password": "pass123",
"full_name": "User One",
"role_id": 2,
}
created = client.put(
"/api/v1/users/",
json={
"email": f"{username}@example.com",
"username": username,
"password": "pass123",
"full_name": "User One",
"role_id": 2,
},
headers=_auth_headers(admin_tokens),
)
assert created.status_code == 200
assert created.json()["result"]["username"] == username

View File

@ -0,0 +1,68 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.auditlog_repository import AuditLogRepository
def _result_with_scalars(items):
result = MagicMock()
scalars = MagicMock()
scalars.first.return_value = items[0] if items else None
scalars.all.return_value = items
result.scalars.return_value = scalars
return result
@pytest.fixture
def mock_db():
return AsyncMock(spec=AsyncSession)
@pytest.fixture
def repository(mock_db):
return AuditLogRepository(mock_db)
@pytest.mark.asyncio
async def test_get_returns_audit_log(repository, mock_db):
row = MagicMock(id=10, user_id=1, event_type="WRITE")
mock_db.execute.return_value = _result_with_scalars([row])
result = await repository.get(10)
assert result is row
mock_db.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_returns_none_for_missing_id(repository, mock_db):
mock_db.execute.return_value = _result_with_scalars([])
result = await repository.get(999999)
assert result is None
@pytest.mark.asyncio
async def test_get_all_applies_filters(repository, mock_db):
row = MagicMock(id=11, user_id=2, org_unit_id=7, event_type="UPDATE")
mock_db.execute.return_value = _result_with_scalars([row])
result = await repository.get_all(
limit=10,
offset=5,
user_id=2,
org_unit_id=7,
task_id=3,
form_id=4,
event_type="UPDATE",
date_from=datetime(2026, 1, 1, tzinfo=timezone.utc),
date_to=datetime(2026, 12, 31, tzinfo=timezone.utc),
)
assert result == [row]
mock_db.execute.assert_awaited_once()

View File

@ -0,0 +1,91 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.core.errors import AccessDeniedException
from src.db.models.role import UserRoleEnum
from src.services.auditlog_service import AuditLogService
@pytest.fixture
def service_with_mock_repo(monkeypatch):
mock_repo = MagicMock()
mock_repo.get = AsyncMock()
mock_repo.get_all = AsyncMock()
repo_cls = MagicMock(return_value=mock_repo)
monkeypatch.setattr("src.services.auditlog_service.AuditLogRepository", repo_cls)
service = AuditLogService(AsyncMock())
return service, mock_repo
@pytest.fixture
def admin_user():
return SimpleNamespace(
id=1,
role_id=UserRoleEnum.ADMIN.value,
email="admin@example.com",
)
@pytest.fixture
def non_admin_user():
return SimpleNamespace(
id=2,
role_id=UserRoleEnum.EXECUTOR_DFIP.value,
email="executor@example.com",
)
@pytest.mark.asyncio
async def test_get_all_allowed_for_admin(service_with_mock_repo, admin_user):
service, mock_repo = service_with_mock_repo
mock_row = SimpleNamespace(id=10)
mock_repo.get_all.return_value = [mock_row]
result = await service.get_all(admin_user, org_unit_id=77, event_type="form")
assert result == [mock_row]
mock_repo.get_all.assert_awaited_once_with(
limit=None,
offset=None,
user_id=None,
org_unit_id=77,
task_id=None,
form_id=None,
event_type="form",
date_from=None,
date_to=None,
)
@pytest.mark.asyncio
async def test_get_all_denied_for_non_admin(service_with_mock_repo, non_admin_user):
service, mock_repo = service_with_mock_repo
with pytest.raises(AccessDeniedException):
await service.get_all(non_admin_user)
mock_repo.get_all.assert_not_called()
def test_orm_log_to_response_maps_entity_and_entity_id(service_with_mock_repo):
service, _ = service_with_mock_repo
log = SimpleNamespace(
id=55,
user_id=7,
event="UPDATE",
event_type="fallback_entity",
event_dt="2026-05-19T12:00:00Z",
event_data={"entity_type": "form_line", "entity_id": "42", "x": 1},
user=None,
)
dto = service.orm_log_to_response(log)
assert dto.entity == "form_line"
assert dto.entity_id == 42
assert dto.action == "UPDATE"
assert dto.user_id == 7