Browse Source
- Миграция 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
11 changed files with 522 additions and 22 deletions
@ -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` |
||||||
@ -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 |
||||||
@ -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") |
||||||
@ -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…
Reference in new issue