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