Browse Source

Спринт 5: Трекер результатов

- Миграция 005: user_id в test_attempts (дефолт 1 = Гость)
- GET /api/attempts с фильтрами по тесту, дате и пагинацией
- Страница /tracker: таблица попыток, фильтры, пагинация
- Ссылка «Трекер» в шапке приложения

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dev-new-design-page-createtest
Aleksey Razorvin 2 months ago
parent
commit
fc684e7c7d
  1. 118
      DOC/ИТОГИ_2026-03-21.md
  2. 17
      DOC/СПРИНТЫ.md
  3. 72
      DOC/ШАГИ/ШАГ_2026-03-21_013.md
  4. 2
      README.md
  5. 31
      backend/alembic/versions/005_attempt_user.py
  6. 69
      backend/app/api/attempts.py
  7. 1
      backend/app/models/attempt.py
  8. 26
      backend/app/schemas/attempt.py
  9. 16
      frontend/src/App.tsx
  10. 33
      frontend/src/api/attempts.ts
  11. 145
      frontend/src/pages/Tracker/index.tsx

118
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`

17
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 (авторизация).
- [ ] Фронт: страница трекера
- Таблица: тест, версия, дата начала, дата завершения, результат, зачёт
- Фильтрация по тесту и дате
- Пагинация
--- ---

72
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

2
README.md

@ -47,7 +47,7 @@
| **2** | Прохождение теста + результаты и разбор ошибок | ✅ | | **2** | Прохождение теста + результаты и разбор ошибок | ✅ |
| **3** | Редактирование тестов + версионность | ✅ | | **3** | Редактирование тестов + версионность | ✅ |
| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ✅ | | **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ✅ |
| **5** | Трекер результатов | | | **5** | Трекер результатов | |
| **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ | | **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ |
| **7** | Уведомления в MAX | ⬜ | | **7** | Уведомления в MAX | ⬜ |

31
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")

69
backend/app/api/attempts.py

@ -1,8 +1,9 @@
import random import random
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.attempt import AttemptAnswer, TestAttempt
from app.models.test import Question, Test from app.models.test import Question, Test
from app.schemas.attempt import ( from app.schemas.attempt import (
AnswerForTest,
AnswerResult,
AttemptListItem,
AttemptListResponse,
AttemptResult, AttemptResult,
AttemptStart, AttemptStart,
AttemptStarted, AttemptStarted,
AttemptSubmitDto, AttemptSubmitDto,
AnswerForTest,
AnswerResult,
QuestionForTest, QuestionForTest,
QuestionResult, QuestionResult,
) )
# Временно: до Sprint 6 (авторизация) все попытки принадлежат гостю
GUEST_USER_ID = 1
GUEST_USER_NAME = "Гость"
router = APIRouter(prefix="/api/attempts", tags=["attempts"]) 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: if not test:
raise HTTPException(status_code=404, detail="Тест не найден") 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) db.add(attempt)
await db.commit() await db.commit()
await db.refresh(attempt) 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) @router.get("/{attempt_id}/result", response_model=AttemptResult)
async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)): async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)):
"""Получить результат завершённой попытки.""" """Получить результат завершённой попытки."""

1
backend/app/models/attempt.py

@ -12,6 +12,7 @@ class TestAttempt(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False) 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( started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )

26
backend/app/schemas/attempt.py

@ -87,3 +87,29 @@ class AttemptResult(BaseModel):
questions: list[QuestionResult] questions: list[QuestionResult]
model_config = {"from_attributes": True} 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

16
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 { 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 ruRU from 'antd/locale/ru_RU'
import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom' import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'
import AttemptResult from './pages/AttemptResult' import AttemptResult from './pages/AttemptResult'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import Tracker from './pages/Tracker'
import TestCreate from './pages/TestCreate' import TestCreate from './pages/TestCreate'
import TestDetail from './pages/TestDetail' import TestDetail from './pages/TestDetail'
import TestEdit from './pages/TestEdit' import TestEdit from './pages/TestEdit'
@ -35,14 +36,22 @@ function AppHeader() {
> >
QA Test App QA Test App
</span> </span>
<Space>
<Button
icon={<BarChartOutlined />}
type="text"
onClick={() => navigate('/tracker')}
>
Трекер
</Button>
<Button <Button
icon={<SettingOutlined />} icon={<SettingOutlined />}
type="text" type="text"
onClick={() => navigate('/settings')} onClick={() => navigate('/settings')}
title="Настройки"
> >
Настройки Настройки
</Button> </Button>
</Space>
</Header> </Header>
) )
} }
@ -62,6 +71,7 @@ export default function App() {
<Route path="/tests/:id/edit" element={<TestEdit />} /> <Route path="/tests/:id/edit" element={<TestEdit />} />
<Route path="/tests/:testId/take" element={<TestTake />} /> <Route path="/tests/:testId/take" element={<TestTake />} />
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} /> <Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
<Route path="/tracker" element={<Tracker />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</Content> </Content>

33
frontend/src/api/attempts.ts

@ -56,6 +56,36 @@ export interface AttemptResult {
questions: QuestionResult[] 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 = { export const attemptsApi = {
start: (test_id: number) => start: (test_id: number) =>
client.post<AttemptStarted>('/attempts', { test_id }), client.post<AttemptStarted>('/attempts', { test_id }),
@ -65,4 +95,7 @@ export const attemptsApi = {
getResult: (attempt_id: number) => getResult: (attempt_id: number) =>
client.get<AttemptResult>(`/attempts/${attempt_id}/result`), client.get<AttemptResult>(`/attempts/${attempt_id}/result`),
list: (params: AttemptListParams = {}) =>
client.get<AttemptListResponse>('/attempts', { params }),
} }

145
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<number | undefined>()
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<AttemptListItem> = [
{
title: 'Сотрудник',
dataIndex: 'user_name',
width: 120,
},
{
title: 'Тест',
key: 'test',
render: (_, r) => (
<span>
{r.test_title}{' '}
<Tag color="default" style={{ fontSize: 11 }}>
v{r.test_version}
</Tag>
</span>
),
},
{
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 ? (
<Space size={4}>
<CheckCircleTwoTone twoToneColor="#52c41a" />
<Tag color="success">Сдал</Tag>
</Space>
) : (
<Space size={4}>
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
<Tag color="error">Не сдал</Tag>
</Space>
)
},
},
]
return (
<div style={{ maxWidth: 1000, margin: '32px auto', padding: '0 24px' }}>
<Title level={2}>Трекер результатов</Title>
{/* Фильтры */}
<Space wrap style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="Все тесты"
style={{ width: 260 }}
value={testId}
onChange={(v) => { setTestId(v); setPage(1) }}
options={(testsData ?? []).map((t) => ({
value: t.id,
label: `${t.title} (v${t.version})`,
}))}
/>
<RangePicker
value={dateRange}
onChange={(v) => { setDateRange(v as [Dayjs, Dayjs] | null); setPage(1) }}
format="DD.MM.YYYY"
placeholder={['Дата от', 'Дата до']}
/>
<Button onClick={handleReset}>Сбросить</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
onChange: setPage,
showTotal: (total) => `Всего: ${total}`,
}}
/>
</div>
)
}
Loading…
Cancel
Save