Merge pull request 'Логи' (#12) from future/AURORA-1012 into test

Reviewed-on: #12
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
This commit is contained in:
Raykov-MS 2026-05-21 10:25:21 +03:00
commit 06ad5e0e93
15 changed files with 750 additions and 163 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

@ -1,12 +1,13 @@
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
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(users.router)
api_router.include_router(admin.router)
api_router.include_router(audit.router)
api_router.include_router(forms.router)
api_router.include_router(projects.router)
api_router.include_router(form_phases.router)

View File

@ -105,6 +105,11 @@ class Settings(BaseSettings):
description="Размер батча для очистки аудита",
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(
env_file=".env",

View File

@ -1,6 +1,6 @@
from datetime import datetime
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"]
@ -280,3 +280,49 @@ class FormPhaseUpdate(BaseModel):
column_keys: list[str] | None = None
opens_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
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from contextlib import suppress
from fastapi import FastAPI, Response
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
async def lifespan(app: FastAPI):
cleanup_task: asyncio.Task | None = None
if not settings.DEBUG:
await create_tables()
ProtectedSettings(
@ -38,9 +104,14 @@ async def lifespan(app: FastAPI):
APP_NAME=settings.APP_NAME,
),
)
cleanup_task = asyncio.create_task(_audit_cleanup_loop())
try:
yield
finally:
if cleanup_task:
cleanup_task.cancel()
with suppress(asyncio.CancelledError):
await cleanup_task
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

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

@ -55,6 +55,13 @@ def admin_tokens(client, admin_password: str):
return response.json()
@pytest.fixture
def auth_headers():
def _build(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
return _build
@pytest.fixture
def isp_tokens(client, isp_password: str):
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
def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
def _pick_form_and_sheet(client, admin_tokens) -> tuple[int, str, str | None]:
response = client.get("/api/v1/form/", headers=_auth_headers(admin_tokens))
def _pick_form_and_sheet(client, admin_tokens, auth_headers) -> tuple[int, str, str | None]:
response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
assert response.status_code == 200
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(
f"/api/v1/form/{form_id}/sheets",
headers=_auth_headers(admin_tokens),
headers=auth_headers(admin_tokens),
)
if sheets_response.status_code != 200:
continue
@ -36,9 +32,9 @@ def _pick_form_and_sheet(client, admin_tokens) -> tuple[int, str, str | None]:
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]:
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
forms = response.json().get("result") or []
for form in forms:
@ -49,7 +45,7 @@ def _pick_form_with_sheet(
continue
sheets_response = client.get(
f"/api/v1/form/{form_id}/sheets",
headers=_auth_headers(admin_tokens),
headers=auth_headers(admin_tokens),
)
if sheets_response.status_code != 200:
continue
@ -59,7 +55,9 @@ def _pick_form_with_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}"
params = []
if direction:
@ -67,7 +65,7 @@ def _pick_input_line_id(client, admin_tokens, form_id: int, sheet: str, directio
if 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
rows = response.json().get("result") or []
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")
def test_backend_calls_v_form_view_via_sheet_endpoint(client, admin_tokens):
form_id, sheet, direction = _pick_form_and_sheet(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, auth_headers)
url = f"/api/v1/form/{form_id}/sheet/{sheet}"
if 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
payload = response.json()
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
def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(client, admin_tokens):
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens)
line_id = _pick_input_line_id(client, admin_tokens, form_id, sheet, direction)
def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(
client, admin_tokens, auth_headers
):
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"
if direction:
@ -105,7 +107,7 @@ def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(client, admin
response = client.patch(
url,
json={"line_id": line_id, "column": "bad.scope", "value": 1},
headers=_auth_headers(admin_tokens),
headers=auth_headers(admin_tokens),
)
# 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)
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(
"/api/v1/projects",
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
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(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
json={"expense_item_id": 1},
headers=_auth_headers(admin_tokens),
headers=auth_headers(admin_tokens),
)
assert add_line.status_code == 200
rows = add_line.json()
assert isinstance(rows, list)
def test_forms_validation_invalid_sections_returns_400(client, admin_tokens):
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR")
def test_forms_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers):
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, auth_headers, "AHR")
response = client.get(
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
payload = response.json()
@ -151,22 +155,26 @@ def test_forms_validation_invalid_sections_returns_400(client, admin_tokens):
assert "message" in payload
def test_forms_validation_empty_sections_csv_is_ignored(client, admin_tokens):
form_id, sheet, direction = _pick_form_and_sheet(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, auth_headers)
url = f"/api/v1/form/{form_id}/sheet/{sheet}?sections= , , "
if 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
payload = response.json()
assert isinstance(payload.get("result"), list)
def test_forms_validation_direction_required_for_form1_ahr(client, admin_tokens):
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_1")
def test_forms_validation_direction_required_for_form1_ahr(
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(
f"/api/v1/form/{form_id}/sheet/{sheet}",
headers=_auth_headers(admin_tokens),
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
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()
def test_forms_validation_direction_case_sensitive(client, admin_tokens):
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_1")
def test_forms_validation_direction_case_sensitive(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(
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
def test_forms_validation_direction_forbidden_on_non_form1(client, admin_tokens):
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_2")
def test_forms_validation_direction_forbidden_on_non_form1(
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(
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
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()
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(
"/api/v1/projects",
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
project_id = int(create.json()["project_id"])
response = client.get(
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
payload = response.json()
@ -215,44 +229,44 @@ def test_form3_validation_invalid_sections_returns_400(client, admin_tokens):
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(
"/api/v1/projects",
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
project_id = int(create.json()["project_id"])
response = client.get(
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 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(
"/api/v1/projects",
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
project_id = int(create.json()["project_id"])
response = client.get(
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 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.
response = client.post(
"/api/v1/projects",
json={"name": "INVALID NAME", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens),
headers=auth_headers(admin_tokens),
)
assert response.status_code == 422
@ -271,38 +285,46 @@ def test_health_and_ready_endpoints(client):
assert "mv_expense_item_tree" in ready_payload
def test_admin_refresh_tree_requires_admin_role(client, admin_tokens):
admin_resp = client.post(
"/api/v1/admin/refresh-tree",
headers=_auth_headers(admin_tokens),
)
assert admin_resp.status_code in (200, 503)
uname = f"itest_non_admin_{uuid.uuid4().hex[:6]}"
create_user = client.put(
"/api/v1/users/",
json={
"email": f"{uname}@example.com",
"username": uname,
"password": "pass123",
"full_name": "Integration User",
"role_id": 2,
},
headers=_auth_headers(admin_tokens),
)
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:
pytest.skip("Не удалось залогинить non-admin пользователя в текущей БД")
user_tokens = login_resp.json()
non_admin_resp = client.post(
"/api/v1/admin/refresh-tree",
headers=_auth_headers(user_tokens),
)
assert non_admin_resp.status_code == 403
# Тест временно отключён: внутри создаётся пользователь.
# def test_admin_refresh_tree_requires_admin_role(client, admin_tokens, auth_headers):
# admin_resp = client.post(
# "/api/v1/admin/refresh-tree",
# headers=auth_headers(admin_tokens),
# )
# assert admin_resp.status_code in (200, 503)
#
# uname = f"itest_non_admin_{uuid.uuid4().hex[:6]}"
# create_user = client.put(
# "/api/v1/users/",
# json={
# "email": f"{uname}@example.com",
# "username": uname,
# "password": "pass123",
# "full_name": "Integration User",
# "role_id": 2,
# },
# headers=auth_headers(admin_tokens),
# )
# 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:
# pytest.skip("Не удалось залогинить non-admin пользователя в текущей БД")
# user_tokens = login_resp.json()
#
# non_admin_resp = client.post(
# "/api/v1/admin/refresh-tree",
# headers=auth_headers(user_tokens),
# )
# 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):

View File

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

View File

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

View File

@ -1,39 +1,39 @@
import uuid
import uuid
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))
def test_users_me(client, admin_tokens, auth_headers):
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_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]
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
# # Что бы не было переполнения бд
# deleted = client.delete(
# f"/api/v1/users/{created.json()['result']['id']}",
# headers=auth_headers(admin_tokens),
# )
# assert deleted.status_code == 200

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