Add audit read api and remove python audit writes

This commit is contained in:
Raykov-MS 2026-05-19 14:37:43 +03:00
parent 5cd7232eb2
commit bea8eb6240
15 changed files with 697 additions and 122 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 AuditLogListResponse, AuditLogQueryParams
from src.services.auditlog_service import AuditLogService
router = APIRouter()
@router.get(
"/audit-logs",
response_model=AuditLogListResponse,
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 = 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 AuditLogListResponse(result=result)

View File

@ -1,10 +1,11 @@
from fastapi import APIRouter
from src.api.v1 import auth, users, admin, forms, projects
from src.api.v1 import auth, users, admin, audit, forms, 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)

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
from pydantic import BaseModel, ConfigDict, EmailStr, Field
@ -250,3 +250,49 @@ class AddLineSchema(BaseModel):
justification: Optional[str] = None
contract_number: Optional[str] = None
contract_end_date: Optional[datetime] = 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,68 @@
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
# 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,
) -> Iterable[AuditLog]:
"""Получение всех записей аудита с опциональной фильтрацией."""
query = select(AuditLog)
if user_id is not None:
query = query.where(AuditLog.user_id == user_id)
if org_unit_id is not None:
query = query.where(AuditLog.org_unit_id == org_unit_id)
if task_id is not None:
query = query.where(AuditLog.task_id == task_id)
if form_id is not None:
query = query.where(AuditLog.form_id == form_id)
if event_type is not None:
query = query.where(AuditLog.event_type == event_type)
if date_from is not None:
query = query.where(AuditLog.event_dt >= date_from)
if date_to is not None:
query = query.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()

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,
) -> Iterable[AuditLog]:
"""Получение всех записей аудита с опциональной фильтрацией."""
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: Any) -> 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

@ -47,3 +47,11 @@ def admin_tokens(client, admin_password: str):
)
assert response.status_code == 200
return response.json()
@pytest.fixture
def auth_headers():
def _build(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
return _build

View File

@ -0,0 +1,66 @@
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,10 +285,10 @@ 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):
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),
headers=auth_headers(admin_tokens),
)
assert admin_resp.status_code in (200, 503)
@ -288,7 +302,7 @@ def test_admin_refresh_tree_requires_admin_role(client, admin_tokens):
"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 пользователя в текущей БД")
@ -300,10 +314,17 @@ def test_admin_refresh_tree_requires_admin_role(client, admin_tokens):
non_admin_resp = client.post(
"/api/v1/admin/refresh-tree",
headers=_auth_headers(user_tokens),
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):
response = client.post("/api/v1/admin/refresh-tree")

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,29 +1,22 @@
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):
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,
}
# 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={
@ -33,7 +26,14 @@ def test_users_create_smoke(client, admin_tokens):
"full_name": "User One",
"role_id": 2,
},
headers=_auth_headers(admin_tokens),
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