diff --git a/DOC/ИТОГИ_2026-03-21.md b/DOC/ИТОГИ_2026-03-21.md
new file mode 100644
index 0000000..77604f7
--- /dev/null
+++ b/DOC/ИТОГИ_2026-03-21.md
@@ -0,0 +1,118 @@
+# Итоги разработки — 21 марта 2026
+
+## Общая информация
+
+| Параметр | Значение |
+|----------|---------|
+| Дата | 21 марта 2026 |
+| Начало работы | 10:56 |
+| Текущее состояние | Спринты 1–5 завершены |
+| Затрачено времени | ~5 часов |
+| Коммитов | 26 |
+
+---
+
+## Хронология по коммитам
+
+| Время | Этап |
+|-------|------|
+| 10:56–11:41 | Подготовка: структура проекта, ТЗ v1.1, стек, план спринтов, README |
+| 12:05–12:46 | **Спринт 1** — инфраструктура + создание тестов |
+| 12:53–13:18 | **Спринт 2** — прохождение теста + результаты + UX |
+| 13:22–14:08 | **Спринт 3** — редактирование + версионирование |
+| 14:08–15:11 | **Спринт 4** — AI-помощник (DeepSeek) |
+| 15:11–16:00 | **Спринт 5** — трекер результатов |
+
+---
+
+## Что реализовано
+
+### Спринт 1 — Инфраструктура + создание тестов (~40 мин)
+- Docker Compose: PostgreSQL 16, FastAPI, React + Vite, Nginx
+- Alembic: миграция `001_init` (таблицы tests, questions, answers)
+- API: `POST /api/tests`, `GET /api/tests`, `GET /api/tests/{id}`
+- Фронт: создание теста, список тестов, просмотр теста
+- Исправлено 4 бага при тестировании (entrypoint, PYTHONPATH, nginx DNS, FastAPI docs URL)
+
+### Спринт 2 — Прохождение теста + результаты (~25 мин)
+- Миграция `002_attempts` (таблицы test_attempts, attempt_answers)
+- API: `POST /api/attempts`, `POST /api/attempts/{id}/submit`, `GET /api/attempts/{id}/result`
+- Прохождение: случайный порядок вопросов, таймер с автосабмитом, навигация назад
+- Страница результатов: балл, зачёт/незачёт, разбор ошибок по каждому вопросу
+- UX: разделение вида сотрудника (`/tests/:id`) и автора (`/tests/:id/edit`), выпадающее меню «⋯» в списке тестов
+
+### Спринт 3 — Редактирование + версионирование (~46 мин)
+- Миграция `003_test_versioning` (поле `parent_id` в tests)
+- API: `PUT /api/tests/{id}`, `GET /api/tests/{id}/versions`, `POST /api/tests/{id}/activate`
+- Логика: нет попыток → редактировать на месте; есть попытки → создать новую версию (`version+1`, `parent_id=old.id`)
+- Фронт: общий компонент `TestForm`, страница редактирования с историей версий и активацией
+- Исправлено 2 бага (FK cascade при bulk DELETE, отображение статуса «Активная»)
+
+### Спринт 4 — AI-помощник (DeepSeek) (~63 мин)
+- Миграция `004_settings` (таблица settings, key-value)
+- Страница `/settings`: ввод API ключа DeepSeek + кнопка «Проверить подключение»
+- 6 AI-эндпоинтов:
+ - `POST /api/llm/check` — проверка подключения
+ - `POST /api/llm/generate` — генерация вопросов по теме (= название теста)
+ - `POST /api/llm/improve` — улучшение формулировки вопроса + ответов
+ - `POST /api/llm/distractors` — генерация дистракторов
+ - `POST /api/llm/review` — рецензия всего теста
+ - `POST /api/llm/improve_all` — улучшение всего теста целиком
+- В форме теста: 4 AI-кнопки с модалами и постатейным сравнением (галочки для применения изменений)
+- Шапка приложения с навигацией
+
+### Спринт 5 — Трекер результатов (~49 мин)
+- Миграция `005_attempt_user` (поле `user_id` в test_attempts, дефолт 1 = «Гость»)
+- API: `GET /api/attempts` с фильтрами (test_id, date_from, date_to) и пагинацией
+- Страница `/tracker`: таблица всех попыток, фильтр по тесту и диапазону дат, пагинация
+- Колонки: Сотрудник / Тест + версия / Начало / Завершение / Результат / Зачёт
+
+---
+
+## Объём кода
+
+| Слой | Файлы |
+|------|-------|
+| Backend — модели | `test.py`, `attempt.py`, `setting.py` |
+| Backend — схемы | `test.py`, `attempt.py`, `setting.py` |
+| Backend — API роутеры | `tests.py`, `attempts.py`, `llm.py`, `settings.py` |
+| Backend — сервисы | `llm.py` |
+| Backend — миграции | `001` → `005` |
+| Frontend — страницы | TestList, TestCreate, TestDetail, TestEdit, TestTake, AttemptResult, Settings, Tracker |
+| Frontend — компоненты | `TestForm` (с AI-функциями) |
+| Frontend — API клиенты | `tests.ts`, `attempts.ts`, `llm.ts`, `settings.ts`, `client.ts` |
+| Документация | ТЗ v1.2, СТЕК, СПРИНТЫ, README, 12 файлов ШАГИ |
+| **Итого** | **~46 файлов** |
+
+---
+
+## Текущий статус спринтов
+
+| Спринт | Содержание | Статус |
+|--------|-----------|--------|
+| 1 | Инфраструктура + создание тестов | ✅ Завершён |
+| 2 | Прохождение теста + результаты | ✅ Завершён |
+| 3 | Редактирование + версионирование | ✅ Завершён |
+| 4 | AI-помощник (DeepSeek) | ✅ Завершён |
+| 5 | Трекер результатов | ✅ Завершён |
+| 6 | Авторизация, роли, подразделения | ⬜ Следующий |
+| 7 | Уведомления в MAX | ⬜ Запланирован |
+
+---
+
+## Технический долг перед Спринтом 6
+
+- `user_id = 1` (Гость) в `test_attempts` — заменить на ID авторизованного пользователя
+- `GUEST_USER_NAME = "Гость"` в `api/attempts.py` — заменить на JOIN с таблицей `users`
+- Все эндпоинты открыты без авторизации — добавить JWT Middleware
+
+---
+
+## Запуск проекта
+
+```bash
+docker compose up --build
+```
+
+Приложение: `http://localhost`
+API документация: `http://localhost/api/docs`
diff --git a/DOC/СПРИНТЫ.md b/DOC/СПРИНТЫ.md
index 68adb5d..220c636 100644
--- a/DOC/СПРИНТЫ.md
+++ b/DOC/СПРИНТЫ.md
@@ -140,15 +140,20 @@
---
-## Спринт 5 — Трекер результатов
+## Спринт 5 — Трекер результатов ✅
**Результат:** Таблица всех попыток прохождения тестов.
+**Статус:** Завершён и протестирован вручную в браузере.
+
+- [x] Миграция `005`: поле `user_id` в `test_attempts` (дефолт 1 = «Гость»)
+- [x] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате, пагинацией)
+- [x] Фронт: страница `/tracker`
+ - Таблица: сотрудник, тест + версия, дата начала, дата завершения, результат, зачёт
+ - Фильтрация по тесту и диапазону дат
+ - Пагинация (20 записей на страницу)
+- [x] Ссылка «Трекер» в шапке приложения
-- [ ] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате)
-- [ ] Фронт: страница трекера
- - Таблица: тест, версия, дата начала, дата завершения, результат, зачёт
- - Фильтрация по тесту и дате
- - Пагинация
+**Примечание:** `user_id = 1` («Гость») — временно до Спринта 6 (авторизация).
---
diff --git a/DOC/ШАГИ/ШАГ_2026-03-21_013.md b/DOC/ШАГИ/ШАГ_2026-03-21_013.md
new file mode 100644
index 0000000..7aa2657
--- /dev/null
+++ b/DOC/ШАГИ/ШАГ_2026-03-21_013.md
@@ -0,0 +1,72 @@
+# ШАГ 013 — Спринт 5: Трекер результатов
+
+**Дата:** 2026-03-21
+**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
+
+---
+
+## Запрос
+
+> Оставляем текущий спринт. Введём user_id здесь вручную — пусть это будет гость.
+
+---
+
+## Реализовано
+
+Страница трекера всех попыток с фильтрацией и пагинацией. Все попытки пока принадлежат одному пользователю — «Гость» (user_id = 1).
+
+---
+
+## Новые файлы
+
+```
+backend/alembic/versions/005_attempt_user.py ← добавляет user_id в test_attempts
+frontend/src/pages/Tracker/index.tsx ← страница /tracker
+```
+
+## Изменённые файлы
+
+```
+backend/app/models/attempt.py ← поле user_id (default=1, server_default='1')
+backend/app/schemas/attempt.py ← AttemptListItem, AttemptListResponse
+backend/app/api/attempts.py ← GET /api/attempts + GUEST_USER_ID/NAME константы
+frontend/src/api/attempts.ts ← AttemptListItem, AttemptListResponse, list()
+frontend/src/App.tsx ← роут /tracker, кнопка «Трекер» в шапке
+```
+
+---
+
+## API эндпоинты (новые)
+
+| Метод | URL | Параметры | Описание |
+|-------|-----|-----------|----------|
+| GET | `/api/attempts` | test_id, date_from, date_to, page, page_size | Список завершённых попыток |
+
+---
+
+## Схема БД (изменено)
+
+```
+test_attempts
+ + user_id INTEGER NOT NULL DEFAULT 1
+```
+
+---
+
+## Технический долг (Sprint 6)
+
+- `user_id = 1` → заменить на ID из JWT токена
+- `GUEST_USER_NAME` → JOIN с таблицей users
+- Все эндпоинты открыты → добавить JWT Middleware
+
+---
+
+## Следующие шаги
+
+- [x] Спринт 1: Инфраструктура + Создание тестов
+- [x] Спринт 2: Прохождение теста + результат
+- [x] Спринт 3: Редактирование + версионность
+- [x] Спринт 4: AI-помощник (DeepSeek)
+- [x] Спринт 5: Трекер результатов
+- [ ] Спринт 6: Авторизация и роли
+- [ ] Спринт 7: Уведомления в MAX
diff --git a/README.md b/README.md
index c263261..3d21833 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@
| **2** | Прохождение теста + результаты и разбор ошибок | ✅ |
| **3** | Редактирование тестов + версионность | ✅ |
| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ✅ |
-| **5** | Трекер результатов | ⬜ |
+| **5** | Трекер результатов | ✅ |
| **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ |
| **7** | Уведомления в MAX | ⬜ |
diff --git a/backend/alembic/versions/005_attempt_user.py b/backend/alembic/versions/005_attempt_user.py
new file mode 100644
index 0000000..21db857
--- /dev/null
+++ b/backend/alembic/versions/005_attempt_user.py
@@ -0,0 +1,31 @@
+"""005_attempt_user
+
+Revision ID: 005
+Revises: 004
+Create Date: 2026-03-21
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "005"
+down_revision: Union[str, None] = "004"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "test_attempts",
+ sa.Column(
+ "user_id",
+ sa.Integer(),
+ nullable=False,
+ server_default="1",
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("test_attempts", "user_id")
diff --git a/backend/app/api/attempts.py b/backend/app/api/attempts.py
index ee5ea54..ffb3465 100644
--- a/backend/app/api/attempts.py
+++ b/backend/app/api/attempts.py
@@ -1,8 +1,9 @@
import random
from datetime import datetime, timezone
+from typing import Optional
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -10,16 +11,22 @@ from app.database import get_db
from app.models.attempt import AttemptAnswer, TestAttempt
from app.models.test import Question, Test
from app.schemas.attempt import (
+ AnswerForTest,
+ AnswerResult,
+ AttemptListItem,
+ AttemptListResponse,
AttemptResult,
AttemptStart,
AttemptStarted,
AttemptSubmitDto,
- AnswerForTest,
- AnswerResult,
QuestionForTest,
QuestionResult,
)
+# Временно: до Sprint 6 (авторизация) все попытки принадлежат гостю
+GUEST_USER_ID = 1
+GUEST_USER_NAME = "Гость"
+
router = APIRouter(prefix="/api/attempts", tags=["attempts"])
@@ -36,7 +43,7 @@ async def start_attempt(data: AttemptStart, db: AsyncSession = Depends(get_db)):
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
- attempt = TestAttempt(test_id=test.id, status="in_progress")
+ attempt = TestAttempt(test_id=test.id, status="in_progress", user_id=GUEST_USER_ID)
db.add(attempt)
await db.commit()
await db.refresh(attempt)
@@ -162,6 +169,58 @@ async def submit_attempt(
)
+@router.get("", response_model=AttemptListResponse)
+async def list_attempts(
+ test_id: Optional[int] = Query(None),
+ date_from: Optional[datetime] = Query(None),
+ date_to: Optional[datetime] = Query(None),
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ db: AsyncSession = Depends(get_db),
+):
+ """Трекер попыток: все завершённые попытки с фильтрацией и пагинацией."""
+ base = (
+ select(TestAttempt)
+ .options(selectinload(TestAttempt.test))
+ .where(TestAttempt.status == "finished")
+ )
+ if test_id is not None:
+ base = base.where(TestAttempt.test_id == test_id)
+ if date_from is not None:
+ base = base.where(TestAttempt.started_at >= date_from)
+ if date_to is not None:
+ base = base.where(TestAttempt.started_at <= date_to)
+
+ total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
+
+ rows_result = await db.execute(
+ base.order_by(TestAttempt.started_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ attempts_list = rows_result.scalars().all()
+
+ items = [
+ AttemptListItem(
+ id=a.id,
+ test_id=a.test_id,
+ test_title=a.test.title,
+ test_version=a.test.version,
+ user_id=a.user_id,
+ user_name=GUEST_USER_NAME, # TODO Sprint 6: заменить на JOIN с таблицей users
+ started_at=a.started_at,
+ finished_at=a.finished_at,
+ score=a.score,
+ correct_count=a.correct_count,
+ total_count=a.total_count,
+ passed=a.passed,
+ )
+ for a in attempts_list
+ ]
+
+ return AttemptListResponse(items=items, total=total, page=page, page_size=page_size)
+
+
@router.get("/{attempt_id}/result", response_model=AttemptResult)
async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)):
"""Получить результат завершённой попытки."""
diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py
index 7222203..4df0ab4 100644
--- a/backend/app/models/attempt.py
+++ b/backend/app/models/attempt.py
@@ -12,6 +12,7 @@ class TestAttempt(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
+ user_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py
index cc1cf88..e6b069d 100644
--- a/backend/app/schemas/attempt.py
+++ b/backend/app/schemas/attempt.py
@@ -87,3 +87,29 @@ class AttemptResult(BaseModel):
questions: list[QuestionResult]
model_config = {"from_attributes": True}
+
+
+# ── Трекер результатов ────────────────────────────────────────
+
+class AttemptListItem(BaseModel):
+ id: int
+ test_id: int
+ test_title: str
+ test_version: int
+ user_id: int
+ user_name: str
+ started_at: datetime
+ finished_at: Optional[datetime]
+ score: Optional[float]
+ correct_count: Optional[int]
+ total_count: Optional[int]
+ passed: Optional[bool]
+
+ model_config = {"from_attributes": True}
+
+
+class AttemptListResponse(BaseModel):
+ items: list[AttemptListItem]
+ total: int
+ page: int
+ page_size: int
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c8642c1..333537a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,11 +1,12 @@
-import { SettingOutlined } from '@ant-design/icons'
+import { BarChartOutlined, SettingOutlined } from '@ant-design/icons'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { Button, ConfigProvider, Layout } from 'antd'
+import { Button, ConfigProvider, Layout, Space } from 'antd'
import ruRU from 'antd/locale/ru_RU'
import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'
import AttemptResult from './pages/AttemptResult'
import Settings from './pages/Settings'
+import Tracker from './pages/Tracker'
import TestCreate from './pages/TestCreate'
import TestDetail from './pages/TestDetail'
import TestEdit from './pages/TestEdit'
@@ -35,14 +36,22 @@ function AppHeader() {
>
QA Test App
- }
- type="text"
- onClick={() => navigate('/settings')}
- title="Настройки"
- >
- Настройки
-
+
+ }
+ type="text"
+ onClick={() => navigate('/tracker')}
+ >
+ Трекер
+
+ }
+ type="text"
+ onClick={() => navigate('/settings')}
+ >
+ Настройки
+
+
)
}
@@ -62,6 +71,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/api/attempts.ts b/frontend/src/api/attempts.ts
index ff69e32..450485f 100644
--- a/frontend/src/api/attempts.ts
+++ b/frontend/src/api/attempts.ts
@@ -56,6 +56,36 @@ export interface AttemptResult {
questions: QuestionResult[]
}
+export interface AttemptListItem {
+ id: number
+ test_id: number
+ test_title: string
+ test_version: number
+ user_id: number
+ user_name: string
+ started_at: string
+ finished_at: string | null
+ score: number | null
+ correct_count: number | null
+ total_count: number | null
+ passed: boolean | null
+}
+
+export interface AttemptListResponse {
+ items: AttemptListItem[]
+ total: number
+ page: number
+ page_size: number
+}
+
+export interface AttemptListParams {
+ test_id?: number
+ date_from?: string
+ date_to?: string
+ page?: number
+ page_size?: number
+}
+
export const attemptsApi = {
start: (test_id: number) =>
client.post('/attempts', { test_id }),
@@ -65,4 +95,7 @@ export const attemptsApi = {
getResult: (attempt_id: number) =>
client.get(`/attempts/${attempt_id}/result`),
+
+ list: (params: AttemptListParams = {}) =>
+ client.get('/attempts', { params }),
}
diff --git a/frontend/src/pages/Tracker/index.tsx b/frontend/src/pages/Tracker/index.tsx
new file mode 100644
index 0000000..578db13
--- /dev/null
+++ b/frontend/src/pages/Tracker/index.tsx
@@ -0,0 +1,145 @@
+import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'
+import { useQuery } from '@tanstack/react-query'
+import { Button, DatePicker, Select, Space, Table, Tag, Typography } from 'antd'
+import type { ColumnsType } from 'antd/es/table'
+import dayjs, { Dayjs } from 'dayjs'
+import { useState } from 'react'
+
+import { AttemptListItem, attemptsApi } from '../../api/attempts'
+import { testsApi } from '../../api/tests'
+
+const { Title } = Typography
+const { RangePicker } = DatePicker
+
+export default function Tracker() {
+ const [testId, setTestId] = useState()
+ const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null)
+ const [page, setPage] = useState(1)
+ const pageSize = 20
+
+ const params = {
+ test_id: testId,
+ date_from: dateRange?.[0].startOf('day').toISOString(),
+ date_to: dateRange?.[1].endOf('day').toISOString(),
+ page,
+ page_size: pageSize,
+ }
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['attempts', params],
+ queryFn: () => attemptsApi.list(params).then((r) => r.data),
+ })
+
+ const { data: testsData } = useQuery({
+ queryKey: ['tests'],
+ queryFn: () => testsApi.list().then((r) => r.data),
+ })
+
+ const handleReset = () => {
+ setTestId(undefined)
+ setDateRange(null)
+ setPage(1)
+ }
+
+ const columns: ColumnsType = [
+ {
+ title: 'Сотрудник',
+ dataIndex: 'user_name',
+ width: 120,
+ },
+ {
+ title: 'Тест',
+ key: 'test',
+ render: (_, r) => (
+
+ {r.test_title}{' '}
+
+ v{r.test_version}
+
+
+ ),
+ },
+ {
+ title: 'Начало',
+ dataIndex: 'started_at',
+ width: 160,
+ render: (v: string) => dayjs(v).format('DD.MM.YYYY HH:mm'),
+ },
+ {
+ title: 'Завершение',
+ dataIndex: 'finished_at',
+ width: 160,
+ render: (v: string | null) => (v ? dayjs(v).format('DD.MM.YYYY HH:mm') : '—'),
+ },
+ {
+ title: 'Результат',
+ key: 'result',
+ width: 140,
+ render: (_, r) =>
+ r.correct_count != null && r.total_count != null
+ ? `${r.correct_count} / ${r.total_count} (${r.score?.toFixed(1)}%)`
+ : '—',
+ },
+ {
+ title: 'Зачёт',
+ dataIndex: 'passed',
+ width: 90,
+ render: (passed: boolean | null) => {
+ if (passed == null) return '—'
+ return passed ? (
+
+
+ Сдал
+
+ ) : (
+
+
+ Не сдал
+
+ )
+ },
+ },
+ ]
+
+ return (
+
+
Трекер результатов
+
+ {/* Фильтры */}
+
+
+
+
`Всего: ${total}`,
+ }}
+ />
+
+ )
+}