Compare commits
30 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
c1a38bfef8 | 2 weeks ago |
|
|
6cb2d527e1 | 2 weeks ago |
|
|
d7409c5fa4 | 2 weeks ago |
|
|
0977eb0c38 | 2 weeks ago |
|
|
8097672917 | 2 weeks ago |
|
|
c64b34edb7 | 2 weeks ago |
|
|
1103201ee3 | 2 weeks ago |
|
|
8df6077798 | 2 weeks ago |
|
|
60b9121a05 | 2 weeks ago |
|
|
6178e6cb5f | 2 weeks ago |
|
|
6b52bca55f | 2 weeks ago |
|
|
b2a3bda01b | 2 weeks ago |
|
|
2b5dc379e1 | 2 weeks ago |
|
|
98049244ae | 2 weeks ago |
|
|
f414f62f31 | 2 weeks ago |
|
|
4f788a9cf6 | 2 weeks ago |
|
|
4762f86187 | 2 weeks ago |
|
|
a7937c937b | 2 weeks ago |
|
|
d5f6abb5ad | 2 weeks ago |
|
|
5551202d6f | 2 weeks ago |
|
|
3d21110dd9 | 2 weeks ago |
|
|
cc23783e2a | 2 weeks ago |
|
|
8b17c5d3c4 | 2 weeks ago |
|
|
054376bca7 | 2 weeks ago |
|
|
d013a16e98 | 2 weeks ago |
|
|
15de1e9369 | 2 weeks ago |
|
|
52c8aff459 | 2 weeks ago |
|
|
7ff7d9b1d0 | 2 weeks ago |
|
|
c1406c4ed1 | 2 weeks ago |
|
|
c689013d20 | 2 weeks ago |
57 changed files with 4041 additions and 0 deletions
@ -0,0 +1,2 @@ |
|||||||
|
# База данных |
||||||
|
DATABASE_URL=postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test |
||||||
@ -0,0 +1,223 @@ |
|||||||
|
# План спринтов |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Статус:** Согласовано |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Принцип |
||||||
|
|
||||||
|
Каждый спринт — это готовое работающее приложение (frontend + backend), которое можно запустить локально командой `docker compose up` и протестировать вручную в браузере. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 1 — Инфраструктура + Создание тестов ✅ |
||||||
|
|
||||||
|
**Результат:** Поднят весь стек, можно зайти на страницу и создать тест. |
||||||
|
**Статус:** Завершён и протестирован вручную в браузере. |
||||||
|
|
||||||
|
### Инфраструктура |
||||||
|
- [x] Структура репозитория: `backend/`, `frontend/`, `nginx/`, `docker-compose.yml` |
||||||
|
- [x] `docker-compose.yml`: сервисы `db`, `backend`, `frontend`, `nginx` |
||||||
|
- [x] PostgreSQL: контейнер, volume для данных |
||||||
|
- [x] FastAPI: контейнер, `GET /api/health` → `{"status": "ok"}` |
||||||
|
- [x] Alembic: инициализирован, первая миграция (`001_init`) |
||||||
|
- [x] React + Vite: контейнер, базовая страница открывается в браузере |
||||||
|
- [x] Nginx: `/` → React SPA, `/api/` → FastAPI |
||||||
|
|
||||||
|
### Создание тестов (без авторизации) |
||||||
|
- [x] Модели БД: `Test`, `Question`, `Answer` |
||||||
|
- [x] API: `POST /api/tests` — создать тест с вопросами и ответами |
||||||
|
- [x] API: `GET /api/tests` — список тестов |
||||||
|
- [x] API: `GET /api/tests/{id}` — детали теста |
||||||
|
- [x] Фронт: страница создания теста (название, вопросы, варианты, настройки) |
||||||
|
- [x] Фронт: список тестов |
||||||
|
- [x] Фронт: страница просмотра теста |
||||||
|
|
||||||
|
**Настройки теста:** порог зачёта (%), таймер (опционально), разрешить возврат к предыдущему вопросу |
||||||
|
|
||||||
|
### Баги, найденные и исправленные при тестировании |
||||||
|
- [x] `permission denied` на `entrypoint.sh` — volume mount перекрывал `chmod +x` из Dockerfile → исправлено: `CMD ["bash", "entrypoint.sh"]` |
||||||
|
- [x] `No module named 'app'` в Alembic — Python не видел `/app` → исправлено: `ENV PYTHONPATH=/app` в Dockerfile |
||||||
|
- [x] `host not found in upstream "backend"` в nginx — nginx резолвил хост при старте, до поднятия backend → исправлено: Docker DNS resolver + `set $backend` |
||||||
|
- [x] `http://localhost/api/docs` → 404 — FastAPI отдавал docs по `/docs`, а не `/api/docs` → исправлено: явные `docs_url`, `redoc_url`, `openapi_url` в FastAPI |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 2 — Прохождение теста ✅ |
||||||
|
|
||||||
|
**Результат:** Можно выбрать тест из списка и пройти его, увидеть результат и разбор ошибок. |
||||||
|
**Статус:** Завершён и протестирован вручную в браузере. |
||||||
|
|
||||||
|
- [x] Модели БД: `TestAttempt`, `AttemptAnswer` |
||||||
|
- [x] API: `POST /api/attempts` — начать попытку (фиксируем время начала) |
||||||
|
- [x] API: `POST /api/attempts/{id}/submit` — завершить попытку, подсчитать результат |
||||||
|
- [x] API: `GET /api/attempts/{id}/result` — результат с разбором ошибок |
||||||
|
- [x] Фронт: страница прохождения теста |
||||||
|
- Случайный порядок вопросов |
||||||
|
- Таймер с обратным отсчётом (если задан) — автосабмит по истечении |
||||||
|
- Навигация назад (если разрешена настройкой теста) |
||||||
|
- [x] Фронт: страница результата |
||||||
|
- Балл и процент |
||||||
|
- Сдал / Не сдал (относительно порога) |
||||||
|
- Разбор ошибок: вопрос, ответ сотрудника, правильный ответ |
||||||
|
- [x] Фронт: кнопка «Пройти тест» прямо в строке таблицы списка тестов |
||||||
|
|
||||||
|
### Доработки после тестирования |
||||||
|
- [x] Страница теста разделена на два вида: |
||||||
|
- `/tests/:id` — вид сотрудника: вопросы и варианты ответов без отметок правильных |
||||||
|
- `/tests/:id/edit` — вид автора: правильные ответы отмечены, жёлтый баннер, кнопка «Редактировать» (задизаблена до Спринта 4) |
||||||
|
- [x] Список тестов: три кнопки действий заменены на выпадающее меню «⋯» — колонка с названием стала полноширинной |
||||||
|
|
||||||
|
### Баги, найденные и исправленные при тестировании |
||||||
|
- [x] «Не удалось загрузить тест» × 2 при нажатии «Пройти тест» — миграция `002_attempts` не применилась, т.к. `--reload` перезапускает только код приложения, но не `entrypoint.sh` → исправлено: `docker compose restart backend` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 3 — Редактирование теста + версионность ✅ |
||||||
|
|
||||||
|
**Результат:** Тест можно редактировать. Если тест уже проходили — создаётся новая версия, старая сохраняется для истории. |
||||||
|
**Статус:** Завершён и протестирован вручную в браузере. |
||||||
|
|
||||||
|
### Backend |
||||||
|
- [x] Миграция `003`: добавить поле `parent_id` в таблицу `tests` |
||||||
|
- [x] `PUT /api/tests/{id}` — редактировать тест: |
||||||
|
- Нет попыток → обновить на месте |
||||||
|
- Есть попытки → создать новый тест (`version + 1`, `parent_id = id`), вернуть `{test, is_new_version: true}` |
||||||
|
- [x] `GET /api/tests` — показывать только активные версии (`is_active = True`) |
||||||
|
- [x] `GET /api/tests/{id}/versions` — цепочка всех версий теста |
||||||
|
- [x] `POST /api/tests/{id}/activate` — сделать версию активной (деактивирует остальные в цепочке) |
||||||
|
|
||||||
|
### Frontend |
||||||
|
- [x] Страница `/tests/:id/edit` разделена на режим просмотра и режим редактирования |
||||||
|
- [x] Форма редактирования с предзаполненными данными (общий компонент `TestForm`) |
||||||
|
- [x] При сохранении с новой версией — редирект + уведомление «Создана новая версия v2» |
||||||
|
- [x] Кнопка «← К просмотру теста» в форме редактирования |
||||||
|
- [x] Секция «История версий»: таблица с версиями, статусом, датой, кнопкой «Сделать активной» |
||||||
|
- [x] Активная версия — единственная видимая в списке тестов |
||||||
|
|
||||||
|
### Баги, найденные и исправленные при тестировании |
||||||
|
- [x] `ForeignKeyViolationError` при сохранении — bulk `DELETE questions` не каскадирует на `answers` → исправлено: сначала удаляем `answers`, потом `questions` |
||||||
|
- [x] Обе версии показывались «Активными» при создании до введения логики деактивации → исправлено: кнопка «Сделать активной» в шапке и в строке таблицы |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 4 — AI-помощник (DeepSeek) |
||||||
|
|
||||||
|
**Результат:** При создании и редактировании теста доступен AI-ассистент на базе DeepSeek. Ключ API настраивается через страницу настроек. |
||||||
|
|
||||||
|
### Страница настроек (`/settings`) |
||||||
|
- [ ] Модель БД: `Setting` (key-value, ключ `deepseek_api_key`) |
||||||
|
- [ ] Миграция `004` |
||||||
|
- [ ] API: `GET /api/settings/{key}`, `PUT /api/settings/{key}` |
||||||
|
- [ ] API: `POST /api/llm/check` — проверить подключение (тестовый запрос к DeepSeek) |
||||||
|
- [ ] Фронт: страница `/settings` — поле для ввода ключа + кнопка «Проверить подключение» |
||||||
|
|
||||||
|
### AI-функции в форме создания/редактирования теста |
||||||
|
- [ ] API: `POST /api/llm/generate` — сгенерировать вопросы и ответы по теме |
||||||
|
- [ ] API: `POST /api/llm/improve` — улучшить формулировку вопроса |
||||||
|
- [ ] API: `POST /api/llm/distractors` — добавить варианты-дистракторы к вопросу |
||||||
|
- [ ] API: `POST /api/llm/review` — проверить качество всего теста |
||||||
|
|
||||||
|
### Интеграция в UI |
||||||
|
- [ ] Кнопка «Сгенерировать с AI» на странице создания теста — вводишь тему, получаешь готовый набор вопросов |
||||||
|
- [ ] Кнопка «✨» рядом с каждым вопросом — улучшить формулировку |
||||||
|
- [ ] Кнопка «+ Дистракторы» рядом с каждым вопросом — дополнить неправильные варианты |
||||||
|
- [ ] Кнопка «Проверить тест» — AI анализирует весь тест и выдаёт рекомендации |
||||||
|
- [ ] Ссылка на страницу `/settings` в шапке приложения |
||||||
|
|
||||||
|
### Технические детали |
||||||
|
- DeepSeek API совместим с форматом OpenAI — используем библиотеку `openai` с `base_url=https://api.deepseek.com` |
||||||
|
- Модель: `deepseek-chat` |
||||||
|
- Ключ хранится в таблице `settings`, передаётся из бэкенда — фронт ключ не видит |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 5 — Трекер результатов |
||||||
|
|
||||||
|
**Результат:** Таблица всех попыток прохождения тестов. |
||||||
|
|
||||||
|
- [ ] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате) |
||||||
|
- [ ] Фронт: страница трекера |
||||||
|
- Таблица: тест, версия, дата начала, дата завершения, результат, зачёт |
||||||
|
- Фильтрация по тесту и дате |
||||||
|
- Пагинация |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 6 — Авторизация и управление пользователями |
||||||
|
|
||||||
|
**Результат:** Вход по логину/паролю, роли ограничивают доступ. Можно создавать сотрудников и подразделения. |
||||||
|
|
||||||
|
### Авторизация |
||||||
|
- [ ] Модели БД: `User`, `Department` |
||||||
|
- [ ] API: `POST /api/auth/login` → JWT access token |
||||||
|
- [ ] API: `POST /api/auth/logout` |
||||||
|
- [ ] API: `GET /api/auth/me` |
||||||
|
- [ ] Middleware: проверка JWT на защищённых эндпоинтах |
||||||
|
- [ ] Фронт: страница входа |
||||||
|
- [ ] Фронт: защищённые роуты (редирект на логин если нет токена) |
||||||
|
|
||||||
|
### Роли и права |
||||||
|
|
||||||
|
| Роль | Тесты | Трекер | |
||||||
|
|------|-------|--------| |
||||||
|
| HR-менеджер / Директор | Создаёт и редактирует все тесты | Вся клиника | |
||||||
|
| Руководитель подразделения | Создаёт и редактирует свои тесты | Только свой отдел | |
||||||
|
| Сотрудник | Проходит назначенные тесты | Только свои результаты | |
||||||
|
|
||||||
|
### Управление пользователями |
||||||
|
- [ ] API: CRUD подразделений |
||||||
|
- [ ] API: CRUD пользователей (создание, редактирование, деактивация) |
||||||
|
- [ ] Фронт: страница управления подразделениями (HR) |
||||||
|
- [ ] Фронт: страница управления сотрудниками (HR / руководитель) |
||||||
|
|
||||||
|
### Назначение тестов |
||||||
|
- [ ] Модели БД: `TestAssignment` |
||||||
|
- [ ] API: `POST /api/assignments` — назначить тест (получатели, дедлайн, кол-во попыток) |
||||||
|
- [ ] Фронт: форма назначения теста |
||||||
|
- [ ] Фронт: дашборд сотрудника — список назначенных тестов со статусами (`Не начат`, `В процессе`, `Завершён`, `Просрочен`) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 7 — Уведомления в MAX |
||||||
|
|
||||||
|
|
||||||
|
**Результат:** Сотрудники получают уведомления в мессенджер MAX. |
||||||
|
|
||||||
|
- [ ] Изучить документацию MAX API |
||||||
|
- [ ] Реализовать сервис уведомлений в backend |
||||||
|
- [ ] Уведомление при назначении теста сотруднику |
||||||
|
- [ ] Уведомление за N дней до дедлайна (настраивается) |
||||||
|
- [ ] Поле `max_user_id` в профиле пользователя |
||||||
|
- [ ] Фронт: в профиле пользователя — поле для MAX ID |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Структура репозитория (целевая после Спринта 1) |
||||||
|
|
||||||
|
``` |
||||||
|
qa_test_app/ |
||||||
|
├── backend/ |
||||||
|
│ ├── app/ |
||||||
|
│ │ ├── api/ ← роутеры FastAPI |
||||||
|
│ │ ├── models/ ← SQLAlchemy модели |
||||||
|
│ │ ├── schemas/ ← Pydantic схемы |
||||||
|
│ │ ├── services/ ← бизнес-логика |
||||||
|
│ │ └── main.py |
||||||
|
│ ├── alembic/ |
||||||
|
│ ├── Dockerfile |
||||||
|
│ └── requirements.txt |
||||||
|
├── frontend/ |
||||||
|
│ ├── src/ |
||||||
|
│ │ ├── api/ ← Axios + TanStack Query |
||||||
|
│ │ ├── components/ ← переиспользуемые компоненты |
||||||
|
│ │ ├── pages/ ← страницы |
||||||
|
│ │ └── main.tsx |
||||||
|
│ ├── Dockerfile |
||||||
|
│ └── package.json |
||||||
|
├── nginx/ |
||||||
|
│ └── nginx.conf |
||||||
|
├── docker-compose.yml |
||||||
|
└── DOC/ |
||||||
|
``` |
||||||
@ -0,0 +1,121 @@ |
|||||||
|
# Технологический стек |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Статус:** Согласовано |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Архитектура |
||||||
|
|
||||||
|
``` |
||||||
|
┌─────────────────────────────────────────────┐ |
||||||
|
│ Nginx │ |
||||||
|
│ / → React SPA (статика) │ |
||||||
|
│ /api/ → FastAPI backend │ |
||||||
|
└────────────────────┬────────────────────────┘ |
||||||
|
│ Docker network |
||||||
|
┌──────────┴──────────┐ |
||||||
|
│ │ |
||||||
|
┌─────▼──────┐ ┌──────▼─────┐ |
||||||
|
│ FastAPI │ │ React SPA │ |
||||||
|
│ (backend) │ │ (frontend)│ |
||||||
|
└─────┬──────┘ └────────────┘ |
||||||
|
│ |
||||||
|
┌─────▼──────┐ |
||||||
|
│ PostgreSQL │ |
||||||
|
└────────────┘ |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Backend |
||||||
|
|
||||||
|
| Компонент | Технология | Версия | |
||||||
|
|-----------|-----------|--------| |
||||||
|
| Язык | Python | 3.12+ | |
||||||
|
| Фреймворк | FastAPI | 0.115+ | |
||||||
|
| ORM | SQLAlchemy (async) | 2.0+ | |
||||||
|
| Миграции | Alembic | latest | |
||||||
|
| База данных | PostgreSQL | 16+ | |
||||||
|
| Валидация | Pydantic v2 | 2.x | |
||||||
|
| Аутентификация | JWT (python-jose) | latest | |
||||||
|
| Хэширование паролей | passlib + bcrypt | latest | |
||||||
|
| ASGI-сервер | Uvicorn | latest | |
||||||
|
|
||||||
|
### Почему FastAPI |
||||||
|
- Автоматическая генерация OpenAPI/Swagger документации — джуниор сразу видит API |
||||||
|
- Async из коробки — правильные привычки с первого проекта |
||||||
|
- Pydantic-схемы = валидация + сериализация в одном месте |
||||||
|
- Активное сообщество, отличная документация на русском |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Frontend |
||||||
|
|
||||||
|
| Компонент | Технология | Версия | |
||||||
|
|-----------|-----------|--------| |
||||||
|
| Фреймворк | React | 18+ | |
||||||
|
| Язык | TypeScript | 5.x | |
||||||
|
| Сборщик | Vite | 5.x | |
||||||
|
| UI-библиотека | Ant Design (antd) | 5.x | |
||||||
|
| Роутинг | React Router | v6 | |
||||||
|
| Серверный стейт | TanStack Query (React Query) | v5 | |
||||||
|
| HTTP-клиент | Axios | latest | |
||||||
|
|
||||||
|
### Почему Ant Design |
||||||
|
- Готовые компоненты: таблицы, формы, прогрессбары, таймеры — сэкономит спринты |
||||||
|
- Хорошо подходит для административных интерфейсов |
||||||
|
- Поддержка русской локализации |
||||||
|
|
||||||
|
### Почему TanStack Query |
||||||
|
- Учит джуниора правильной работе с серверным состоянием |
||||||
|
- Кэширование, loading/error состояния, инвалидация — всё из коробки |
||||||
|
- Убирает необходимость в Redux для большинства задач |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Инфраструктура |
||||||
|
|
||||||
|
| Компонент | Технология | |
||||||
|
|-----------|-----------| |
||||||
|
| Контейнеризация | Docker + Docker Compose | |
||||||
|
| Реверс-прокси | Nginx | |
||||||
|
| ОС сервера | Linux (Ubuntu/Debian) | |
||||||
|
|
||||||
|
### Структура Docker Compose |
||||||
|
|
||||||
|
```yaml |
||||||
|
services: |
||||||
|
db: # PostgreSQL |
||||||
|
backend: # FastAPI + Uvicorn |
||||||
|
frontend: # React (build) / Vite dev server |
||||||
|
nginx: # Reverse proxy |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Инструменты разработки |
||||||
|
|
||||||
|
| Назначение | Инструмент | |
||||||
|
|-----------|-----------| |
||||||
|
| Линтер Python | Ruff | |
||||||
|
| Форматтер Python | Black | |
||||||
|
| Линтер/форматтер JS | ESLint + Prettier | |
||||||
|
| API-тестирование | Swagger UI (встроен в FastAPI) | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Уведомления (последний спринт) |
||||||
|
|
||||||
|
- Канал: мессенджер **MAX** |
||||||
|
- Реализация: отдельный сервис/модуль в backend |
||||||
|
- Интеграция: MAX API (изучить документацию перед спринтом) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Вне scope (не используем) |
||||||
|
|
||||||
|
- Redis / очереди задач (Celery) — не нужны для данного масштаба |
||||||
|
- GraphQL — REST достаточно |
||||||
|
- Kubernetes — Docker Compose достаточно для 50–200 пользователей |
||||||
|
- SSR (Next.js) — не нужен, SPA достаточно |
||||||
@ -0,0 +1,163 @@ |
|||||||
|
# Техническое задание |
||||||
|
## Система тестирования сотрудников клиники |
||||||
|
|
||||||
|
**Версия:** 1.2 |
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Статус:** Согласовано |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1. Назначение системы |
||||||
|
|
||||||
|
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. Руководители подразделений и HR-менеджер создают тесты и назначают их сотрудникам. Сотрудники проходят тесты в браузере. Система фиксирует все попытки и результаты. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 2. Роли и права доступа |
||||||
|
|
||||||
|
| Роль | Кто | Создаёт тесты | Назначает тесты | Видит результаты | |
||||||
|
|------|-----|:---:|:---:|:---:| |
||||||
|
| **HR-менеджер** | Руководитель службы HR, Директор клиники | ✅ | Всем сотрудникам клиники | Всех сотрудников | |
||||||
|
| **Руководитель подразделения** | Главный врач, рук. службы администраторов и др. | ✅ | Только своему подразделению | Только своего подразделения | |
||||||
|
| **Сотрудник** | Все остальные работники | ❌ | ❌ | Только свои | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 3. Авторизация |
||||||
|
|
||||||
|
- Вход по логину и паролю |
||||||
|
- Учётные записи создаются администратором системы вручную |
||||||
|
- Сессия хранится на сервере (cookie-based или JWT — определить при выборе стека) |
||||||
|
- Пароль хранится в зашифрованном виде (bcrypt или аналог) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 4. Функциональные требования |
||||||
|
|
||||||
|
### 4.1. Управление пользователями и подразделениями |
||||||
|
|
||||||
|
- Создание/редактирование/деактивация учётных записей сотрудников |
||||||
|
- Каждый сотрудник принадлежит одному подразделению |
||||||
|
- Создание/редактирование справочника подразделений |
||||||
|
- Назначение роли сотруднику: HR-менеджер / Руководитель подразделения / Сотрудник |
||||||
|
|
||||||
|
### 4.2. Создание и редактирование тестов |
||||||
|
|
||||||
|
**Тест содержит:** |
||||||
|
- Название теста |
||||||
|
- Описание (опционально) |
||||||
|
- Список вопросов (минимум 7) |
||||||
|
- Порог зачёта — минимальный % правильных ответов (задаётся автором) |
||||||
|
- Таймер прохождения — лимит в минутах (опционально) |
||||||
|
|
||||||
|
**Вопрос содержит:** |
||||||
|
- Текст вопроса |
||||||
|
- Минимум 3 варианта ответа |
||||||
|
- Один или несколько правильных ответов (чекбокс или радио-кнопка в зависимости от типа) |
||||||
|
|
||||||
|
**Настройки теста (задаются автором при создании):** |
||||||
|
- Разрешить возврат к предыдущему вопросу: да / нет |
||||||
|
|
||||||
|
**Правила работы с тестом:** |
||||||
|
- Автор может редактировать тест пока никто его не проходил |
||||||
|
- Если тест уже проходили — создаётся новая версия (`version + 1`), старая сохраняется |
||||||
|
- Все версии теста хранятся; результаты привязаны к конкретной версии |
||||||
|
- Активная версия — та, которую видят сотрудники; автор может вручную переключить активную версию |
||||||
|
- Тест можно деактивировать (скрыть из списка, не удалять) |
||||||
|
|
||||||
|
### 4.3. Назначение теста |
||||||
|
|
||||||
|
При назначении задаются: |
||||||
|
- Список получателей (отдел или конкретные сотрудники) |
||||||
|
- Срок сдачи — дата дедлайна (задаётся в днях от даты назначения или конкретной датой) |
||||||
|
- Допустимое количество попыток (1 или более — задаётся при назначении) |
||||||
|
|
||||||
|
HR-менеджер может назначить тест сотрудникам любых подразделений. |
||||||
|
Руководитель подразделения — только сотрудникам своего подразделения. |
||||||
|
|
||||||
|
### 4.4. Прохождение теста (интерфейс сотрудника) |
||||||
|
|
||||||
|
- На главной странице сотрудник видит список назначенных ему тестов со статусами: |
||||||
|
- `Не начат` — ещё не открывал |
||||||
|
- `В процессе` — начал, не завершил (если таймер — отсчёт продолжается) |
||||||
|
- `Завершён` — сдал/не сдал |
||||||
|
- `Просрочен` — дедлайн прошёл, не сдан |
||||||
|
- Если задан таймер — отображается обратный отсчёт, по истечении тест завершается автоматически |
||||||
|
- Порядок вопросов **случайный** при каждом прохождении |
||||||
|
- Возможность вернуться к предыдущему вопросу — определяется настройкой теста |
||||||
|
|
||||||
|
### 4.5. Результаты после завершения теста |
||||||
|
|
||||||
|
Сотрудник сразу после сдачи видит: |
||||||
|
- Итоговый балл и процент правильных ответов |
||||||
|
- Факт зачёта: **сдал / не сдал** (относительно порога) |
||||||
|
- Разбор ошибок: по каждому вопросу — его ответ и правильный ответ |
||||||
|
|
||||||
|
### 4.6. Трекер попыток |
||||||
|
|
||||||
|
Система фиксирует каждую попытку прохождения теста: |
||||||
|
|
||||||
|
| Поле | Описание | |
||||||
|
|------|----------| |
||||||
|
| Сотрудник | ФИО, подразделение | |
||||||
|
| Тест | Название | |
||||||
|
| Попытка № | Порядковый номер попытки | |
||||||
|
| Начало | Дата и время начала | |
||||||
|
| Завершение | Дата и время окончания | |
||||||
|
| Результат | Количество правильных ответов / всего, % | |
||||||
|
| Зачёт | Да / Нет (преодолён ли порог) | |
||||||
|
|
||||||
|
Руководитель видит трекер по своему подразделению. |
||||||
|
HR-менеджер видит трекер по всей клинике. |
||||||
|
Сотрудник видит только свои попытки. |
||||||
|
|
||||||
|
### 4.7. AI-помощник при создании и редактировании тестов |
||||||
|
|
||||||
|
Интеграция с LLM (DeepSeek) доступна авторам тестов в форме создания и редактирования. |
||||||
|
|
||||||
|
**Функции AI-помощника:** |
||||||
|
|
||||||
|
| Функция | Описание | |
||||||
|
|---------|----------| |
||||||
|
| Генерация теста | Автор вводит тему — AI генерирует готовый набор вопросов с вариантами ответов | |
||||||
|
| Улучшение формулировки | AI переформулирует выбранный вопрос более чётко и однозначно | |
||||||
|
| Добавление дистракторов | AI генерирует правдоподобные неправильные варианты ответов к вопросу | |
||||||
|
| Проверка качества | AI анализирует весь тест и выдаёт рекомендации по улучшению | |
||||||
|
|
||||||
|
**Настройки:** |
||||||
|
- API-ключ DeepSeek вводится на странице `/settings` и хранится в базе данных |
||||||
|
- Страница настроек содержит кнопку «Проверить подключение» — выполняет тестовый запрос к API |
||||||
|
- Ключ хранится только на бэкенде и не передаётся на фронтенд |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 5. Нефункциональные требования |
||||||
|
|
||||||
|
| Параметр | Требование | |
||||||
|
|----------|-----------| |
||||||
|
| Количество пользователей | 50–200 человек | |
||||||
|
| Платформа | Веб-приложение, браузер (desktop-first) | |
||||||
|
| Доступность | Внутренняя сеть клиники | |
||||||
|
| Язык интерфейса | Русский | |
||||||
|
| Время отклика | < 2 секунды для основных операций | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 6. Уведомления |
||||||
|
|
||||||
|
Уведомления реализуются в одном из последних спринтов. |
||||||
|
|
||||||
|
- Канал: мессенджер **MAX** |
||||||
|
- События для уведомления: |
||||||
|
- Сотруднику назначен новый тест |
||||||
|
- Приближается дедлайн сдачи теста |
||||||
|
- Интеграция с MAX API — отдельная задача спринта |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 7. Вне scope (не реализуем в данной версии) |
||||||
|
|
||||||
|
- Интеграция с AD/LDAP |
||||||
|
- Мобильное приложение |
||||||
|
- Вопросы с вложениями (изображения, видео) |
||||||
|
- Экспорт отчётов в Excel / PDF |
||||||
@ -0,0 +1,85 @@ |
|||||||
|
# ШАГ 003 — Закрытие открытых вопросов ТЗ |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Запрос |
||||||
|
|
||||||
|
> давай обсудим открытые вопросы сейчас |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Ответ |
||||||
|
|
||||||
|
Проведён диалог с заказчиком по всем 6 открытым вопросам из ТЗ. Все решения приняты и внесены в `DOC/ТЗ.md` (версия 1.1, статус: Согласовано). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Принятые решения |
||||||
|
|
||||||
|
### 1. Роль Директора клиники |
||||||
|
**Решение:** Директор получает права HR-менеджера — те же возможности, отдельная роль не нужна. |
||||||
|
|
||||||
|
В таблице ролей ТЗ: |
||||||
|
``` |
||||||
|
HR-менеджер | Руководитель службы HR, Директор клиники |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 2. Редактирование теста после назначения |
||||||
|
**Решение:** Создаётся новая версия теста. |
||||||
|
|
||||||
|
- Назначенный тест заморожен — изменить его нельзя |
||||||
|
- Для исправления создаётся новая версия |
||||||
|
- Все версии хранятся в системе |
||||||
|
- Результаты прохождений привязаны к конкретной версии теста |
||||||
|
|
||||||
|
Это важно для целостности данных: нельзя изменить вопросы уже после того, как кто-то прошёл тест. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 3. Порядок вопросов |
||||||
|
**Решение:** Случайный при каждом прохождении. |
||||||
|
|
||||||
|
Это усложняет списывание — два сотрудника, проходящих тест одновременно, видят вопросы в разном порядке. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 4. Возврат к предыдущему вопросу |
||||||
|
**Решение:** Настраивается автором теста при создании. |
||||||
|
|
||||||
|
Параметр добавлен в настройки теста: `Разрешить возврат к предыдущему вопросу: да / нет`. |
||||||
|
- Для строгих тестов (экзаменационный формат) — выключить |
||||||
|
- Для обучающих тестов — можно включить |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 5. Уведомления |
||||||
|
**Решение:** Нужны, канал — мессенджер **MAX**. |
||||||
|
|
||||||
|
- Уведомление о назначении нового теста |
||||||
|
- Уведомление о приближении дедлайна |
||||||
|
- **Реализуем в одном из последних спринтов** — интеграция с MAX API отдельная задача |
||||||
|
|
||||||
|
> **Для джуниора:** MAX — корпоративный мессенджер. Интеграция потребует изучения API MAX и реализации отдельного сервиса уведомлений. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 6. Экспорт отчётов |
||||||
|
**Решение:** Не нужен в данной версии, вынесен за scope. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Итог |
||||||
|
|
||||||
|
ТЗ обновлено до версии 1.1, статус изменён на **Согласовано**. Открытых вопросов не осталось. |
||||||
|
|
||||||
|
### Следующие шаги |
||||||
|
|
||||||
|
- [x] Написать ТЗ |
||||||
|
- [x] Закрыть открытые вопросы ТЗ |
||||||
|
- [ ] Выбрать технологический стек |
||||||
|
- [ ] Составить план спринтов |
||||||
@ -0,0 +1,96 @@ |
|||||||
|
# ШАГ 004 — Выбор технологического стека |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Запрос |
||||||
|
|
||||||
|
> да (переходим к обсуждению стека) |
||||||
|
|
||||||
|
Контекст: |
||||||
|
- Команда: джуниор-разработчики, знают Python |
||||||
|
- Архитектура: отдельный API + SPA |
||||||
|
- Деплой: Docker на своём сервере |
||||||
|
- Frontend фреймворк: React |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Ответ |
||||||
|
|
||||||
|
Стек зафиксирован в `DOC/СТЕК.md`. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Принятые решения и обоснование |
||||||
|
|
||||||
|
### Backend: FastAPI |
||||||
|
|
||||||
|
Почему не Django: |
||||||
|
- Django — монолитный фреймворк, заточен под SSR, его DRF (Django REST Framework) добавляет сложность |
||||||
|
- FastAPI чище для чистого API-сервиса, меньше магии, проще читать код |
||||||
|
|
||||||
|
Почему не Flask: |
||||||
|
- Flask слишком минималистичен, джуниор потратит время на сборку инфраструктуры вместо бизнес-логики |
||||||
|
- FastAPI даёт валидацию, документацию и типизацию из коробки |
||||||
|
|
||||||
|
### База данных: PostgreSQL |
||||||
|
|
||||||
|
- SQLite не подходит для многопользовательского доступа (блокировки при записи) |
||||||
|
- PostgreSQL — проверенный выбор для продакшена, отличная поддержка в SQLAlchemy |
||||||
|
|
||||||
|
### ORM: SQLAlchemy 2.0 (async) |
||||||
|
|
||||||
|
- Async-режим важен для FastAPI — приложение не блокируется на запросах к БД |
||||||
|
- Alembic для миграций — джуниор учится правильно управлять схемой БД |
||||||
|
|
||||||
|
### Frontend: React 18 + TypeScript |
||||||
|
|
||||||
|
- React — самый востребованный фреймворк на рынке труда |
||||||
|
- TypeScript добавляет типизацию, ловит ошибки на этапе компиляции |
||||||
|
- Vite — современный и быстрый сборщик (замена устаревшего CRA) |
||||||
|
|
||||||
|
### UI: Ant Design |
||||||
|
|
||||||
|
- Готовые компоненты таблиц, форм, модалок, таймеров — сэкономим 1-2 спринта |
||||||
|
- Хорошо подходит для admin-панелей и внутренних инструментов |
||||||
|
- Встроенная русская локализация |
||||||
|
|
||||||
|
### Серверный стейт: TanStack Query |
||||||
|
|
||||||
|
Обучающий момент для джуниора: |
||||||
|
- Учит разделять серверное состояние (данные с API) и клиентское состояние (UI) |
||||||
|
- Без него джуниоры обычно пишут useEffect + useState везде — это антипаттерн |
||||||
|
- Автоматическое кэширование и перезапросы — меньше кода, больше надёжности |
||||||
|
|
||||||
|
### Инфраструктура: Docker Compose + Nginx |
||||||
|
|
||||||
|
``` |
||||||
|
Nginx (80/443) |
||||||
|
├── / → React SPA (статика из build) |
||||||
|
└── /api/ → FastAPI backend (uvicorn) |
||||||
|
|
||||||
|
Docker Compose сервисы: |
||||||
|
├── db (PostgreSQL) |
||||||
|
├── backend (FastAPI) |
||||||
|
├── frontend (React build / Vite в dev) |
||||||
|
└── nginx |
||||||
|
``` |
||||||
|
|
||||||
|
- Один `docker compose up` запускает весь стек |
||||||
|
- Nginx в одном контейнере раздаёт фронтенд и проксирует запросы к API |
||||||
|
- Для 50–200 пользователей этой архитектуры полностью достаточно |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Итог |
||||||
|
|
||||||
|
Документ `DOC/СТЕК.md` содержит полное описание стека с версиями и обоснованиями. |
||||||
|
|
||||||
|
### Следующие шаги |
||||||
|
|
||||||
|
- [x] Написать ТЗ |
||||||
|
- [x] Закрыть открытые вопросы ТЗ |
||||||
|
- [x] Выбрать технологический стек |
||||||
|
- [ ] Составить план спринтов |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
# ШАГ 005 — План спринтов |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Запрос |
||||||
|
|
||||||
|
> мое предложение такое: давай на первом спринте мы сможем создавать тесты. здесь не нужно никакой авторизации и ролей. главное зашел на страницу создания теста и смог его создать. следующий спринт — можем выполнять тест. следующий спринт — трекер результатов. затем аутентификация и управление пользователями и подразделениями. и потом уведомления |
||||||
|
|
||||||
|
> спринт 0 и спринт 1 соединяем |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Ответ |
||||||
|
|
||||||
|
### Ключевое решение по порядку спринтов |
||||||
|
|
||||||
|
Заказчик выбрал подход **"ценность сначала"**: бизнес-логика (создание и прохождение тестов) реализуется в первых спринтах без авторизации. Это даёт несколько преимуществ: |
||||||
|
|
||||||
|
1. **Быстрая обратная связь** — уже после спринта 1 можно потрогать живой продукт |
||||||
|
2. **Мотивация для джуниоров** — видят результат, а не бойлерплейт |
||||||
|
3. **Проще отлаживать** — нет JWT, нет проверки прав, чистая бизнес-логика |
||||||
|
4. **Авторизация добавляется поверх** — в спринте 4 она "накладывается" на уже работающее приложение |
||||||
|
|
||||||
|
Это классический подход Lean/MVP: сначала рабочий продукт, потом безопасность и управление. |
||||||
|
|
||||||
|
### Итоговый план |
||||||
|
|
||||||
|
| Спринт | Что делаем | Результат в браузере | |
||||||
|
|--------|-----------|---------------------| |
||||||
|
| **1** | Инфраструктура + Создание тестов | Создаём тест, видим список тестов | |
||||||
|
| **2** | Прохождение теста | Проходим тест, видим результат и ошибки | |
||||||
|
| **3** | Трекер результатов | Таблица всех попыток | |
||||||
|
| **4** | Авторизация + роли + подразделения + назначение | Вход по логину, разграничение доступа | |
||||||
|
| **5** | Уведомления в MAX | Получаем уведомления в мессенджер | |
||||||
|
|
||||||
|
### Следующие шаги |
||||||
|
|
||||||
|
- [x] Написать ТЗ |
||||||
|
- [x] Закрыть открытые вопросы ТЗ |
||||||
|
- [x] Выбрать технологический стек |
||||||
|
- [x] Составить план спринтов |
||||||
|
- [ ] Начать Спринт 1 |
||||||
@ -0,0 +1,160 @@ |
|||||||
|
# ШАГ 006 — Спринт 1: Инфраструктура + Создание тестов |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Запрос |
||||||
|
|
||||||
|
> готов. поехали (начинаем Спринт 1) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Ответ |
||||||
|
|
||||||
|
Реализован Спринт 1 в полном объёме: инфраструктура + создание тестов без авторизации. |
||||||
|
Спринт протестирован вручную в браузере, найдены и исправлены 4 бага. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Структура проекта после спринта |
||||||
|
|
||||||
|
``` |
||||||
|
qa_test_app/ |
||||||
|
├── docker-compose.yml ← поднимает весь стек одной командой |
||||||
|
├── .env.example |
||||||
|
├── nginx/ |
||||||
|
│ └── nginx.conf ← / → frontend, /api/ → backend |
||||||
|
├── backend/ |
||||||
|
│ ├── Dockerfile |
||||||
|
│ ├── entrypoint.sh ← ждёт БД, запускает миграции, стартует сервер |
||||||
|
│ ├── requirements.txt |
||||||
|
│ ├── alembic.ini |
||||||
|
│ ├── alembic/ |
||||||
|
│ │ ├── env.py ← async alembic, берёт URL из settings |
||||||
|
│ │ ├── script.py.mako |
||||||
|
│ │ └── versions/ |
||||||
|
│ │ └── 001_init.py ← создаёт таблицы tests, questions, answers |
||||||
|
│ └── app/ |
||||||
|
│ ├── config.py ← настройки через pydantic-settings |
||||||
|
│ ├── database.py ← async SQLAlchemy engine + session |
||||||
|
│ ├── main.py ← FastAPI app, /api/health |
||||||
|
│ ├── models/test.py ← ORM модели: Test, Question, Answer |
||||||
|
│ ├── schemas/test.py ← Pydantic схемы с валидацией |
||||||
|
│ └── api/tests.py ← REST эндпоинты |
||||||
|
└── frontend/ |
||||||
|
├── Dockerfile |
||||||
|
├── package.json |
||||||
|
├── vite.config.ts |
||||||
|
├── index.html |
||||||
|
└── src/ |
||||||
|
├── App.tsx ← роутер + провайдеры |
||||||
|
├── api/ |
||||||
|
│ ├── client.ts ← axios с baseURL=/api |
||||||
|
│ └── tests.ts ← типы + функции запросов |
||||||
|
└── pages/ |
||||||
|
├── TestList/ ← список тестов + кнопка создать |
||||||
|
├── TestCreate/ ← форма создания теста |
||||||
|
└── TestDetail/ ← просмотр теста с вопросами |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## API эндпоинты |
||||||
|
|
||||||
|
| Метод | URL | Описание | |
||||||
|
|-------|-----|----------| |
||||||
|
| GET | `/api/health` | Проверка работы сервера | |
||||||
|
| GET | `/api/tests` | Список тестов | |
||||||
|
| GET | `/api/tests/{id}` | Детали теста с вопросами и ответами | |
||||||
|
| POST | `/api/tests` | Создать тест | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Схема БД |
||||||
|
|
||||||
|
``` |
||||||
|
tests |
||||||
|
id, title, description, passing_score, time_limit, |
||||||
|
allow_navigation_back, is_active, version, created_at |
||||||
|
|
||||||
|
questions |
||||||
|
id, test_id → tests.id, text, order |
||||||
|
|
||||||
|
answers |
||||||
|
id, question_id → questions.id, text, is_correct |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Валидация |
||||||
|
|
||||||
|
**Backend (Pydantic):** |
||||||
|
- Тест: минимум 7 вопросов, passing_score 0–100 |
||||||
|
- Вопрос: минимум 3 варианта ответа, хотя бы 1 правильный |
||||||
|
|
||||||
|
**Frontend (Ant Design Form):** |
||||||
|
- Те же правила воспроизведены на клиенте |
||||||
|
- Nested Form.List для динамических вопросов и ответов |
||||||
|
- Таймер показывается только при включённом переключателе (shouldUpdate) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Как запустить |
||||||
|
|
||||||
|
```bash |
||||||
|
docker compose up --build |
||||||
|
``` |
||||||
|
|
||||||
|
Открыть браузер: `http://localhost` |
||||||
|
|
||||||
|
- Список тестов → кнопка «Создать тест» |
||||||
|
- Заполнить форму → нажать «Создать тест» |
||||||
|
- Перейти к созданному тесту и увидеть все вопросы и ответы |
||||||
|
- `http://localhost/api/health` → `{"status": "ok"}` |
||||||
|
- `http://localhost/api/docs` → Swagger UI FastAPI |
||||||
|
- `http://localhost/api/redoc` → ReDoc документация |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Баги, найденные и исправленные при ручном тестировании |
||||||
|
|
||||||
|
| # | Симптом | Причина | Исправление | |
||||||
|
|---|---------|---------|-------------| |
||||||
|
| 1 | `permission denied` на `entrypoint.sh` | `docker-compose.yml` монтирует `./backend:/app` — volume перекрывает файлы образа, включая результат `chmod +x` из Dockerfile | `CMD ["bash", "entrypoint.sh"]` вместо `CMD ["./entrypoint.sh"]` | |
||||||
|
| 2 | `No module named 'app'` в Alembic | Python не добавляет WORKDIR в `sys.path` автоматически | `ENV PYTHONPATH=/app` в Dockerfile | |
||||||
|
| 3 | nginx: `host not found in upstream "backend"` | nginx резолвит upstream-хосты **при старте**, а backend ещё не поднялся | Docker DNS resolver `127.0.0.11` + `set $backend` — резолвинг откладывается до момента запроса | |
||||||
|
| 4 | `http://localhost/api/docs` → 404 | FastAPI по умолчанию отдаёт Swagger по `/docs`, а через nginx путь становится `/api/docs` → `backend:8000/api/docs` (не существует) | Явно указать `docs_url="/api/docs"`, `redoc_url="/api/redoc"`, `openapi_url="/api/openapi.json"` в FastAPI | |
||||||
|
|
||||||
|
> **Для джуниора:** все четыре бага — типичные для первого запуска Docker + FastAPI + nginx. Запомни их, встретишь снова. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Ключевые решения для джуниора |
||||||
|
|
||||||
|
**Async SQLAlchemy 2.0:** |
||||||
|
- `async_sessionmaker` + `AsyncSession` — не блокируем event loop при запросах к БД |
||||||
|
- `selectinload` для жадной загрузки связей (вместо N+1 запросов) |
||||||
|
|
||||||
|
**Alembic async:** |
||||||
|
- `async_engine_from_config` + `connection.run_sync(do_run_migrations)` |
||||||
|
- URL берётся из `app.config.settings` — одно место для конфига |
||||||
|
|
||||||
|
**Docker Compose healthcheck:** |
||||||
|
- `db` объявляет `healthcheck`, `backend` ждёт `condition: service_healthy` |
||||||
|
- Дополнительно `entrypoint.sh` вызывает `pg_isready` для надёжности |
||||||
|
|
||||||
|
**TanStack Query:** |
||||||
|
- `useQuery` для чтения данных — кэш, loading state, error state из коробки |
||||||
|
- `useMutation` для создания — `invalidateQueries` обновляет список после успеха |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Следующие шаги |
||||||
|
|
||||||
|
- [x] Спринт 1: Инфраструктура + Создание тестов |
||||||
|
- [ ] Спринт 2: Прохождение теста + результаты |
||||||
|
- [ ] Спринт 3: Трекер результатов |
||||||
|
- [ ] Спринт 4: Авторизация и роли |
||||||
|
- [ ] Спринт 5: Уведомления в MAX |
||||||
@ -0,0 +1,97 @@ |
|||||||
|
# ШАГ 007 — Спринт 2: Прохождение теста + результат |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Запрос |
||||||
|
|
||||||
|
> запускай реализацию спринта 2 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Ответ |
||||||
|
|
||||||
|
Реализован Спринт 2: прохождение теста с таймером и навигацией, результат с разбором ошибок. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Новые файлы |
||||||
|
|
||||||
|
``` |
||||||
|
backend/app/models/attempt.py ← TestAttempt, AttemptAnswer |
||||||
|
backend/app/schemas/attempt.py ← схемы для старта, сдачи и результата |
||||||
|
backend/app/api/attempts.py ← 3 эндпоинта |
||||||
|
backend/alembic/versions/002_attempts.py ← миграция |
||||||
|
|
||||||
|
frontend/src/api/attempts.ts ← типы и запросы |
||||||
|
frontend/src/pages/TestTake/ ← страница прохождения теста |
||||||
|
frontend/src/pages/AttemptResult/ ← страница результата |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## API эндпоинты (новые) |
||||||
|
|
||||||
|
| Метод | URL | Описание | |
||||||
|
|-------|-----|----------| |
||||||
|
| POST | `/api/attempts` | Начать попытку → возвращает вопросы перемешанные, без правильных ответов | |
||||||
|
| POST | `/api/attempts/{id}/submit` | Сдать тест → подсчитать и вернуть результат | |
||||||
|
| GET | `/api/attempts/{id}/result` | Получить результат сохранённой попытки | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Схема БД (добавлено) |
||||||
|
|
||||||
|
``` |
||||||
|
test_attempts |
||||||
|
id, test_id → tests.id, started_at, finished_at, |
||||||
|
score, passed, correct_count, total_count, status |
||||||
|
|
||||||
|
attempt_answers |
||||||
|
id, attempt_id → test_attempts.id, |
||||||
|
question_id → questions.id, answer_id → answers.id |
||||||
|
``` |
||||||
|
|
||||||
|
Одна строка `attempt_answers` = один выбранный вариант ответа. |
||||||
|
Для вопросов с несколькими правильными ответами — несколько строк. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Логика прохождения теста |
||||||
|
|
||||||
|
**Старт попытки:** |
||||||
|
- Создаётся запись `TestAttempt` со статусом `in_progress` |
||||||
|
- Вопросы и ответы внутри каждого вопроса перемешиваются случайно |
||||||
|
- Поле `is_correct` **не передаётся** на фронт — нельзя смошенничать через DevTools |
||||||
|
- Поле `is_multiple: bool` говорит фронту: показывать радио-кнопки или чекбоксы |
||||||
|
|
||||||
|
**Сдача теста:** |
||||||
|
- Фронт отправляет `[{ question_id, answer_ids[] }]` для каждого вопроса |
||||||
|
- Вопрос засчитывается правильным только если `selected_ids == correct_ids` (точное совпадение) |
||||||
|
- Балл = (правильных / всего) × 100 |
||||||
|
- Зачёт: балл ≥ порогу из теста |
||||||
|
|
||||||
|
**Разбор ошибок:** |
||||||
|
- Для каждого вопроса: `is_answered_correctly` |
||||||
|
- Для каждого варианта: `is_correct` + `is_selected` → фронт показывает что выбрал и что было правильно |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## UX прохождения теста |
||||||
|
|
||||||
|
- Вопросы по одному, прогресс-бар сверху |
||||||
|
- Таймер: если задан — обратный отсчёт, при `< 60 сек` — предупреждение, при `0` — автосабмит |
||||||
|
- Кнопка «Назад» заблокирована если `allow_navigation_back = false` |
||||||
|
- Чекбоксы для `is_multiple`, радио-кнопки для одиночного ответа |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Следующие шаги |
||||||
|
|
||||||
|
- [x] Спринт 1: Инфраструктура + Создание тестов |
||||||
|
- [x] Спринт 2: Прохождение теста + результат |
||||||
|
- [ ] Спринт 3: Трекер результатов |
||||||
|
- [ ] Спринт 4: Авторизация и роли |
||||||
|
- [ ] Спринт 5: Уведомления в MAX |
||||||
@ -0,0 +1,73 @@ |
|||||||
|
# ШАГ 008 — Спринт 2: Баг «Не удалось загрузить тест» |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Запрос |
||||||
|
|
||||||
|
> вот такой баг при нажатии кнопки «Пройти тест» — «Не удалось загрузить тест» × 2 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Симптом |
||||||
|
|
||||||
|
При нажатии кнопки «Пройти тест» фронт выполняет `POST /api/attempts` и получает ошибку. |
||||||
|
В консоли backend: |
||||||
|
|
||||||
|
``` |
||||||
|
sqlalchemy.exc.ProgrammingError: (asyncpg.exceptions.UndefinedTableError) |
||||||
|
relation "test_attempts" does not exist |
||||||
|
``` |
||||||
|
|
||||||
|
Ошибка появляется дважды — из-за React StrictMode, который в dev-режиме намеренно монтирует компоненты дважды. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Причина |
||||||
|
|
||||||
|
`uvicorn --reload` следит только за изменениями Python-файлов и перезапускает **процесс приложения**, но **не перезапускает контейнер целиком** и не выполняет `entrypoint.sh` повторно. |
||||||
|
|
||||||
|
Миграция `002_attempts` (создаёт таблицы `test_attempts` и `attempt_answers`) была добавлена уже после первого запуска стека — поэтому она ни разу не применялась. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Исправление |
||||||
|
|
||||||
|
```bash |
||||||
|
docker compose restart backend |
||||||
|
``` |
||||||
|
|
||||||
|
При перезапуске контейнер выполняет `entrypoint.sh` заново: |
||||||
|
|
||||||
|
``` |
||||||
|
Running migrations... |
||||||
|
INFO [alembic.runtime.migration] Context impl PostgresqlImpl. |
||||||
|
INFO [alembic.runtime.migration] Will assume transactional DDL. |
||||||
|
INFO [alembic.runtime.migration] Running upgrade 001 -> 002, attempts |
||||||
|
Starting server... |
||||||
|
INFO: Application startup complete. |
||||||
|
``` |
||||||
|
|
||||||
|
Таблицы созданы — баг исчезает. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Правило для джуниора |
||||||
|
|
||||||
|
> `uvicorn --reload` ≠ перезапуск контейнера. |
||||||
|
> |
||||||
|
> Если ты добавил новую миграцию Alembic и стек уже работает — выполни `docker compose restart backend`, чтобы миграция применилась. |
||||||
|
> |
||||||
|
> `docker compose up` запускает контейнер только если он не запущен. `restart` — принудительно пересоздаёт его. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Следующие шаги |
||||||
|
|
||||||
|
- [x] Спринт 1: Инфраструктура + Создание тестов |
||||||
|
- [x] Спринт 2: Прохождение теста + результат |
||||||
|
- [ ] Спринт 3: Трекер результатов |
||||||
|
- [ ] Спринт 4: Авторизация и роли |
||||||
|
- [ ] Спринт 5: Уведомления в MAX |
||||||
@ -0,0 +1,73 @@ |
|||||||
|
# ШАГ 009 — Спринт 2: Доработки UX после тестирования |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Запросы |
||||||
|
|
||||||
|
> 1. На странице теста видны правильные ответы — зачем тогда проходить тест? |
||||||
|
> 2. На главной колонка с названием теста стала слишком узкой из-за трёх кнопок действий. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Изменения |
||||||
|
|
||||||
|
### 1. Разделение страницы теста на два вида |
||||||
|
|
||||||
|
**Проблема:** `/tests/:id` показывала зелёные галочки у правильных ответов. Пользователь мог подсмотреть ответы до прохождения теста. |
||||||
|
|
||||||
|
**Решение:** два отдельных маршрута с разным содержимым. |
||||||
|
|
||||||
|
| Маршрут | Для кого | Что показывает | |
||||||
|
|---------|----------|----------------| |
||||||
|
| `/tests/:id` | Сотрудник | Вопросы и варианты ответов без отметок | |
||||||
|
| `/tests/:id/edit` | Автор | Все ответы с отметками ✓/✗ + жёлтый баннер «Вид автора» | |
||||||
|
|
||||||
|
Кнопка «Редактировать» на странице автора пока задизаблена — активируется в Спринте 4 вместе с авторизацией. |
||||||
|
|
||||||
|
**Затронутые файлы:** |
||||||
|
``` |
||||||
|
frontend/src/pages/TestDetail/index.tsx ← убраны CheckCircleTwoTone / CloseCircleTwoTone |
||||||
|
frontend/src/pages/TestEdit/index.tsx ← новый файл, бывший TestDetail + баннер |
||||||
|
frontend/src/App.tsx ← добавлен маршрут /tests/:id/edit |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 2. Выпадающее меню «⋯» в списке тестов |
||||||
|
|
||||||
|
**Проблема:** Три кнопки («Открыть», «Изменить», «Пройти тест») в одной ячейке сжимали колонку с названием теста. |
||||||
|
|
||||||
|
**Решение:** Все три действия убраны в `Dropdown` по кнопке `MoreOutlined` (⋯). Колонка действий сужена до 60px, колонка с названием занимает всю оставшуюся ширину. |
||||||
|
|
||||||
|
``` |
||||||
|
frontend/src/pages/TestList/index.tsx ← Space + 3 Button → Dropdown с menu.items |
||||||
|
``` |
||||||
|
|
||||||
|
**Структура меню:** |
||||||
|
``` |
||||||
|
Открыть |
||||||
|
Изменить |
||||||
|
───────── |
||||||
|
Пройти тест |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Архитектурное решение для джуниора |
||||||
|
|
||||||
|
> Разделение «вид сотрудника» / «вид автора» сделано через **два отдельных React-компонента** на разных маршрутах, а не через условный рендеринг внутри одного компонента. |
||||||
|
> |
||||||
|
> Почему: в Спринте 4 эти маршруты будут защищены разными ролями (`ProtectedRoute`). Если бы логика была в одном компоненте, пришлось бы прятать данные на фронте — это ненадёжно. Два маршрута = два разных запроса = чистое разграничение доступа. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Следующие шаги |
||||||
|
|
||||||
|
- [x] Спринт 1: Инфраструктура + Создание тестов |
||||||
|
- [x] Спринт 2: Прохождение теста + результат |
||||||
|
- [ ] Спринт 3: Трекер результатов |
||||||
|
- [ ] Спринт 4: Авторизация и роли |
||||||
|
- [ ] Спринт 5: Уведомления в MAX |
||||||
@ -0,0 +1,98 @@ |
|||||||
|
# ШАГ 010 — Спринт 3: Редактирование теста + версионность |
||||||
|
|
||||||
|
**Дата:** 2026-03-21 |
||||||
|
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Запрос |
||||||
|
|
||||||
|
> запускаем спринт 3 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Реализовано |
||||||
|
|
||||||
|
Полноценное редактирование тестов с автоматическим версионированием. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Новые файлы |
||||||
|
|
||||||
|
``` |
||||||
|
backend/alembic/versions/003_test_versioning.py ← добавляет parent_id в tests |
||||||
|
frontend/src/components/TestForm/index.tsx ← переиспользуемая форма теста |
||||||
|
``` |
||||||
|
|
||||||
|
## Изменённые файлы |
||||||
|
|
||||||
|
``` |
||||||
|
backend/app/models/test.py ← добавлено поле parent_id |
||||||
|
backend/app/schemas/test.py ← TestOut.parent_id, TestUpdateResponse |
||||||
|
backend/app/api/tests.py ← PUT + /versions + /activate |
||||||
|
frontend/src/api/tests.ts ← update, versions, activate |
||||||
|
frontend/src/pages/TestCreate/ ← упрощён через TestForm |
||||||
|
frontend/src/pages/TestEdit/ ← полноценный просмотр + редактирование |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## API эндпоинты (новые) |
||||||
|
|
||||||
|
| Метод | URL | Описание | |
||||||
|
|-------|-----|----------| |
||||||
|
| PUT | `/api/tests/{id}` | Редактировать тест | |
||||||
|
| GET | `/api/tests/{id}/versions` | Цепочка всех версий | |
||||||
|
| POST | `/api/tests/{id}/activate` | Сделать версию активной | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Схема БД (изменено) |
||||||
|
|
||||||
|
``` |
||||||
|
tests |
||||||
|
+ parent_id → tests.id ← ссылка на предыдущую версию (NULL у первой) |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Логика версионирования |
||||||
|
|
||||||
|
| Условие при сохранении | Результат | |
||||||
|
|------------------------|-----------| |
||||||
|
| Попыток нет | Редактируем на месте, версия не меняется | |
||||||
|
| Есть хотя бы одна попытка | Новый тест: `version+1`, `parent_id=old.id`, `is_active=True`; старый: `is_active=False` | |
||||||
|
|
||||||
|
**Список тестов** показывает только `is_active=True` версии. |
||||||
|
**История версий** отображается на странице `/tests/:id/edit` — таблица всех версий цепочки. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## UX редактирования |
||||||
|
|
||||||
|
- Страница `/tests/:id/edit` работает в двух режимах: просмотр автора → нажать «Редактировать» → форма редактирования |
||||||
|
- Кнопка «← К просмотру теста» в шапке формы |
||||||
|
- При создании новой версии: редирект на `/tests/:newId/edit` + уведомление |
||||||
|
- В таблице «История версий»: статус (Активная/Неактивная), кнопка «Сделать активной» для любой версии |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Баги, найденные и исправленные при тестировании |
||||||
|
|
||||||
|
| # | Симптом | Причина | Исправление | |
||||||
|
|---|---------|---------|-------------| |
||||||
|
| 1 | `ForeignKeyViolationError` при сохранении теста | `DELETE FROM questions` bulk-запросом не каскадирует на `answers` — в схеме нет `ON DELETE CASCADE` | Сначала удаляем `answers` по `question_id IN (...)`, затем `questions` | |
||||||
|
| 2 | Обе версии показывались «Активными» | Тест был создан до введения логики деактивации родителя | Добавлена кнопка «Сделать активной» в шапке и в строке таблицы для любой версии | |
||||||
|
|
||||||
|
> **Для джуниора:** `cascade="all, delete-orphan"` в SQLAlchemy работает только при удалении через ORM-объекты (`session.delete(obj)`). При bulk-delete через `db.execute(delete(Model)...)` ORM-каскад не срабатывает — нужно вручную удалять дочерние записи в правильном порядке. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Следующие шаги |
||||||
|
|
||||||
|
- [x] Спринт 1: Инфраструктура + Создание тестов |
||||||
|
- [x] Спринт 2: Прохождение теста + результат |
||||||
|
- [x] Спринт 3: Редактирование + версионность |
||||||
|
- [ ] Спринт 4: Трекер результатов |
||||||
|
- [ ] Спринт 5: Авторизация и роли |
||||||
|
- [ ] Спринт 6: Уведомления в MAX |
||||||
@ -0,0 +1,73 @@ |
|||||||
|
# QA Test App — Система тестирования сотрудников клиники |
||||||
|
|
||||||
|
Веб-приложение для проведения внутреннего тестирования сотрудников. Руководители подразделений создают тесты, назначают их сотрудникам, система фиксирует результаты. |
||||||
|
|
||||||
|
> Проект разрабатывается как **мастер-класс для джуниор-разработчиков**. История разработки — пошаговые запросы и решения — сохраняется в `DOC/ШАГИ/`. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Формат тестирования |
||||||
|
|
||||||
|
- Вопрос + минимум 3 варианта ответа (один или несколько правильных) |
||||||
|
- Минимум 7 вопросов в тесте |
||||||
|
- Случайный порядок вопросов при каждом прохождении |
||||||
|
- Опциональный таймер на прохождение |
||||||
|
- Порог зачёта задаётся автором теста (%) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Роли |
||||||
|
|
||||||
|
| Роль | Возможности | |
||||||
|
|------|------------| |
||||||
|
| **HR-менеджер / Директор** | Создаёт тесты, назначает всем сотрудникам клиники, видит все результаты | |
||||||
|
| **Руководитель подразделения** | Создаёт тесты, назначает только своему отделу, видит результаты своего отдела | |
||||||
|
| **Сотрудник** | Проходит назначенные тесты, видит свои результаты и ошибки | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Стек |
||||||
|
|
||||||
|
| Слой | Технология | |
||||||
|
|------|-----------| |
||||||
|
| Backend | Python 3.12 + FastAPI + SQLAlchemy 2.0 + Alembic | |
||||||
|
| База данных | PostgreSQL 16 | |
||||||
|
| Frontend | React 18 + TypeScript + Vite + Ant Design + TanStack Query | |
||||||
|
| Инфраструктура | Docker Compose + Nginx | |
||||||
|
| AI-помощник | DeepSeek API (openai-совместимый) — Спринт 4 | |
||||||
|
| Уведомления | Мессенджер MAX — Спринт 7 | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## План спринтов |
||||||
|
|
||||||
|
| Спринт | Содержание | Статус | |
||||||
|
|--------|-----------|--------| |
||||||
|
| **1** | Инфраструктура (Docker, FastAPI, React, PostgreSQL) + создание тестов | ✅ | |
||||||
|
| **2** | Прохождение теста + результаты и разбор ошибок | ✅ | |
||||||
|
| **3** | Редактирование тестов + версионность | ✅ | |
||||||
|
| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ⬜ | |
||||||
|
| **5** | Трекер результатов | ⬜ | |
||||||
|
| **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ | |
||||||
|
| **7** | Уведомления в MAX | ⬜ | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Документация |
||||||
|
|
||||||
|
| Файл | Содержание | |
||||||
|
|------|-----------| |
||||||
|
| `DOC/ТЗ.md` | Техническое задание (v1.1) | |
||||||
|
| `DOC/СТЕК.md` | Технологический стек с обоснованием | |
||||||
|
| `DOC/СПРИНТЫ.md` | Детальный план спринтов с задачами | |
||||||
|
| `DOC/ШАГИ/` | История разработки шаг за шагом | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Запуск (после Спринта 1) |
||||||
|
|
||||||
|
```bash |
||||||
|
docker compose up --build |
||||||
|
``` |
||||||
|
|
||||||
|
Приложение будет доступно на `http://localhost`. |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
FROM python:3.12-slim |
||||||
|
|
||||||
|
# pg_isready нужен для проверки готовности БД в entrypoint |
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client \ |
||||||
|
&& rm -rf /var/lib/apt/lists/* |
||||||
|
|
||||||
|
WORKDIR /app |
||||||
|
|
||||||
|
ENV PYTHONPATH=/app |
||||||
|
|
||||||
|
COPY requirements.txt . |
||||||
|
RUN pip install --no-cache-dir -r requirements.txt |
||||||
|
|
||||||
|
COPY . . |
||||||
|
|
||||||
|
RUN chmod +x entrypoint.sh |
||||||
|
|
||||||
|
CMD ["bash", "entrypoint.sh"] |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
[alembic] |
||||||
|
script_location = alembic |
||||||
|
|
||||||
|
# URL переопределяется в alembic/env.py из переменной окружения DATABASE_URL |
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname |
||||||
|
|
||||||
|
[loggers] |
||||||
|
keys = root,sqlalchemy,alembic |
||||||
|
|
||||||
|
[handlers] |
||||||
|
keys = console |
||||||
|
|
||||||
|
[formatters] |
||||||
|
keys = generic |
||||||
|
|
||||||
|
[logger_root] |
||||||
|
level = WARN |
||||||
|
handlers = console |
||||||
|
qualname = |
||||||
|
|
||||||
|
[logger_sqlalchemy] |
||||||
|
level = WARN |
||||||
|
handlers = |
||||||
|
qualname = sqlalchemy.engine |
||||||
|
|
||||||
|
[logger_alembic] |
||||||
|
level = INFO |
||||||
|
handlers = |
||||||
|
qualname = alembic |
||||||
|
|
||||||
|
[handler_console] |
||||||
|
class = StreamHandler |
||||||
|
args = (sys.stderr,) |
||||||
|
level = NOTSET |
||||||
|
formatter = generic |
||||||
|
|
||||||
|
[formatter_generic] |
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s |
||||||
|
datefmt = %H:%M:%S |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
import asyncio |
||||||
|
from logging.config import fileConfig |
||||||
|
|
||||||
|
from sqlalchemy import pool |
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config |
||||||
|
|
||||||
|
from alembic import context |
||||||
|
|
||||||
|
# Alembic config |
||||||
|
config = context.config |
||||||
|
|
||||||
|
if config.config_file_name is not None: |
||||||
|
fileConfig(config.config_file_name) |
||||||
|
|
||||||
|
# Берём DATABASE_URL из настроек приложения |
||||||
|
from app.config import settings |
||||||
|
from app.database import Base |
||||||
|
from app.models import attempt, test # noqa: F401 — импортируем модели, чтобы Alembic их видел |
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url) |
||||||
|
|
||||||
|
target_metadata = Base.metadata |
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None: |
||||||
|
url = config.get_main_option("sqlalchemy.url") |
||||||
|
context.configure( |
||||||
|
url=url, |
||||||
|
target_metadata=target_metadata, |
||||||
|
literal_binds=True, |
||||||
|
dialect_opts={"paramstyle": "named"}, |
||||||
|
) |
||||||
|
with context.begin_transaction(): |
||||||
|
context.run_migrations() |
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection): |
||||||
|
context.configure(connection=connection, target_metadata=target_metadata) |
||||||
|
with context.begin_transaction(): |
||||||
|
context.run_migrations() |
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None: |
||||||
|
connectable = async_engine_from_config( |
||||||
|
config.get_section(config.config_ini_section, {}), |
||||||
|
prefix="sqlalchemy.", |
||||||
|
poolclass=pool.NullPool, |
||||||
|
) |
||||||
|
async with connectable.connect() as connection: |
||||||
|
await connection.run_sync(do_run_migrations) |
||||||
|
await connectable.dispose() |
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode(): |
||||||
|
run_migrations_offline() |
||||||
|
else: |
||||||
|
asyncio.run(run_migrations_online()) |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
"""${message} |
||||||
|
|
||||||
|
Revision ID: ${up_revision} |
||||||
|
Revises: ${down_revision | comma,n} |
||||||
|
Create Date: ${create_date} |
||||||
|
|
||||||
|
""" |
||||||
|
from typing import Sequence, Union |
||||||
|
|
||||||
|
from alembic import op |
||||||
|
import sqlalchemy as sa |
||||||
|
${imports if imports else ""} |
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)} |
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)} |
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} |
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} |
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None: |
||||||
|
${upgrades if upgrades else "pass"} |
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None: |
||||||
|
${downgrades if downgrades else "pass"} |
||||||
@ -0,0 +1,62 @@ |
|||||||
|
"""init |
||||||
|
|
||||||
|
Revision ID: 001 |
||||||
|
Revises: |
||||||
|
Create Date: 2026-03-21 |
||||||
|
|
||||||
|
""" |
||||||
|
from typing import Sequence, Union |
||||||
|
|
||||||
|
import sqlalchemy as sa |
||||||
|
from alembic import op |
||||||
|
|
||||||
|
revision: str = "001" |
||||||
|
down_revision: Union[str, None] = None |
||||||
|
branch_labels: Union[str, Sequence[str], None] = None |
||||||
|
depends_on: Union[str, Sequence[str], None] = None |
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None: |
||||||
|
op.create_table( |
||||||
|
"tests", |
||||||
|
sa.Column("id", sa.Integer(), nullable=False), |
||||||
|
sa.Column("title", sa.String(255), nullable=False), |
||||||
|
sa.Column("description", sa.Text(), nullable=True), |
||||||
|
sa.Column("passing_score", sa.Integer(), nullable=False), |
||||||
|
sa.Column("time_limit", sa.Integer(), nullable=True), |
||||||
|
sa.Column("allow_navigation_back", sa.Boolean(), nullable=False, server_default="true"), |
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), |
||||||
|
sa.Column("version", sa.Integer(), nullable=False, server_default="1"), |
||||||
|
sa.Column( |
||||||
|
"created_at", |
||||||
|
sa.DateTime(timezone=True), |
||||||
|
server_default=sa.text("now()"), |
||||||
|
), |
||||||
|
sa.PrimaryKeyConstraint("id"), |
||||||
|
) |
||||||
|
|
||||||
|
op.create_table( |
||||||
|
"questions", |
||||||
|
sa.Column("id", sa.Integer(), nullable=False), |
||||||
|
sa.Column("test_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=False), |
||||||
|
sa.Column("text", sa.Text(), nullable=False), |
||||||
|
sa.Column("order", sa.Integer(), nullable=False), |
||||||
|
sa.PrimaryKeyConstraint("id"), |
||||||
|
) |
||||||
|
|
||||||
|
op.create_table( |
||||||
|
"answers", |
||||||
|
sa.Column("id", sa.Integer(), nullable=False), |
||||||
|
sa.Column( |
||||||
|
"question_id", sa.Integer(), sa.ForeignKey("questions.id"), nullable=False |
||||||
|
), |
||||||
|
sa.Column("text", sa.Text(), nullable=False), |
||||||
|
sa.Column("is_correct", sa.Boolean(), nullable=False), |
||||||
|
sa.PrimaryKeyConstraint("id"), |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None: |
||||||
|
op.drop_table("answers") |
||||||
|
op.drop_table("questions") |
||||||
|
op.drop_table("tests") |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
"""attempts |
||||||
|
|
||||||
|
Revision ID: 002 |
||||||
|
Revises: 001 |
||||||
|
Create Date: 2026-03-21 |
||||||
|
|
||||||
|
""" |
||||||
|
from typing import Sequence, Union |
||||||
|
|
||||||
|
import sqlalchemy as sa |
||||||
|
from alembic import op |
||||||
|
|
||||||
|
revision: str = "002" |
||||||
|
down_revision: Union[str, None] = "001" |
||||||
|
branch_labels: Union[str, Sequence[str], None] = None |
||||||
|
depends_on: Union[str, Sequence[str], None] = None |
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None: |
||||||
|
op.create_table( |
||||||
|
"test_attempts", |
||||||
|
sa.Column("id", sa.Integer(), nullable=False), |
||||||
|
sa.Column("test_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=False), |
||||||
|
sa.Column( |
||||||
|
"started_at", |
||||||
|
sa.DateTime(timezone=True), |
||||||
|
server_default=sa.text("now()"), |
||||||
|
nullable=False, |
||||||
|
), |
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), |
||||||
|
sa.Column("score", sa.Float(), nullable=True), |
||||||
|
sa.Column("passed", sa.Boolean(), nullable=True), |
||||||
|
sa.Column("correct_count", sa.Integer(), nullable=True), |
||||||
|
sa.Column("total_count", sa.Integer(), nullable=True), |
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="in_progress"), |
||||||
|
sa.PrimaryKeyConstraint("id"), |
||||||
|
) |
||||||
|
|
||||||
|
op.create_table( |
||||||
|
"attempt_answers", |
||||||
|
sa.Column("id", sa.Integer(), nullable=False), |
||||||
|
sa.Column( |
||||||
|
"attempt_id", sa.Integer(), sa.ForeignKey("test_attempts.id"), nullable=False |
||||||
|
), |
||||||
|
sa.Column("question_id", sa.Integer(), sa.ForeignKey("questions.id"), nullable=False), |
||||||
|
sa.Column("answer_id", sa.Integer(), sa.ForeignKey("answers.id"), nullable=False), |
||||||
|
sa.PrimaryKeyConstraint("id"), |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None: |
||||||
|
op.drop_table("attempt_answers") |
||||||
|
op.drop_table("test_attempts") |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
"""test versioning |
||||||
|
|
||||||
|
Revision ID: 003 |
||||||
|
Revises: 002 |
||||||
|
Create Date: 2026-03-21 |
||||||
|
|
||||||
|
""" |
||||||
|
from typing import Sequence, Union |
||||||
|
|
||||||
|
import sqlalchemy as sa |
||||||
|
from alembic import op |
||||||
|
|
||||||
|
revision: str = "003" |
||||||
|
down_revision: Union[str, None] = "002" |
||||||
|
branch_labels: Union[str, Sequence[str], None] = None |
||||||
|
depends_on: Union[str, Sequence[str], None] = None |
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None: |
||||||
|
op.add_column( |
||||||
|
"tests", |
||||||
|
sa.Column("parent_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=True), |
||||||
|
) |
||||||
|
op.create_index("ix_tests_parent_id", "tests", ["parent_id"]) |
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None: |
||||||
|
op.drop_index("ix_tests_parent_id", table_name="tests") |
||||||
|
op.drop_column("tests", "parent_id") |
||||||
@ -0,0 +1,223 @@ |
|||||||
|
import random |
||||||
|
from datetime import datetime, timezone |
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException |
||||||
|
from sqlalchemy import select |
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession |
||||||
|
from sqlalchemy.orm import selectinload |
||||||
|
|
||||||
|
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 ( |
||||||
|
AttemptResult, |
||||||
|
AttemptStart, |
||||||
|
AttemptStarted, |
||||||
|
AttemptSubmitDto, |
||||||
|
AnswerForTest, |
||||||
|
AnswerResult, |
||||||
|
QuestionForTest, |
||||||
|
QuestionResult, |
||||||
|
) |
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/attempts", tags=["attempts"]) |
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AttemptStarted, status_code=201) |
||||||
|
async def start_attempt(data: AttemptStart, db: AsyncSession = Depends(get_db)): |
||||||
|
"""Начать попытку прохождения теста. Возвращает вопросы в случайном порядке без правильных ответов.""" |
||||||
|
|
||||||
|
result = await db.execute( |
||||||
|
select(Test) |
||||||
|
.options(selectinload(Test.questions).selectinload(Question.answers)) |
||||||
|
.where(Test.id == data.test_id, Test.is_active == True) |
||||||
|
) |
||||||
|
test = result.scalar_one_or_none() |
||||||
|
if not test: |
||||||
|
raise HTTPException(status_code=404, detail="Тест не найден") |
||||||
|
|
||||||
|
attempt = TestAttempt(test_id=test.id, status="in_progress") |
||||||
|
db.add(attempt) |
||||||
|
await db.commit() |
||||||
|
await db.refresh(attempt) |
||||||
|
|
||||||
|
# Перемешиваем вопросы и ответы случайно |
||||||
|
questions_shuffled = list(test.questions) |
||||||
|
random.shuffle(questions_shuffled) |
||||||
|
|
||||||
|
questions_for_test = [] |
||||||
|
for q in questions_shuffled: |
||||||
|
correct_count = sum(1 for a in q.answers if a.is_correct) |
||||||
|
answers_shuffled = list(q.answers) |
||||||
|
random.shuffle(answers_shuffled) |
||||||
|
questions_for_test.append( |
||||||
|
QuestionForTest( |
||||||
|
id=q.id, |
||||||
|
text=q.text, |
||||||
|
is_multiple=correct_count > 1, |
||||||
|
answers=[AnswerForTest(id=a.id, text=a.text) for a in answers_shuffled], |
||||||
|
) |
||||||
|
) |
||||||
|
|
||||||
|
return AttemptStarted( |
||||||
|
id=attempt.id, |
||||||
|
test_id=test.id, |
||||||
|
test_title=test.title, |
||||||
|
test_description=test.description, |
||||||
|
started_at=attempt.started_at, |
||||||
|
time_limit=test.time_limit, |
||||||
|
allow_navigation_back=test.allow_navigation_back, |
||||||
|
questions=questions_for_test, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{attempt_id}/submit", response_model=AttemptResult) |
||||||
|
async def submit_attempt( |
||||||
|
attempt_id: int, data: AttemptSubmitDto, db: AsyncSession = Depends(get_db) |
||||||
|
): |
||||||
|
"""Завершить попытку: сохранить ответы, подсчитать результат.""" |
||||||
|
|
||||||
|
result = await db.execute( |
||||||
|
select(TestAttempt) |
||||||
|
.where(TestAttempt.id == attempt_id, TestAttempt.status == "in_progress") |
||||||
|
) |
||||||
|
attempt = result.scalar_one_or_none() |
||||||
|
if not attempt: |
||||||
|
raise HTTPException(status_code=404, detail="Попытка не найдена или уже завершена") |
||||||
|
|
||||||
|
# Загружаем тест с вопросами и ответами |
||||||
|
test_result = await db.execute( |
||||||
|
select(Test) |
||||||
|
.options(selectinload(Test.questions).selectinload(Question.answers)) |
||||||
|
.where(Test.id == attempt.test_id) |
||||||
|
) |
||||||
|
test = test_result.scalar_one() |
||||||
|
|
||||||
|
# Сохраняем выбранные ответы |
||||||
|
submitted = {qa.question_id: set(qa.answer_ids) for qa in data.answers} |
||||||
|
|
||||||
|
for qa in data.answers: |
||||||
|
for answer_id in qa.answer_ids: |
||||||
|
db.add(AttemptAnswer( |
||||||
|
attempt_id=attempt.id, |
||||||
|
question_id=qa.question_id, |
||||||
|
answer_id=answer_id, |
||||||
|
)) |
||||||
|
|
||||||
|
# Подсчёт результата |
||||||
|
correct_count = 0 |
||||||
|
question_results = [] |
||||||
|
|
||||||
|
for question in test.questions: |
||||||
|
correct_ids = {a.id for a in question.answers if a.is_correct} |
||||||
|
selected_ids = submitted.get(question.id, set()) |
||||||
|
is_correct = selected_ids == correct_ids |
||||||
|
|
||||||
|
if is_correct: |
||||||
|
correct_count += 1 |
||||||
|
|
||||||
|
question_results.append( |
||||||
|
QuestionResult( |
||||||
|
id=question.id, |
||||||
|
text=question.text, |
||||||
|
is_answered_correctly=is_correct, |
||||||
|
answers=[ |
||||||
|
AnswerResult( |
||||||
|
id=a.id, |
||||||
|
text=a.text, |
||||||
|
is_correct=a.is_correct, |
||||||
|
is_selected=a.id in selected_ids, |
||||||
|
) |
||||||
|
for a in question.answers |
||||||
|
], |
||||||
|
) |
||||||
|
) |
||||||
|
|
||||||
|
total = len(test.questions) |
||||||
|
score = round((correct_count / total) * 100, 1) if total > 0 else 0.0 |
||||||
|
passed = score >= test.passing_score |
||||||
|
finished_at = datetime.now(timezone.utc) |
||||||
|
|
||||||
|
attempt.finished_at = finished_at |
||||||
|
attempt.score = score |
||||||
|
attempt.passed = passed |
||||||
|
attempt.correct_count = correct_count |
||||||
|
attempt.total_count = total |
||||||
|
attempt.status = "finished" |
||||||
|
|
||||||
|
await db.commit() |
||||||
|
|
||||||
|
return AttemptResult( |
||||||
|
id=attempt.id, |
||||||
|
test_id=test.id, |
||||||
|
test_title=test.title, |
||||||
|
started_at=attempt.started_at, |
||||||
|
finished_at=finished_at, |
||||||
|
score=score, |
||||||
|
passed=passed, |
||||||
|
passing_score=test.passing_score, |
||||||
|
correct_count=correct_count, |
||||||
|
total_count=total, |
||||||
|
questions=question_results, |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{attempt_id}/result", response_model=AttemptResult) |
||||||
|
async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)): |
||||||
|
"""Получить результат завершённой попытки.""" |
||||||
|
|
||||||
|
result = await db.execute( |
||||||
|
select(TestAttempt) |
||||||
|
.options(selectinload(TestAttempt.answers).selectinload(AttemptAnswer.answer)) |
||||||
|
.where(TestAttempt.id == attempt_id, TestAttempt.status == "finished") |
||||||
|
) |
||||||
|
attempt = result.scalar_one_or_none() |
||||||
|
if not attempt: |
||||||
|
raise HTTPException(status_code=404, detail="Результат не найден") |
||||||
|
|
||||||
|
test_result = await db.execute( |
||||||
|
select(Test) |
||||||
|
.options(selectinload(Test.questions).selectinload(Question.answers)) |
||||||
|
.where(Test.id == attempt.test_id) |
||||||
|
) |
||||||
|
test = test_result.scalar_one() |
||||||
|
|
||||||
|
# Восстанавливаем выбранные ответы из БД |
||||||
|
selected_by_question: dict[int, set[int]] = {} |
||||||
|
for aa in attempt.answers: |
||||||
|
selected_by_question.setdefault(aa.question_id, set()).add(aa.answer_id) |
||||||
|
|
||||||
|
question_results = [] |
||||||
|
for question in test.questions: |
||||||
|
correct_ids = {a.id for a in question.answers if a.is_correct} |
||||||
|
selected_ids = selected_by_question.get(question.id, set()) |
||||||
|
question_results.append( |
||||||
|
QuestionResult( |
||||||
|
id=question.id, |
||||||
|
text=question.text, |
||||||
|
is_answered_correctly=selected_ids == correct_ids, |
||||||
|
answers=[ |
||||||
|
AnswerResult( |
||||||
|
id=a.id, |
||||||
|
text=a.text, |
||||||
|
is_correct=a.is_correct, |
||||||
|
is_selected=a.id in selected_ids, |
||||||
|
) |
||||||
|
for a in question.answers |
||||||
|
], |
||||||
|
) |
||||||
|
) |
||||||
|
|
||||||
|
return AttemptResult( |
||||||
|
id=attempt.id, |
||||||
|
test_id=test.id, |
||||||
|
test_title=test.title, |
||||||
|
started_at=attempt.started_at, |
||||||
|
finished_at=attempt.finished_at, |
||||||
|
score=attempt.score, |
||||||
|
passed=attempt.passed, |
||||||
|
passing_score=test.passing_score, |
||||||
|
correct_count=attempt.correct_count, |
||||||
|
total_count=attempt.total_count, |
||||||
|
questions=question_results, |
||||||
|
) |
||||||
@ -0,0 +1,236 @@ |
|||||||
|
from fastapi import APIRouter, Depends, HTTPException |
||||||
|
from sqlalchemy import delete, func, select, update |
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession |
||||||
|
from sqlalchemy.orm import selectinload |
||||||
|
|
||||||
|
from app.database import get_db |
||||||
|
from app.models.attempt import TestAttempt |
||||||
|
from app.models.test import Answer, Question, Test |
||||||
|
from app.schemas.test import TestCreate, TestListItem, TestOut, TestUpdateResponse |
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/tests", tags=["tests"]) |
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TestListItem]) |
||||||
|
async def list_tests(db: AsyncSession = Depends(get_db)): |
||||||
|
# Показываем только активные версии (is_active = True) |
||||||
|
result = await db.execute( |
||||||
|
select(Test) |
||||||
|
.options(selectinload(Test.questions)) |
||||||
|
.where(Test.is_active == True) |
||||||
|
.order_by(Test.created_at.desc()) |
||||||
|
) |
||||||
|
tests = result.scalars().all() |
||||||
|
|
||||||
|
items = [] |
||||||
|
for test in tests: |
||||||
|
item = TestListItem.model_validate(test) |
||||||
|
item.questions_count = len(test.questions) |
||||||
|
items.append(item) |
||||||
|
|
||||||
|
return items |
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{test_id}", response_model=TestOut) |
||||||
|
async def get_test(test_id: int, db: AsyncSession = Depends(get_db)): |
||||||
|
# Загружаем любую версию по id (без фильтра is_active — нужно для просмотра истории) |
||||||
|
result = await db.execute( |
||||||
|
select(Test) |
||||||
|
.options(selectinload(Test.questions).selectinload(Question.answers)) |
||||||
|
.where(Test.id == test_id) |
||||||
|
) |
||||||
|
test = result.scalar_one_or_none() |
||||||
|
if not test: |
||||||
|
raise HTTPException(status_code=404, detail="Тест не найден") |
||||||
|
return test |
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TestOut, status_code=201) |
||||||
|
async def create_test(data: TestCreate, db: AsyncSession = Depends(get_db)): |
||||||
|
test = Test( |
||||||
|
title=data.title, |
||||||
|
description=data.description, |
||||||
|
passing_score=data.passing_score, |
||||||
|
time_limit=data.time_limit, |
||||||
|
allow_navigation_back=data.allow_navigation_back, |
||||||
|
) |
||||||
|
db.add(test) |
||||||
|
await db.flush() |
||||||
|
|
||||||
|
for order, q_data in enumerate(data.questions): |
||||||
|
question = Question(test_id=test.id, text=q_data.text, order=order) |
||||||
|
db.add(question) |
||||||
|
await db.flush() |
||||||
|
|
||||||
|
for a_data in q_data.answers: |
||||||
|
db.add(Answer( |
||||||
|
question_id=question.id, |
||||||
|
text=a_data.text, |
||||||
|
is_correct=a_data.is_correct, |
||||||
|
)) |
||||||
|
|
||||||
|
await db.commit() |
||||||
|
|
||||||
|
result = await db.execute( |
||||||
|
select(Test) |
||||||
|
.options(selectinload(Test.questions).selectinload(Question.answers)) |
||||||
|
.where(Test.id == test.id) |
||||||
|
) |
||||||
|
return result.scalar_one() |
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{test_id}/versions", response_model=list[TestListItem]) |
||||||
|
async def get_test_versions(test_id: int, db: AsyncSession = Depends(get_db)): |
||||||
|
"""Возвращает все версии теста (от первой к последней).""" |
||||||
|
result = await db.execute(select(Test).where(Test.id == test_id)) |
||||||
|
test = result.scalar_one_or_none() |
||||||
|
if not test: |
||||||
|
raise HTTPException(status_code=404, detail="Тест не найден") |
||||||
|
|
||||||
|
# Идём вверх до корневой версии |
||||||
|
root = test |
||||||
|
while root.parent_id is not None: |
||||||
|
result = await db.execute(select(Test).where(Test.id == root.parent_id)) |
||||||
|
root = result.scalar_one() |
||||||
|
|
||||||
|
# Идём вниз от корня, собирая цепочку |
||||||
|
versions: list[Test] = [] |
||||||
|
current = root |
||||||
|
while current is not None: |
||||||
|
result = await db.execute( |
||||||
|
select(Test) |
||||||
|
.options(selectinload(Test.questions)) |
||||||
|
.where(Test.id == current.id) |
||||||
|
) |
||||||
|
current_with_qs = result.scalar_one() |
||||||
|
versions.append(current_with_qs) |
||||||
|
|
||||||
|
result = await db.execute(select(Test).where(Test.parent_id == current.id)) |
||||||
|
current = result.scalar_one_or_none() |
||||||
|
|
||||||
|
items = [] |
||||||
|
for v in versions: |
||||||
|
item = TestListItem.model_validate(v) |
||||||
|
item.questions_count = len(v.questions) |
||||||
|
items.append(item) |
||||||
|
return items |
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/activate", response_model=TestOut) |
||||||
|
async def activate_test_version(test_id: int, db: AsyncSession = Depends(get_db)): |
||||||
|
"""Делает указанную версию активной, деактивирует все остальные в цепочке.""" |
||||||
|
result = await db.execute(select(Test).where(Test.id == test_id)) |
||||||
|
test = result.scalar_one_or_none() |
||||||
|
if not test: |
||||||
|
raise HTTPException(status_code=404, detail="Тест не найден") |
||||||
|
|
||||||
|
# Идём вверх до корневой версии |
||||||
|
root = test |
||||||
|
while root.parent_id is not None: |
||||||
|
result = await db.execute(select(Test).where(Test.id == root.parent_id)) |
||||||
|
root = result.scalar_one() |
||||||
|
|
||||||
|
# Собираем все id версий в цепочке |
||||||
|
all_ids: list[int] = [] |
||||||
|
current = root |
||||||
|
while current is not None: |
||||||
|
all_ids.append(current.id) |
||||||
|
result = await db.execute(select(Test).where(Test.parent_id == current.id)) |
||||||
|
current = result.scalar_one_or_none() |
||||||
|
|
||||||
|
# Деактивируем все, активируем нужную |
||||||
|
await db.execute(update(Test).where(Test.id.in_(all_ids)).values(is_active=False)) |
||||||
|
await db.execute(update(Test).where(Test.id == test_id).values(is_active=True)) |
||||||
|
await db.commit() |
||||||
|
|
||||||
|
result = await db.execute( |
||||||
|
select(Test) |
||||||
|
.options(selectinload(Test.questions).selectinload(Question.answers)) |
||||||
|
.where(Test.id == test_id) |
||||||
|
) |
||||||
|
return result.scalar_one() |
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{test_id}", response_model=TestUpdateResponse) |
||||||
|
async def update_test(test_id: int, data: TestCreate, db: AsyncSession = Depends(get_db)): |
||||||
|
result = await db.execute(select(Test).where(Test.id == test_id)) |
||||||
|
test = result.scalar_one_or_none() |
||||||
|
if not test: |
||||||
|
raise HTTPException(status_code=404, detail="Тест не найден") |
||||||
|
|
||||||
|
attempts_count = await db.scalar( |
||||||
|
select(func.count()).select_from(TestAttempt).where(TestAttempt.test_id == test_id) |
||||||
|
) |
||||||
|
|
||||||
|
if attempts_count == 0: |
||||||
|
# Редактируем на месте |
||||||
|
test.title = data.title |
||||||
|
test.description = data.description |
||||||
|
test.passing_score = data.passing_score |
||||||
|
test.time_limit = data.time_limit |
||||||
|
test.allow_navigation_back = data.allow_navigation_back |
||||||
|
|
||||||
|
# Сначала удаляем ответы (FK: answers.question_id → questions.id) |
||||||
|
q_ids_result = await db.execute(select(Question.id).where(Question.test_id == test_id)) |
||||||
|
q_ids = [row[0] for row in q_ids_result.fetchall()] |
||||||
|
if q_ids: |
||||||
|
await db.execute(delete(Answer).where(Answer.question_id.in_(q_ids))) |
||||||
|
await db.execute(delete(Question).where(Question.test_id == test_id)) |
||||||
|
await db.flush() |
||||||
|
|
||||||
|
for order, q_data in enumerate(data.questions): |
||||||
|
question = Question(test_id=test.id, text=q_data.text, order=order) |
||||||
|
db.add(question) |
||||||
|
await db.flush() |
||||||
|
for a_data in q_data.answers: |
||||||
|
db.add(Answer( |
||||||
|
question_id=question.id, |
||||||
|
text=a_data.text, |
||||||
|
is_correct=a_data.is_correct, |
||||||
|
)) |
||||||
|
|
||||||
|
await db.commit() |
||||||
|
|
||||||
|
result = await db.execute( |
||||||
|
select(Test) |
||||||
|
.options(selectinload(Test.questions).selectinload(Question.answers)) |
||||||
|
.where(Test.id == test.id) |
||||||
|
) |
||||||
|
return {"test": result.scalar_one(), "is_new_version": False} |
||||||
|
|
||||||
|
else: |
||||||
|
# Есть попытки — создаём новую версию, деактивируем текущую |
||||||
|
test.is_active = False |
||||||
|
|
||||||
|
new_test = Test( |
||||||
|
title=data.title, |
||||||
|
description=data.description, |
||||||
|
passing_score=data.passing_score, |
||||||
|
time_limit=data.time_limit, |
||||||
|
allow_navigation_back=data.allow_navigation_back, |
||||||
|
version=test.version + 1, |
||||||
|
parent_id=test.id, |
||||||
|
is_active=True, |
||||||
|
) |
||||||
|
db.add(new_test) |
||||||
|
await db.flush() |
||||||
|
|
||||||
|
for order, q_data in enumerate(data.questions): |
||||||
|
question = Question(test_id=new_test.id, text=q_data.text, order=order) |
||||||
|
db.add(question) |
||||||
|
await db.flush() |
||||||
|
for a_data in q_data.answers: |
||||||
|
db.add(Answer( |
||||||
|
question_id=question.id, |
||||||
|
text=a_data.text, |
||||||
|
is_correct=a_data.is_correct, |
||||||
|
)) |
||||||
|
|
||||||
|
await db.commit() |
||||||
|
|
||||||
|
result = await db.execute( |
||||||
|
select(Test) |
||||||
|
.options(selectinload(Test.questions).selectinload(Question.answers)) |
||||||
|
.where(Test.id == new_test.id) |
||||||
|
) |
||||||
|
return {"test": result.scalar_one(), "is_new_version": True} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
from pydantic_settings import BaseSettings |
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings): |
||||||
|
database_url: str = "postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test" |
||||||
|
|
||||||
|
class Config: |
||||||
|
env_file = ".env" |
||||||
|
|
||||||
|
|
||||||
|
settings = Settings() |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession |
||||||
|
from sqlalchemy.orm import DeclarativeBase |
||||||
|
|
||||||
|
from app.config import settings |
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, echo=True) |
||||||
|
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) |
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase): |
||||||
|
pass |
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession: |
||||||
|
async with AsyncSessionLocal() as session: |
||||||
|
yield session |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
from fastapi import FastAPI |
||||||
|
from fastapi.middleware.cors import CORSMiddleware |
||||||
|
|
||||||
|
from app.api import attempts, tests |
||||||
|
|
||||||
|
app = FastAPI( |
||||||
|
title="QA Test App API", |
||||||
|
version="0.1.0", |
||||||
|
docs_url="/api/docs", |
||||||
|
redoc_url="/api/redoc", |
||||||
|
openapi_url="/api/openapi.json", |
||||||
|
) |
||||||
|
|
||||||
|
app.add_middleware( |
||||||
|
CORSMiddleware, |
||||||
|
allow_origins=["*"], |
||||||
|
allow_methods=["*"], |
||||||
|
allow_headers=["*"], |
||||||
|
) |
||||||
|
|
||||||
|
app.include_router(tests.router) |
||||||
|
app.include_router(attempts.router) |
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health") |
||||||
|
async def health(): |
||||||
|
return {"status": "ok"} |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
from datetime import datetime |
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String |
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship |
||||||
|
from sqlalchemy.sql import func |
||||||
|
|
||||||
|
from app.database import Base |
||||||
|
|
||||||
|
|
||||||
|
class TestAttempt(Base): |
||||||
|
__tablename__ = "test_attempts" |
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True) |
||||||
|
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False) |
||||||
|
started_at: Mapped[datetime] = mapped_column( |
||||||
|
DateTime(timezone=True), server_default=func.now() |
||||||
|
) |
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) |
||||||
|
score: Mapped[float | None] = mapped_column(Float, nullable=True) # процент 0–100 |
||||||
|
passed: Mapped[bool | None] = mapped_column(Boolean, nullable=True) |
||||||
|
correct_count: Mapped[int | None] = mapped_column(Integer, nullable=True) |
||||||
|
total_count: Mapped[int | None] = mapped_column(Integer, nullable=True) |
||||||
|
# in_progress | finished |
||||||
|
status: Mapped[str] = mapped_column(String(20), default="in_progress", nullable=False) |
||||||
|
|
||||||
|
test: Mapped["Test"] = relationship("Test") # type: ignore[name-defined] |
||||||
|
answers: Mapped[list["AttemptAnswer"]] = relationship( |
||||||
|
"AttemptAnswer", back_populates="attempt", cascade="all, delete-orphan" |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
class AttemptAnswer(Base): |
||||||
|
"""Одна запись = один выбранный вариант ответа сотрудника.""" |
||||||
|
|
||||||
|
__tablename__ = "attempt_answers" |
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True) |
||||||
|
attempt_id: Mapped[int] = mapped_column( |
||||||
|
Integer, ForeignKey("test_attempts.id"), nullable=False |
||||||
|
) |
||||||
|
question_id: Mapped[int] = mapped_column( |
||||||
|
Integer, ForeignKey("questions.id"), nullable=False |
||||||
|
) |
||||||
|
answer_id: Mapped[int] = mapped_column( |
||||||
|
Integer, ForeignKey("answers.id"), nullable=False |
||||||
|
) |
||||||
|
|
||||||
|
attempt: Mapped["TestAttempt"] = relationship("TestAttempt", back_populates="answers") |
||||||
|
answer: Mapped["Answer"] = relationship("Answer") # type: ignore[name-defined] |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
from datetime import datetime |
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text |
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship |
||||||
|
from sqlalchemy.sql import func |
||||||
|
|
||||||
|
from app.database import Base |
||||||
|
|
||||||
|
|
||||||
|
class Test(Base): |
||||||
|
__tablename__ = "tests" |
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True) |
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False) |
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True) |
||||||
|
passing_score: Mapped[int] = mapped_column(Integer, nullable=False) |
||||||
|
time_limit: Mapped[int | None] = mapped_column(Integer, nullable=True) # минуты |
||||||
|
allow_navigation_back: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) |
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) |
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) |
||||||
|
parent_id: Mapped[int | None] = mapped_column( |
||||||
|
Integer, ForeignKey("tests.id"), nullable=True |
||||||
|
) |
||||||
|
created_at: Mapped[datetime] = mapped_column( |
||||||
|
DateTime(timezone=True), server_default=func.now() |
||||||
|
) |
||||||
|
|
||||||
|
questions: Mapped[list["Question"]] = relationship( |
||||||
|
"Question", back_populates="test", cascade="all, delete-orphan" |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
class Question(Base): |
||||||
|
__tablename__ = "questions" |
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True) |
||||||
|
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False) |
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False) |
||||||
|
order: Mapped[int] = mapped_column(Integer, nullable=False) |
||||||
|
|
||||||
|
test: Mapped["Test"] = relationship("Test", back_populates="questions") |
||||||
|
answers: Mapped[list["Answer"]] = relationship( |
||||||
|
"Answer", back_populates="question", cascade="all, delete-orphan" |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
class Answer(Base): |
||||||
|
__tablename__ = "answers" |
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True) |
||||||
|
question_id: Mapped[int] = mapped_column( |
||||||
|
Integer, ForeignKey("questions.id"), nullable=False |
||||||
|
) |
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False) |
||||||
|
is_correct: Mapped[bool] = mapped_column(Boolean, nullable=False) |
||||||
|
|
||||||
|
question: Mapped["Question"] = relationship("Question", back_populates="answers") |
||||||
@ -0,0 +1,89 @@ |
|||||||
|
from datetime import datetime |
||||||
|
from typing import Optional |
||||||
|
|
||||||
|
from pydantic import BaseModel |
||||||
|
|
||||||
|
|
||||||
|
# ── Начало попытки ────────────────────────────────────────── |
||||||
|
|
||||||
|
class AttemptStart(BaseModel): |
||||||
|
test_id: int |
||||||
|
|
||||||
|
|
||||||
|
class AnswerForTest(BaseModel): |
||||||
|
"""Вариант ответа без поля is_correct — не раскрываем правильные ответы.""" |
||||||
|
id: int |
||||||
|
text: str |
||||||
|
|
||||||
|
model_config = {"from_attributes": True} |
||||||
|
|
||||||
|
|
||||||
|
class QuestionForTest(BaseModel): |
||||||
|
id: int |
||||||
|
text: str |
||||||
|
is_multiple: bool # True = несколько правильных ответов → показываем чекбоксы |
||||||
|
answers: list[AnswerForTest] # перемешаны случайно |
||||||
|
|
||||||
|
model_config = {"from_attributes": True} |
||||||
|
|
||||||
|
|
||||||
|
class AttemptStarted(BaseModel): |
||||||
|
"""Возвращается после старта попытки.""" |
||||||
|
id: int |
||||||
|
test_id: int |
||||||
|
test_title: str |
||||||
|
test_description: Optional[str] |
||||||
|
started_at: datetime |
||||||
|
time_limit: Optional[int] # минуты, из теста |
||||||
|
allow_navigation_back: bool # из теста |
||||||
|
questions: list[QuestionForTest] # перемешаны случайно |
||||||
|
|
||||||
|
model_config = {"from_attributes": True} |
||||||
|
|
||||||
|
|
||||||
|
# ── Сдача попытки ──────────────────────────────────────────── |
||||||
|
|
||||||
|
class QuestionAnswer(BaseModel): |
||||||
|
"""Ответы сотрудника на один вопрос.""" |
||||||
|
question_id: int |
||||||
|
answer_ids: list[int] # выбранные варианты (может быть пустым) |
||||||
|
|
||||||
|
|
||||||
|
class AttemptSubmitDto(BaseModel): |
||||||
|
answers: list[QuestionAnswer] |
||||||
|
|
||||||
|
|
||||||
|
# ── Результат ──────────────────────────────────────────────── |
||||||
|
|
||||||
|
class AnswerResult(BaseModel): |
||||||
|
id: int |
||||||
|
text: str |
||||||
|
is_correct: bool # правильный ли ответ вообще |
||||||
|
is_selected: bool # выбрал ли его сотрудник |
||||||
|
|
||||||
|
model_config = {"from_attributes": True} |
||||||
|
|
||||||
|
|
||||||
|
class QuestionResult(BaseModel): |
||||||
|
id: int |
||||||
|
text: str |
||||||
|
is_answered_correctly: bool # вся комбинация ответов верна |
||||||
|
answers: list[AnswerResult] |
||||||
|
|
||||||
|
model_config = {"from_attributes": True} |
||||||
|
|
||||||
|
|
||||||
|
class AttemptResult(BaseModel): |
||||||
|
id: int |
||||||
|
test_id: int |
||||||
|
test_title: str |
||||||
|
started_at: datetime |
||||||
|
finished_at: datetime |
||||||
|
score: float # процент правильных ответов |
||||||
|
passed: bool # преодолён ли порог зачёта |
||||||
|
passing_score: int # порог из теста |
||||||
|
correct_count: int |
||||||
|
total_count: int |
||||||
|
questions: list[QuestionResult] |
||||||
|
|
||||||
|
model_config = {"from_attributes": True} |
||||||
@ -0,0 +1,91 @@ |
|||||||
|
from datetime import datetime |
||||||
|
from typing import Optional |
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator |
||||||
|
|
||||||
|
|
||||||
|
class AnswerCreate(BaseModel): |
||||||
|
text: str = Field(min_length=1) |
||||||
|
is_correct: bool |
||||||
|
|
||||||
|
|
||||||
|
class AnswerOut(BaseModel): |
||||||
|
id: int |
||||||
|
text: str |
||||||
|
is_correct: bool |
||||||
|
|
||||||
|
model_config = {"from_attributes": True} |
||||||
|
|
||||||
|
|
||||||
|
class QuestionCreate(BaseModel): |
||||||
|
text: str = Field(min_length=1) |
||||||
|
answers: list[AnswerCreate] |
||||||
|
|
||||||
|
@field_validator("answers") |
||||||
|
@classmethod |
||||||
|
def validate_answers(cls, v: list[AnswerCreate]) -> list[AnswerCreate]: |
||||||
|
if len(v) < 3: |
||||||
|
raise ValueError("Минимум 3 варианта ответа на вопрос") |
||||||
|
if not any(a.is_correct for a in v): |
||||||
|
raise ValueError("Хотя бы один ответ должен быть правильным") |
||||||
|
return v |
||||||
|
|
||||||
|
|
||||||
|
class QuestionOut(BaseModel): |
||||||
|
id: int |
||||||
|
text: str |
||||||
|
order: int |
||||||
|
answers: list[AnswerOut] |
||||||
|
|
||||||
|
model_config = {"from_attributes": True} |
||||||
|
|
||||||
|
|
||||||
|
class TestCreate(BaseModel): |
||||||
|
title: str = Field(min_length=1, max_length=255) |
||||||
|
description: Optional[str] = None |
||||||
|
passing_score: int = Field(ge=0, le=100) |
||||||
|
time_limit: Optional[int] = Field(None, ge=1) |
||||||
|
allow_navigation_back: bool = True |
||||||
|
questions: list[QuestionCreate] |
||||||
|
|
||||||
|
@field_validator("questions") |
||||||
|
@classmethod |
||||||
|
def validate_questions(cls, v: list[QuestionCreate]) -> list[QuestionCreate]: |
||||||
|
if len(v) < 7: |
||||||
|
raise ValueError("Минимум 7 вопросов в тесте") |
||||||
|
return v |
||||||
|
|
||||||
|
|
||||||
|
class TestOut(BaseModel): |
||||||
|
id: int |
||||||
|
title: str |
||||||
|
description: Optional[str] |
||||||
|
passing_score: int |
||||||
|
time_limit: Optional[int] |
||||||
|
allow_navigation_back: bool |
||||||
|
is_active: bool |
||||||
|
version: int |
||||||
|
parent_id: Optional[int] |
||||||
|
created_at: datetime |
||||||
|
questions: list[QuestionOut] = [] |
||||||
|
|
||||||
|
model_config = {"from_attributes": True} |
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateResponse(BaseModel): |
||||||
|
test: TestOut |
||||||
|
is_new_version: bool |
||||||
|
|
||||||
|
|
||||||
|
class TestListItem(BaseModel): |
||||||
|
id: int |
||||||
|
title: str |
||||||
|
description: Optional[str] |
||||||
|
passing_score: int |
||||||
|
time_limit: Optional[int] |
||||||
|
is_active: bool |
||||||
|
version: int |
||||||
|
created_at: datetime |
||||||
|
questions_count: int = 0 |
||||||
|
|
||||||
|
model_config = {"from_attributes": True} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
#!/bin/bash |
||||||
|
set -e |
||||||
|
|
||||||
|
echo "Waiting for PostgreSQL..." |
||||||
|
until pg_isready -h db -p 5432 -U qa_user; do |
||||||
|
sleep 1 |
||||||
|
done |
||||||
|
|
||||||
|
echo "Running migrations..." |
||||||
|
alembic upgrade head |
||||||
|
|
||||||
|
echo "Starting server..." |
||||||
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
fastapi==0.115.0 |
||||||
|
uvicorn[standard]==0.30.6 |
||||||
|
sqlalchemy==2.0.35 |
||||||
|
asyncpg==0.29.0 |
||||||
|
alembic==1.13.3 |
||||||
|
pydantic==2.9.2 |
||||||
|
pydantic-settings==2.5.2 |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
services: |
||||||
|
|
||||||
|
db: |
||||||
|
image: postgres:16-alpine |
||||||
|
environment: |
||||||
|
POSTGRES_DB: qa_test |
||||||
|
POSTGRES_USER: qa_user |
||||||
|
POSTGRES_PASSWORD: qa_password |
||||||
|
volumes: |
||||||
|
- postgres_data:/var/lib/postgresql/data |
||||||
|
healthcheck: |
||||||
|
test: ["CMD-SHELL", "pg_isready -U qa_user -d qa_test"] |
||||||
|
interval: 5s |
||||||
|
timeout: 5s |
||||||
|
retries: 10 |
||||||
|
|
||||||
|
backend: |
||||||
|
build: ./backend |
||||||
|
environment: |
||||||
|
DATABASE_URL: postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test |
||||||
|
depends_on: |
||||||
|
db: |
||||||
|
condition: service_healthy |
||||||
|
volumes: |
||||||
|
- ./backend:/app |
||||||
|
|
||||||
|
frontend: |
||||||
|
build: ./frontend |
||||||
|
volumes: |
||||||
|
- ./frontend/src:/app/src |
||||||
|
- ./frontend/index.html:/app/index.html |
||||||
|
depends_on: |
||||||
|
- backend |
||||||
|
|
||||||
|
nginx: |
||||||
|
image: nginx:alpine |
||||||
|
ports: |
||||||
|
- "80:80" |
||||||
|
volumes: |
||||||
|
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf |
||||||
|
depends_on: |
||||||
|
- backend |
||||||
|
- frontend |
||||||
|
|
||||||
|
volumes: |
||||||
|
postgres_data: |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
FROM node:20-alpine |
||||||
|
|
||||||
|
WORKDIR /app |
||||||
|
|
||||||
|
COPY package*.json ./ |
||||||
|
RUN npm install |
||||||
|
|
||||||
|
COPY . . |
||||||
|
|
||||||
|
EXPOSE 5173 |
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html lang="ru"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||||
|
<title>QA Test App</title> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div id="root"></div> |
||||||
|
<script type="module" src="/src/main.tsx"></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
{ |
||||||
|
"name": "qa-test-frontend", |
||||||
|
"version": "1.0.0", |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"dev": "vite", |
||||||
|
"build": "tsc && vite build", |
||||||
|
"preview": "vite preview" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@ant-design/icons": "^5.4.0", |
||||||
|
"@tanstack/react-query": "^5.59.0", |
||||||
|
"antd": "^5.21.0", |
||||||
|
"axios": "^1.7.7", |
||||||
|
"react": "^18.3.1", |
||||||
|
"react-dom": "^18.3.1", |
||||||
|
"react-router-dom": "^6.27.0" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@types/react": "^18.3.11", |
||||||
|
"@types/react-dom": "^18.3.1", |
||||||
|
"@vitejs/plugin-react": "^4.3.2", |
||||||
|
"typescript": "^5.6.3", |
||||||
|
"vite": "^5.4.8" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' |
||||||
|
import { ConfigProvider } from 'antd' |
||||||
|
import ruRU from 'antd/locale/ru_RU' |
||||||
|
import { BrowserRouter, Route, Routes } from 'react-router-dom' |
||||||
|
|
||||||
|
import AttemptResult from './pages/AttemptResult' |
||||||
|
import TestCreate from './pages/TestCreate' |
||||||
|
import TestDetail from './pages/TestDetail' |
||||||
|
import TestEdit from './pages/TestEdit' |
||||||
|
import TestList from './pages/TestList' |
||||||
|
import TestTake from './pages/TestTake' |
||||||
|
|
||||||
|
const queryClient = new QueryClient() |
||||||
|
|
||||||
|
export default function App() { |
||||||
|
return ( |
||||||
|
<QueryClientProvider client={queryClient}> |
||||||
|
<ConfigProvider locale={ruRU}> |
||||||
|
<BrowserRouter> |
||||||
|
<Routes> |
||||||
|
<Route path="/" element={<TestList />} /> |
||||||
|
<Route path="/tests/create" element={<TestCreate />} /> |
||||||
|
<Route path="/tests/:id" element={<TestDetail />} /> |
||||||
|
<Route path="/tests/:id/edit" element={<TestEdit />} /> |
||||||
|
<Route path="/tests/:testId/take" element={<TestTake />} /> |
||||||
|
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} /> |
||||||
|
</Routes> |
||||||
|
</BrowserRouter> |
||||||
|
</ConfigProvider> |
||||||
|
</QueryClientProvider> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,68 @@ |
|||||||
|
import client from './client' |
||||||
|
|
||||||
|
export interface AnswerForTest { |
||||||
|
id: number |
||||||
|
text: string |
||||||
|
} |
||||||
|
|
||||||
|
export interface QuestionForTest { |
||||||
|
id: number |
||||||
|
text: string |
||||||
|
is_multiple: boolean // true → показываем чекбоксы, false → радио-кнопки
|
||||||
|
answers: AnswerForTest[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface AttemptStarted { |
||||||
|
id: number |
||||||
|
test_id: number |
||||||
|
test_title: string |
||||||
|
test_description: string | null |
||||||
|
started_at: string |
||||||
|
time_limit: number | null // минуты
|
||||||
|
allow_navigation_back: boolean |
||||||
|
questions: QuestionForTest[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface QuestionAnswer { |
||||||
|
question_id: number |
||||||
|
answer_ids: number[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface AnswerResult { |
||||||
|
id: number |
||||||
|
text: string |
||||||
|
is_correct: boolean |
||||||
|
is_selected: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export interface QuestionResult { |
||||||
|
id: number |
||||||
|
text: string |
||||||
|
is_answered_correctly: boolean |
||||||
|
answers: AnswerResult[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface AttemptResult { |
||||||
|
id: number |
||||||
|
test_id: number |
||||||
|
test_title: string |
||||||
|
started_at: string |
||||||
|
finished_at: string |
||||||
|
score: number |
||||||
|
passed: boolean |
||||||
|
passing_score: number |
||||||
|
correct_count: number |
||||||
|
total_count: number |
||||||
|
questions: QuestionResult[] |
||||||
|
} |
||||||
|
|
||||||
|
export const attemptsApi = { |
||||||
|
start: (test_id: number) => |
||||||
|
client.post<AttemptStarted>('/attempts', { test_id }), |
||||||
|
|
||||||
|
submit: (attempt_id: number, answers: QuestionAnswer[]) => |
||||||
|
client.post<AttemptResult>(`/attempts/${attempt_id}/submit`, { answers }), |
||||||
|
|
||||||
|
getResult: (attempt_id: number) => |
||||||
|
client.get<AttemptResult>(`/attempts/${attempt_id}/result`), |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
import axios from 'axios' |
||||||
|
|
||||||
|
const client = axios.create({ |
||||||
|
baseURL: '/api', |
||||||
|
}) |
||||||
|
|
||||||
|
export default client |
||||||
@ -0,0 +1,74 @@ |
|||||||
|
import client from './client' |
||||||
|
|
||||||
|
export interface Answer { |
||||||
|
id: number |
||||||
|
text: string |
||||||
|
is_correct: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export interface Question { |
||||||
|
id: number |
||||||
|
text: string |
||||||
|
order: number |
||||||
|
answers: Answer[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface Test { |
||||||
|
id: number |
||||||
|
title: string |
||||||
|
description: string | null |
||||||
|
passing_score: number |
||||||
|
time_limit: number | null |
||||||
|
allow_navigation_back: boolean |
||||||
|
is_active: boolean |
||||||
|
version: number |
||||||
|
parent_id: number | null |
||||||
|
created_at: string |
||||||
|
questions: Question[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface UpdateTestResponse { |
||||||
|
test: Test |
||||||
|
is_new_version: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export interface TestListItem { |
||||||
|
id: number |
||||||
|
title: string |
||||||
|
description: string | null |
||||||
|
passing_score: number |
||||||
|
time_limit: number | null |
||||||
|
is_active: boolean |
||||||
|
version: number |
||||||
|
created_at: string |
||||||
|
questions_count: number |
||||||
|
} |
||||||
|
|
||||||
|
export interface CreateAnswerDto { |
||||||
|
text: string |
||||||
|
is_correct: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export interface CreateQuestionDto { |
||||||
|
text: string |
||||||
|
answers: CreateAnswerDto[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface CreateTestDto { |
||||||
|
title: string |
||||||
|
description?: string |
||||||
|
passing_score: number |
||||||
|
time_limit?: number |
||||||
|
allow_navigation_back: boolean |
||||||
|
questions: CreateQuestionDto[] |
||||||
|
} |
||||||
|
|
||||||
|
export const testsApi = { |
||||||
|
list: () => client.get<TestListItem[]>('/tests'), |
||||||
|
get: (id: number) => client.get<Test>(`/tests/${id}`), |
||||||
|
create: (data: CreateTestDto) => client.post<Test>('/tests', data), |
||||||
|
update: (id: number, data: CreateTestDto) => |
||||||
|
client.put<UpdateTestResponse>(`/tests/${id}`, data), |
||||||
|
versions: (id: number) => client.get<TestListItem[]>(`/tests/${id}/versions`), |
||||||
|
activate: (id: number) => client.post<Test>(`/tests/${id}/activate`), |
||||||
|
} |
||||||
@ -0,0 +1,268 @@ |
|||||||
|
import { ArrowLeftOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons' |
||||||
|
import { |
||||||
|
Button, |
||||||
|
Card, |
||||||
|
Checkbox, |
||||||
|
Form, |
||||||
|
Input, |
||||||
|
InputNumber, |
||||||
|
Space, |
||||||
|
Switch, |
||||||
|
Typography, |
||||||
|
} from 'antd' |
||||||
|
|
||||||
|
|
||||||
|
const { Title } = Typography |
||||||
|
|
||||||
|
const EMPTY_ANSWER = { text: '', is_correct: false } |
||||||
|
const EMPTY_QUESTION = { text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] } |
||||||
|
|
||||||
|
export interface TestFormValues { |
||||||
|
title: string |
||||||
|
description?: string |
||||||
|
passing_score: number |
||||||
|
has_timer: boolean |
||||||
|
time_limit?: number |
||||||
|
allow_navigation_back: boolean |
||||||
|
questions: { text: string; answers: { text: string; is_correct: boolean }[] }[] |
||||||
|
} |
||||||
|
|
||||||
|
interface TestFormProps { |
||||||
|
heading: string |
||||||
|
initialValues?: Partial<TestFormValues> |
||||||
|
onSubmit: (values: TestFormValues) => void |
||||||
|
isPending: boolean |
||||||
|
submitLabel: string |
||||||
|
onCancel: () => void |
||||||
|
onBack?: () => void |
||||||
|
backLabel?: string |
||||||
|
} |
||||||
|
|
||||||
|
export default function TestForm({ |
||||||
|
heading, |
||||||
|
initialValues, |
||||||
|
onSubmit, |
||||||
|
isPending, |
||||||
|
submitLabel, |
||||||
|
onCancel, |
||||||
|
onBack, |
||||||
|
backLabel = 'Назад', |
||||||
|
}: TestFormProps) { |
||||||
|
const [form] = Form.useForm<TestFormValues>() |
||||||
|
|
||||||
|
const defaultValues: Partial<TestFormValues> = { |
||||||
|
allow_navigation_back: true, |
||||||
|
has_timer: false, |
||||||
|
passing_score: 70, |
||||||
|
questions: Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION })), |
||||||
|
...initialValues, |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}> |
||||||
|
{onBack && ( |
||||||
|
<Button |
||||||
|
icon={<ArrowLeftOutlined />} |
||||||
|
onClick={onBack} |
||||||
|
style={{ marginBottom: 16 }} |
||||||
|
> |
||||||
|
{backLabel} |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
<Title level={2}>{heading}</Title> |
||||||
|
|
||||||
|
<Form form={form} layout="vertical" onFinish={onSubmit} initialValues={defaultValues}> |
||||||
|
{/* ── Основные настройки ── */} |
||||||
|
<Card title="Основные настройки" style={{ marginBottom: 16 }}> |
||||||
|
<Form.Item |
||||||
|
name="title" |
||||||
|
label="Название теста" |
||||||
|
rules={[{ required: true, message: 'Введите название теста' }]} |
||||||
|
> |
||||||
|
<Input placeholder="Например: Пожарная безопасность 2026" /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.Item name="description" label="Описание (необязательно)"> |
||||||
|
<Input.TextArea rows={2} placeholder="Краткое описание теста" /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.Item |
||||||
|
name="passing_score" |
||||||
|
label="Порог зачёта" |
||||||
|
rules={[{ required: true, message: 'Укажите порог' }]} |
||||||
|
> |
||||||
|
<InputNumber min={0} max={100} addonAfter="%" style={{ width: 140 }} /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.Item label="Ограничение по времени"> |
||||||
|
<Space align="center"> |
||||||
|
<Form.Item name="has_timer" valuePropName="checked" noStyle> |
||||||
|
<Switch /> |
||||||
|
</Form.Item> |
||||||
|
<Form.Item |
||||||
|
noStyle |
||||||
|
shouldUpdate={(prev, cur) => prev.has_timer !== cur.has_timer} |
||||||
|
> |
||||||
|
{({ getFieldValue }) => |
||||||
|
getFieldValue('has_timer') ? ( |
||||||
|
<Form.Item |
||||||
|
name="time_limit" |
||||||
|
noStyle |
||||||
|
rules={[{ required: true, message: 'Укажите время' }]} |
||||||
|
> |
||||||
|
<InputNumber min={1} addonAfter="мин" style={{ width: 150 }} /> |
||||||
|
</Form.Item> |
||||||
|
) : ( |
||||||
|
<span style={{ color: '#999' }}>без ограничения</span> |
||||||
|
) |
||||||
|
} |
||||||
|
</Form.Item> |
||||||
|
</Space> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.Item |
||||||
|
name="allow_navigation_back" |
||||||
|
label="Разрешить возврат к предыдущему вопросу" |
||||||
|
valuePropName="checked" |
||||||
|
> |
||||||
|
<Switch /> |
||||||
|
</Form.Item> |
||||||
|
</Card> |
||||||
|
|
||||||
|
{/* ── Вопросы ── */} |
||||||
|
<Form.List |
||||||
|
name="questions" |
||||||
|
rules={[ |
||||||
|
{ |
||||||
|
validator: async (_, questions) => { |
||||||
|
if (!questions || questions.length < 7) { |
||||||
|
return Promise.reject(new Error('Минимум 7 вопросов')) |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
]} |
||||||
|
> |
||||||
|
{(questionFields, { add: addQuestion, remove: removeQuestion }, { errors }) => ( |
||||||
|
<> |
||||||
|
{questionFields.map(({ key, name: qName }, index) => ( |
||||||
|
<Card |
||||||
|
key={key} |
||||||
|
title={`Вопрос ${index + 1}`} |
||||||
|
extra={ |
||||||
|
questionFields.length > 7 ? ( |
||||||
|
<MinusCircleOutlined |
||||||
|
style={{ color: '#ff4d4f', fontSize: 16 }} |
||||||
|
onClick={() => removeQuestion(qName)} |
||||||
|
/> |
||||||
|
) : null |
||||||
|
} |
||||||
|
style={{ marginBottom: 16 }} |
||||||
|
> |
||||||
|
<Form.Item |
||||||
|
name={[qName, 'text']} |
||||||
|
rules={[{ required: true, message: 'Введите текст вопроса' }]} |
||||||
|
> |
||||||
|
<Input.TextArea rows={2} placeholder="Текст вопроса" /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.List |
||||||
|
name={[qName, 'answers']} |
||||||
|
rules={[ |
||||||
|
{ |
||||||
|
validator: async (_, answers) => { |
||||||
|
if (!answers || answers.length < 3) { |
||||||
|
return Promise.reject(new Error('Минимум 3 варианта ответа')) |
||||||
|
} |
||||||
|
if (!answers.some((a: { is_correct: boolean }) => a?.is_correct)) { |
||||||
|
return Promise.reject( |
||||||
|
new Error('Отметьте хотя бы один правильный ответ'), |
||||||
|
) |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
]} |
||||||
|
> |
||||||
|
{( |
||||||
|
answerFields, |
||||||
|
{ add: addAnswer, remove: removeAnswer }, |
||||||
|
{ errors: answerErrors }, |
||||||
|
) => ( |
||||||
|
<> |
||||||
|
{answerFields.map(({ key: ak, name: aName }) => ( |
||||||
|
<Space |
||||||
|
key={ak} |
||||||
|
style={{ display: 'flex', marginBottom: 8 }} |
||||||
|
align="start" |
||||||
|
> |
||||||
|
<Form.Item |
||||||
|
name={[aName, 'is_correct']} |
||||||
|
valuePropName="checked" |
||||||
|
initialValue={false} |
||||||
|
style={{ marginBottom: 0 }} |
||||||
|
> |
||||||
|
<Checkbox /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
<Form.Item |
||||||
|
name={[aName, 'text']} |
||||||
|
rules={[{ required: true, message: 'Введите вариант ответа' }]} |
||||||
|
style={{ marginBottom: 0, flex: 1 }} |
||||||
|
> |
||||||
|
<Input placeholder="Вариант ответа" style={{ width: 440 }} /> |
||||||
|
</Form.Item> |
||||||
|
|
||||||
|
{answerFields.length > 3 && ( |
||||||
|
<MinusCircleOutlined |
||||||
|
style={{ color: '#ff4d4f' }} |
||||||
|
onClick={() => removeAnswer(aName)} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
))} |
||||||
|
|
||||||
|
<Form.ErrorList errors={answerErrors} /> |
||||||
|
|
||||||
|
<Button |
||||||
|
type="dashed" |
||||||
|
size="small" |
||||||
|
icon={<PlusOutlined />} |
||||||
|
onClick={() => addAnswer({ text: '', is_correct: false })} |
||||||
|
style={{ marginTop: 4 }} |
||||||
|
> |
||||||
|
Добавить вариант |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</Form.List> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
|
||||||
|
<Form.ErrorList errors={errors} /> |
||||||
|
|
||||||
|
<Button |
||||||
|
type="dashed" |
||||||
|
block |
||||||
|
icon={<PlusOutlined />} |
||||||
|
style={{ marginBottom: 24 }} |
||||||
|
onClick={() => |
||||||
|
addQuestion({ text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] }) |
||||||
|
} |
||||||
|
> |
||||||
|
Добавить вопрос |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</Form.List> |
||||||
|
|
||||||
|
<Form.Item> |
||||||
|
<Space> |
||||||
|
<Button type="primary" htmlType="submit" loading={isPending}> |
||||||
|
{submitLabel} |
||||||
|
</Button> |
||||||
|
<Button onClick={onCancel}>Отмена</Button> |
||||||
|
</Space> |
||||||
|
</Form.Item> |
||||||
|
</Form> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
* { |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
margin: 0; |
||||||
|
background: #f5f5f5; |
||||||
|
} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
import React from 'react' |
||||||
|
import ReactDOM from 'react-dom/client' |
||||||
|
import App from './App' |
||||||
|
import './index.css' |
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render( |
||||||
|
<React.StrictMode> |
||||||
|
<App /> |
||||||
|
</React.StrictMode>, |
||||||
|
) |
||||||
@ -0,0 +1,153 @@ |
|||||||
|
import { |
||||||
|
CheckCircleTwoTone, |
||||||
|
CloseCircleTwoTone, |
||||||
|
MinusCircleOutlined, |
||||||
|
TrophyOutlined, |
||||||
|
} from '@ant-design/icons' |
||||||
|
import { useQuery } from '@tanstack/react-query' |
||||||
|
import { Button, Card, Col, Divider, List, Result, Row, Space, Spin, Tag, Typography } from 'antd' |
||||||
|
import { useNavigate, useParams } from 'react-router-dom' |
||||||
|
|
||||||
|
import { AnswerResult, attemptsApi } from '../../api/attempts' |
||||||
|
|
||||||
|
const { Title, Text } = Typography |
||||||
|
|
||||||
|
export default function AttemptResult() { |
||||||
|
const { attemptId } = useParams<{ attemptId: string }>() |
||||||
|
const navigate = useNavigate() |
||||||
|
|
||||||
|
const { data: result, isLoading } = useQuery({ |
||||||
|
queryKey: ['attempts', attemptId, 'result'], |
||||||
|
queryFn: () => attemptsApi.getResult(Number(attemptId)).then((r) => r.data), |
||||||
|
}) |
||||||
|
|
||||||
|
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} /> |
||||||
|
if (!result) return null |
||||||
|
|
||||||
|
const duration = Math.round( |
||||||
|
(new Date(result.finished_at).getTime() - new Date(result.started_at).getTime()) / 1000, |
||||||
|
) |
||||||
|
const minutes = Math.floor(duration / 60) |
||||||
|
const seconds = duration % 60 |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}> |
||||||
|
|
||||||
|
{/* Итог */} |
||||||
|
<Result |
||||||
|
icon={ |
||||||
|
result.passed ? ( |
||||||
|
<TrophyOutlined style={{ color: '#52c41a' }} /> |
||||||
|
) : ( |
||||||
|
<CloseCircleTwoTone twoToneColor="#ff4d4f" /> |
||||||
|
) |
||||||
|
} |
||||||
|
status={result.passed ? 'success' : 'error'} |
||||||
|
title={result.passed ? 'Тест сдан!' : 'Тест не сдан'} |
||||||
|
subTitle={result.test_title} |
||||||
|
/> |
||||||
|
|
||||||
|
{/* Статистика */} |
||||||
|
<Card style={{ marginBottom: 24 }}> |
||||||
|
<Row gutter={32} justify="center" style={{ textAlign: 'center' }}> |
||||||
|
<Col> |
||||||
|
<Title level={1} style={{ margin: 0, color: result.passed ? '#52c41a' : '#ff4d4f' }}> |
||||||
|
{result.score}% |
||||||
|
</Title> |
||||||
|
<Text type="secondary">Результат</Text> |
||||||
|
</Col> |
||||||
|
<Col> |
||||||
|
<Title level={1} style={{ margin: 0 }}> |
||||||
|
{result.correct_count}/{result.total_count} |
||||||
|
</Title> |
||||||
|
<Text type="secondary">Правильных ответов</Text> |
||||||
|
</Col> |
||||||
|
<Col> |
||||||
|
<Title level={1} style={{ margin: 0 }}> |
||||||
|
{result.passing_score}% |
||||||
|
</Title> |
||||||
|
<Text type="secondary">Порог зачёта</Text> |
||||||
|
</Col> |
||||||
|
<Col> |
||||||
|
<Title level={1} style={{ margin: 0 }}> |
||||||
|
{minutes > 0 ? `${minutes}м ` : ''}{seconds}с |
||||||
|
</Title> |
||||||
|
<Text type="secondary">Время</Text> |
||||||
|
</Col> |
||||||
|
</Row> |
||||||
|
</Card> |
||||||
|
|
||||||
|
{/* Разбор ошибок */} |
||||||
|
<Title level={3}>Разбор ответов</Title> |
||||||
|
|
||||||
|
{result.questions.map((question, index) => ( |
||||||
|
<Card |
||||||
|
key={question.id} |
||||||
|
style={{ |
||||||
|
marginBottom: 12, |
||||||
|
borderColor: question.is_answered_correctly ? '#b7eb8f' : '#ffccc7', |
||||||
|
}} |
||||||
|
title={ |
||||||
|
<Space> |
||||||
|
{question.is_answered_correctly ? ( |
||||||
|
<CheckCircleTwoTone twoToneColor="#52c41a" /> |
||||||
|
) : ( |
||||||
|
<CloseCircleTwoTone twoToneColor="#ff4d4f" /> |
||||||
|
)} |
||||||
|
<Text strong> |
||||||
|
{index + 1}. {question.text} |
||||||
|
</Text> |
||||||
|
</Space> |
||||||
|
} |
||||||
|
> |
||||||
|
<List |
||||||
|
dataSource={question.answers} |
||||||
|
renderItem={(answer: AnswerResult) => { |
||||||
|
const icon = answer.is_correct ? ( |
||||||
|
<CheckCircleTwoTone twoToneColor="#52c41a" /> |
||||||
|
) : answer.is_selected ? ( |
||||||
|
<CloseCircleTwoTone twoToneColor="#ff4d4f" /> |
||||||
|
) : ( |
||||||
|
<MinusCircleOutlined style={{ color: '#d9d9d9' }} /> |
||||||
|
) |
||||||
|
|
||||||
|
return ( |
||||||
|
<List.Item style={{ padding: '4px 0' }}> |
||||||
|
<Space> |
||||||
|
{icon} |
||||||
|
<Text |
||||||
|
style={{ |
||||||
|
fontWeight: answer.is_correct ? 600 : 400, |
||||||
|
color: answer.is_selected && !answer.is_correct ? '#ff4d4f' : undefined, |
||||||
|
}} |
||||||
|
> |
||||||
|
{answer.text} |
||||||
|
</Text> |
||||||
|
{answer.is_selected && answer.is_correct && ( |
||||||
|
<Tag color="green">ваш ответ ✓</Tag> |
||||||
|
)} |
||||||
|
{answer.is_selected && !answer.is_correct && ( |
||||||
|
<Tag color="red">ваш ответ ✗</Tag> |
||||||
|
)} |
||||||
|
{!answer.is_selected && answer.is_correct && ( |
||||||
|
<Tag color="green">правильный ответ</Tag> |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
</List.Item> |
||||||
|
) |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
|
||||||
|
<Divider /> |
||||||
|
|
||||||
|
<Space> |
||||||
|
<Button onClick={() => navigate('/')}>К списку тестов</Button> |
||||||
|
<Button type="primary" onClick={() => navigate(`/tests/${result.test_id}`)}> |
||||||
|
Страница теста |
||||||
|
</Button> |
||||||
|
</Space> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query' |
||||||
|
import { message } from 'antd' |
||||||
|
import { useNavigate } from 'react-router-dom' |
||||||
|
|
||||||
|
import { CreateTestDto, testsApi } from '../../api/tests' |
||||||
|
import TestForm, { TestFormValues } from '../../components/TestForm' |
||||||
|
|
||||||
|
export default function TestCreate() { |
||||||
|
const navigate = useNavigate() |
||||||
|
const queryClient = useQueryClient() |
||||||
|
|
||||||
|
const { mutate: createTest, isPending } = useMutation({ |
||||||
|
mutationFn: (data: CreateTestDto) => testsApi.create(data).then((r) => r.data), |
||||||
|
onSuccess: (test) => { |
||||||
|
queryClient.invalidateQueries({ queryKey: ['tests'] }) |
||||||
|
message.success('Тест успешно создан') |
||||||
|
navigate(`/tests/${test.id}`) |
||||||
|
}, |
||||||
|
onError: (error: unknown) => { |
||||||
|
const err = error as { response?: { data?: { detail?: string } } } |
||||||
|
message.error(err.response?.data?.detail || 'Ошибка при создании теста') |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const onSubmit = (values: TestFormValues) => { |
||||||
|
createTest({ |
||||||
|
title: values.title, |
||||||
|
description: values.description, |
||||||
|
passing_score: values.passing_score, |
||||||
|
time_limit: values.has_timer ? values.time_limit : undefined, |
||||||
|
allow_navigation_back: values.allow_navigation_back ?? true, |
||||||
|
questions: values.questions, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<TestForm |
||||||
|
heading="Создание теста" |
||||||
|
onSubmit={onSubmit} |
||||||
|
isPending={isPending} |
||||||
|
submitLabel="Создать тест" |
||||||
|
onCancel={() => navigate('/')} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,89 @@ |
|||||||
|
import { ArrowLeftOutlined, PlayCircleOutlined } from '@ant-design/icons' |
||||||
|
import { useQuery } from '@tanstack/react-query' |
||||||
|
import { Button, Card, Descriptions, List, Space, Spin, Tag, Typography } from 'antd' |
||||||
|
import { useNavigate, useParams } from 'react-router-dom' |
||||||
|
|
||||||
|
import { Answer, testsApi } from '../../api/tests' |
||||||
|
|
||||||
|
const { Title, Text } = Typography |
||||||
|
|
||||||
|
export default function TestDetail() { |
||||||
|
const { id } = useParams<{ id: string }>() |
||||||
|
const navigate = useNavigate() |
||||||
|
|
||||||
|
const { data: test, isLoading } = useQuery({ |
||||||
|
queryKey: ['tests', id], |
||||||
|
queryFn: () => testsApi.get(Number(id)).then((r) => r.data), |
||||||
|
}) |
||||||
|
|
||||||
|
if (isLoading) { |
||||||
|
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} /> |
||||||
|
} |
||||||
|
|
||||||
|
if (!test) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}> |
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}> |
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}> |
||||||
|
К списку тестов |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
type="primary" |
||||||
|
icon={<PlayCircleOutlined />} |
||||||
|
onClick={() => navigate(`/tests/${test.id}/take`)} |
||||||
|
> |
||||||
|
Пройти тест |
||||||
|
</Button> |
||||||
|
</Space> |
||||||
|
|
||||||
|
<Title level={2}>{test.title}</Title> |
||||||
|
|
||||||
|
{test.description && ( |
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}> |
||||||
|
{test.description} |
||||||
|
</Text> |
||||||
|
)} |
||||||
|
|
||||||
|
<Card style={{ marginBottom: 24 }}> |
||||||
|
<Descriptions column={3}> |
||||||
|
<Descriptions.Item label="Вопросов">{test.questions.length}</Descriptions.Item> |
||||||
|
<Descriptions.Item label="Порог зачёта">{test.passing_score}%</Descriptions.Item> |
||||||
|
<Descriptions.Item label="Таймер"> |
||||||
|
{test.time_limit ? `${test.time_limit} мин` : 'Без ограничений'} |
||||||
|
</Descriptions.Item> |
||||||
|
<Descriptions.Item label="Возврат к вопросу"> |
||||||
|
{test.allow_navigation_back ? ( |
||||||
|
<Tag color="green">Разрешён</Tag> |
||||||
|
) : ( |
||||||
|
<Tag color="red">Запрещён</Tag> |
||||||
|
)} |
||||||
|
</Descriptions.Item> |
||||||
|
<Descriptions.Item label="Версия">{test.version}</Descriptions.Item> |
||||||
|
<Descriptions.Item label="Создан"> |
||||||
|
{new Date(test.created_at).toLocaleDateString('ru-RU')} |
||||||
|
</Descriptions.Item> |
||||||
|
</Descriptions> |
||||||
|
</Card> |
||||||
|
|
||||||
|
<Title level={3}>Вопросы ({test.questions.length})</Title> |
||||||
|
|
||||||
|
{test.questions.map((question, index) => ( |
||||||
|
<Card key={question.id} style={{ marginBottom: 12 }}> |
||||||
|
<Text strong> |
||||||
|
{index + 1}. {question.text} |
||||||
|
</Text> |
||||||
|
<List |
||||||
|
style={{ marginTop: 10 }} |
||||||
|
dataSource={question.answers} |
||||||
|
renderItem={(answer: Answer) => ( |
||||||
|
<List.Item style={{ padding: '4px 0' }}> |
||||||
|
<Text>{answer.text}</Text> |
||||||
|
</List.Item> |
||||||
|
)} |
||||||
|
/> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,285 @@ |
|||||||
|
import { |
||||||
|
ArrowLeftOutlined, |
||||||
|
CheckCircleTwoTone, |
||||||
|
CloseCircleTwoTone, |
||||||
|
EditOutlined, |
||||||
|
PlayCircleOutlined, |
||||||
|
} from '@ant-design/icons' |
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' |
||||||
|
import { Alert, Button, Card, Descriptions, List, Space, Spin, Table, Tag, Typography, message } from 'antd' |
||||||
|
import type { ColumnsType } from 'antd/es/table' |
||||||
|
import { useState } from 'react' |
||||||
|
import { useNavigate, useParams } from 'react-router-dom' |
||||||
|
|
||||||
|
import { Answer, CreateTestDto, TestListItem, testsApi } from '../../api/tests' |
||||||
|
import TestForm, { TestFormValues } from '../../components/TestForm' |
||||||
|
|
||||||
|
const { Title, Text } = Typography |
||||||
|
|
||||||
|
export default function TestEdit() { |
||||||
|
const { id } = useParams<{ id: string }>() |
||||||
|
const navigate = useNavigate() |
||||||
|
const queryClient = useQueryClient() |
||||||
|
const [editMode, setEditMode] = useState(false) |
||||||
|
|
||||||
|
const { data: test, isLoading } = useQuery({ |
||||||
|
queryKey: ['tests', id], |
||||||
|
queryFn: () => testsApi.get(Number(id)).then((r) => r.data), |
||||||
|
}) |
||||||
|
|
||||||
|
const { data: versions = [] } = useQuery({ |
||||||
|
queryKey: ['tests', id, 'versions'], |
||||||
|
queryFn: () => testsApi.versions(Number(id)).then((r) => r.data), |
||||||
|
enabled: !editMode, |
||||||
|
}) |
||||||
|
|
||||||
|
const { mutate: activateVersion, isPending: isActivating } = useMutation({ |
||||||
|
mutationFn: (versionId: number) => testsApi.activate(versionId).then((r) => r.data), |
||||||
|
onSuccess: () => { |
||||||
|
queryClient.invalidateQueries({ queryKey: ['tests'] }) |
||||||
|
queryClient.invalidateQueries({ queryKey: ['tests', id, 'versions'] }) |
||||||
|
queryClient.invalidateQueries({ queryKey: ['tests', id] }) |
||||||
|
message.success('Активная версия изменена') |
||||||
|
}, |
||||||
|
onError: () => message.error('Не удалось изменить активную версию'), |
||||||
|
}) |
||||||
|
|
||||||
|
const { mutate: updateTest, isPending } = useMutation({ |
||||||
|
mutationFn: (data: CreateTestDto) => testsApi.update(Number(id), data).then((r) => r.data), |
||||||
|
onSuccess: (result) => { |
||||||
|
queryClient.invalidateQueries({ queryKey: ['tests'] }) |
||||||
|
if (result.is_new_version) { |
||||||
|
message.success(`Создана новая версия теста (v${result.test.version})`) |
||||||
|
navigate(`/tests/${result.test.id}/edit`) |
||||||
|
} else { |
||||||
|
message.success('Тест обновлён') |
||||||
|
queryClient.invalidateQueries({ queryKey: ['tests', id] }) |
||||||
|
setEditMode(false) |
||||||
|
} |
||||||
|
}, |
||||||
|
onError: (error: unknown) => { |
||||||
|
const err = error as { response?: { data?: { detail?: string } } } |
||||||
|
message.error(err.response?.data?.detail || 'Ошибка при сохранении теста') |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
if (isLoading) { |
||||||
|
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} /> |
||||||
|
} |
||||||
|
|
||||||
|
if (!test) return null |
||||||
|
|
||||||
|
// Режим редактирования — показываем форму с предзаполненными данными
|
||||||
|
if (editMode) { |
||||||
|
const initialValues: TestFormValues = { |
||||||
|
title: test.title, |
||||||
|
description: test.description ?? undefined, |
||||||
|
passing_score: test.passing_score, |
||||||
|
has_timer: test.time_limit !== null, |
||||||
|
time_limit: test.time_limit ?? undefined, |
||||||
|
allow_navigation_back: test.allow_navigation_back, |
||||||
|
questions: test.questions.map((q) => ({ |
||||||
|
text: q.text, |
||||||
|
answers: q.answers.map((a) => ({ text: a.text, is_correct: a.is_correct })), |
||||||
|
})), |
||||||
|
} |
||||||
|
|
||||||
|
const onSubmit = (values: TestFormValues) => { |
||||||
|
updateTest({ |
||||||
|
title: values.title, |
||||||
|
description: values.description, |
||||||
|
passing_score: values.passing_score, |
||||||
|
time_limit: values.has_timer ? values.time_limit : undefined, |
||||||
|
allow_navigation_back: values.allow_navigation_back ?? true, |
||||||
|
questions: values.questions, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<TestForm |
||||||
|
heading={`Редактирование теста — v${test.version}`} |
||||||
|
initialValues={initialValues} |
||||||
|
onSubmit={onSubmit} |
||||||
|
isPending={isPending} |
||||||
|
submitLabel="Сохранить" |
||||||
|
onCancel={() => setEditMode(false)} |
||||||
|
onBack={() => setEditMode(false)} |
||||||
|
backLabel="К просмотру теста" |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
// Режим просмотра (вид автора)
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}> |
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}> |
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}> |
||||||
|
К списку тестов |
||||||
|
</Button> |
||||||
|
<Space> |
||||||
|
{versions.length > 1 && (!test.is_active || versions.some(v => v.id !== test.id && v.is_active)) && ( |
||||||
|
<Button |
||||||
|
loading={isActivating} |
||||||
|
onClick={() => activateVersion(test.id)} |
||||||
|
> |
||||||
|
Сделать активной |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
<Button |
||||||
|
icon={<EditOutlined />} |
||||||
|
onClick={() => setEditMode(true)} |
||||||
|
> |
||||||
|
Редактировать |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
type="primary" |
||||||
|
icon={<PlayCircleOutlined />} |
||||||
|
onClick={() => navigate(`/tests/${test.id}/take`)} |
||||||
|
> |
||||||
|
Пройти тест |
||||||
|
</Button> |
||||||
|
</Space> |
||||||
|
</Space> |
||||||
|
|
||||||
|
<Alert |
||||||
|
type="warning" |
||||||
|
showIcon |
||||||
|
message="Вид автора — правильные ответы отмечены" |
||||||
|
style={{ marginBottom: 16 }} |
||||||
|
/> |
||||||
|
|
||||||
|
<Title level={2}>{test.title}</Title> |
||||||
|
|
||||||
|
{test.description && ( |
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}> |
||||||
|
{test.description} |
||||||
|
</Text> |
||||||
|
)} |
||||||
|
|
||||||
|
<Card style={{ marginBottom: 24 }}> |
||||||
|
<Descriptions column={3}> |
||||||
|
<Descriptions.Item label="Вопросов">{test.questions.length}</Descriptions.Item> |
||||||
|
<Descriptions.Item label="Порог зачёта">{test.passing_score}%</Descriptions.Item> |
||||||
|
<Descriptions.Item label="Таймер"> |
||||||
|
{test.time_limit ? `${test.time_limit} мин` : 'Без ограничений'} |
||||||
|
</Descriptions.Item> |
||||||
|
<Descriptions.Item label="Возврат к вопросу"> |
||||||
|
{test.allow_navigation_back ? ( |
||||||
|
<Tag color="green">Разрешён</Tag> |
||||||
|
) : ( |
||||||
|
<Tag color="red">Запрещён</Tag> |
||||||
|
)} |
||||||
|
</Descriptions.Item> |
||||||
|
<Descriptions.Item label="Версия">{test.version}</Descriptions.Item> |
||||||
|
<Descriptions.Item label="Создан"> |
||||||
|
{new Date(test.created_at).toLocaleDateString('ru-RU')} |
||||||
|
</Descriptions.Item> |
||||||
|
</Descriptions> |
||||||
|
</Card> |
||||||
|
|
||||||
|
{versions.length > 1 && ( |
||||||
|
<> |
||||||
|
<Title level={3}>История версий</Title> |
||||||
|
<Table<TestListItem> |
||||||
|
dataSource={versions} |
||||||
|
rowKey="id" |
||||||
|
size="small" |
||||||
|
pagination={false} |
||||||
|
style={{ marginBottom: 24 }} |
||||||
|
rowClassName={(record) => (record.id === test.id ? 'ant-table-row-selected' : '')} |
||||||
|
columns={ |
||||||
|
[ |
||||||
|
{ |
||||||
|
title: 'Версия', |
||||||
|
dataIndex: 'version', |
||||||
|
width: 70, |
||||||
|
render: (v: number) => <Tag color="default">v{v}</Tag>, |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Статус', |
||||||
|
dataIndex: 'is_active', |
||||||
|
width: 130, |
||||||
|
render: (active: boolean) => |
||||||
|
active ? ( |
||||||
|
<Tag color="green">Активная</Tag> |
||||||
|
) : ( |
||||||
|
<Tag color="default">Неактивная</Tag> |
||||||
|
), |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Дата', |
||||||
|
dataIndex: 'created_at', |
||||||
|
width: 120, |
||||||
|
render: (d: string) => new Date(d).toLocaleDateString('ru-RU'), |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Вопросов', |
||||||
|
dataIndex: 'questions_count', |
||||||
|
width: 100, |
||||||
|
align: 'center' as const, |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Порог зачёта', |
||||||
|
dataIndex: 'passing_score', |
||||||
|
width: 120, |
||||||
|
align: 'center' as const, |
||||||
|
render: (s: number) => `${s}%`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: '', |
||||||
|
key: 'action', |
||||||
|
render: (_: unknown, record: TestListItem) => ( |
||||||
|
<Space> |
||||||
|
{record.id !== test.id && ( |
||||||
|
<Button size="small" onClick={() => navigate(`/tests/${record.id}/edit`)}> |
||||||
|
Открыть |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
{(record.id !== test.id || !record.is_active) && ( |
||||||
|
<Button |
||||||
|
size="small" |
||||||
|
type={record.is_active ? 'default' : 'primary'} |
||||||
|
loading={isActivating} |
||||||
|
onClick={() => activateVersion(record.id)} |
||||||
|
> |
||||||
|
Сделать активной |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
), |
||||||
|
}, |
||||||
|
] as ColumnsType<TestListItem> |
||||||
|
} |
||||||
|
/> |
||||||
|
</> |
||||||
|
)} |
||||||
|
|
||||||
|
<Title level={3}>Вопросы ({test.questions.length})</Title> |
||||||
|
|
||||||
|
{test.questions.map((question, index) => ( |
||||||
|
<Card key={question.id} style={{ marginBottom: 12 }}> |
||||||
|
<Text strong> |
||||||
|
{index + 1}. {question.text} |
||||||
|
</Text> |
||||||
|
<List |
||||||
|
style={{ marginTop: 10 }} |
||||||
|
dataSource={question.answers} |
||||||
|
renderItem={(answer: Answer) => ( |
||||||
|
<List.Item style={{ padding: '4px 0' }}> |
||||||
|
<Space> |
||||||
|
{answer.is_correct ? ( |
||||||
|
<CheckCircleTwoTone twoToneColor="#52c41a" /> |
||||||
|
) : ( |
||||||
|
<CloseCircleTwoTone twoToneColor="#d9d9d9" /> |
||||||
|
)} |
||||||
|
<Text>{answer.text}</Text> |
||||||
|
</Space> |
||||||
|
</List.Item> |
||||||
|
)} |
||||||
|
/> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
|
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,123 @@ |
|||||||
|
import { EditOutlined, MoreOutlined, PlayCircleOutlined, PlusOutlined } from '@ant-design/icons' |
||||||
|
import { useQuery } from '@tanstack/react-query' |
||||||
|
import { Button, Dropdown, Spin, Table, Typography } from 'antd' |
||||||
|
import type { ColumnsType } from 'antd/es/table' |
||||||
|
import { useNavigate } from 'react-router-dom' |
||||||
|
|
||||||
|
import { TestListItem, testsApi } from '../../api/tests' |
||||||
|
|
||||||
|
const { Title } = Typography |
||||||
|
|
||||||
|
export default function TestList() { |
||||||
|
const navigate = useNavigate() |
||||||
|
|
||||||
|
const { data: tests = [], isLoading } = useQuery({ |
||||||
|
queryKey: ['tests'], |
||||||
|
queryFn: () => testsApi.list().then((r) => r.data), |
||||||
|
}) |
||||||
|
|
||||||
|
const columns: ColumnsType<TestListItem> = [ |
||||||
|
{ |
||||||
|
title: 'Название', |
||||||
|
dataIndex: 'title', |
||||||
|
key: 'title', |
||||||
|
render: (text: string, record: TestListItem) => ( |
||||||
|
<a onClick={() => navigate(`/tests/${record.id}`)}>{text}</a> |
||||||
|
), |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Вопросов', |
||||||
|
dataIndex: 'questions_count', |
||||||
|
key: 'questions_count', |
||||||
|
width: 100, |
||||||
|
align: 'center', |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Порог зачёта', |
||||||
|
dataIndex: 'passing_score', |
||||||
|
key: 'passing_score', |
||||||
|
width: 130, |
||||||
|
align: 'center', |
||||||
|
render: (score: number) => `${score}%`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Таймер', |
||||||
|
dataIndex: 'time_limit', |
||||||
|
key: 'time_limit', |
||||||
|
width: 110, |
||||||
|
align: 'center', |
||||||
|
render: (limit: number | null) => (limit ? `${limit} мин` : '—'), |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Создан', |
||||||
|
dataIndex: 'created_at', |
||||||
|
key: 'created_at', |
||||||
|
width: 130, |
||||||
|
render: (date: string) => new Date(date).toLocaleDateString('ru-RU'), |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: '', |
||||||
|
key: 'actions', |
||||||
|
width: 60, |
||||||
|
align: 'center', |
||||||
|
render: (_: unknown, record: TestListItem) => ( |
||||||
|
<Dropdown |
||||||
|
menu={{ |
||||||
|
items: [ |
||||||
|
{ |
||||||
|
key: 'open', |
||||||
|
label: 'Открыть', |
||||||
|
onClick: () => navigate(`/tests/${record.id}`), |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: 'edit', |
||||||
|
icon: <EditOutlined />, |
||||||
|
label: 'Изменить', |
||||||
|
onClick: () => navigate(`/tests/${record.id}/edit`), |
||||||
|
}, |
||||||
|
{ type: 'divider' }, |
||||||
|
{ |
||||||
|
key: 'take', |
||||||
|
icon: <PlayCircleOutlined />, |
||||||
|
label: 'Пройти тест', |
||||||
|
onClick: () => navigate(`/tests/${record.id}/take`), |
||||||
|
}, |
||||||
|
], |
||||||
|
}} |
||||||
|
trigger={['click']} |
||||||
|
> |
||||||
|
<Button size="small" icon={<MoreOutlined />} /> |
||||||
|
</Dropdown> |
||||||
|
), |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
if (isLoading) { |
||||||
|
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} /> |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 1000, margin: '0 auto', padding: 24 }}> |
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> |
||||||
|
<Title level={2} style={{ margin: 0 }}> |
||||||
|
Тесты |
||||||
|
</Title> |
||||||
|
<Button |
||||||
|
type="primary" |
||||||
|
icon={<PlusOutlined />} |
||||||
|
onClick={() => navigate('/tests/create')} |
||||||
|
> |
||||||
|
Создать тест |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Table |
||||||
|
dataSource={tests} |
||||||
|
columns={columns} |
||||||
|
rowKey="id" |
||||||
|
locale={{ emptyText: 'Тестов пока нет. Создайте первый!' }} |
||||||
|
pagination={{ pageSize: 20 }} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,225 @@ |
|||||||
|
import { useEffect, useRef, useState } from 'react' |
||||||
|
import { useNavigate, useParams } from 'react-router-dom' |
||||||
|
import { |
||||||
|
Alert, |
||||||
|
Button, |
||||||
|
Card, |
||||||
|
Checkbox, |
||||||
|
Progress, |
||||||
|
Radio, |
||||||
|
Space, |
||||||
|
Spin, |
||||||
|
Tag, |
||||||
|
Typography, |
||||||
|
message, |
||||||
|
} from 'antd' |
||||||
|
import { ArrowLeftOutlined, ArrowRightOutlined, SendOutlined } from '@ant-design/icons' |
||||||
|
|
||||||
|
import { AttemptStarted, QuestionAnswer, attemptsApi } from '../../api/attempts' |
||||||
|
|
||||||
|
const { Title, Text } = Typography |
||||||
|
|
||||||
|
export default function TestTake() { |
||||||
|
const { testId } = useParams<{ testId: string }>() |
||||||
|
const navigate = useNavigate() |
||||||
|
|
||||||
|
const [attempt, setAttempt] = useState<AttemptStarted | null>(null) |
||||||
|
const [loading, setLoading] = useState(true) |
||||||
|
const [submitting, setSubmitting] = useState(false) |
||||||
|
const [currentIndex, setCurrentIndex] = useState(0) |
||||||
|
// answers: questionId → выбранные answerId[]
|
||||||
|
const [answers, setAnswers] = useState<Map<number, number[]>>(new Map()) |
||||||
|
const [timeLeft, setTimeLeft] = useState<number | null>(null) |
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null) |
||||||
|
|
||||||
|
// Стартуем попытку при монтировании
|
||||||
|
useEffect(() => { |
||||||
|
attemptsApi |
||||||
|
.start(Number(testId)) |
||||||
|
.then((r) => { |
||||||
|
setAttempt(r.data) |
||||||
|
if (r.data.time_limit) { |
||||||
|
setTimeLeft(r.data.time_limit * 60) |
||||||
|
} |
||||||
|
}) |
||||||
|
.catch(() => message.error('Не удалось загрузить тест')) |
||||||
|
.finally(() => setLoading(false)) |
||||||
|
}, [testId]) |
||||||
|
|
||||||
|
// Таймер
|
||||||
|
useEffect(() => { |
||||||
|
if (timeLeft === null) return |
||||||
|
|
||||||
|
timerRef.current = setInterval(() => { |
||||||
|
setTimeLeft((prev) => { |
||||||
|
if (prev === null || prev <= 1) { |
||||||
|
clearInterval(timerRef.current!) |
||||||
|
handleSubmit() |
||||||
|
return 0 |
||||||
|
} |
||||||
|
return prev - 1 |
||||||
|
}) |
||||||
|
}, 1000) |
||||||
|
|
||||||
|
return () => clearInterval(timerRef.current!) |
||||||
|
}, [timeLeft !== null]) // запускаем один раз когда timeLeft появился
|
||||||
|
|
||||||
|
const handleSubmit = async () => { |
||||||
|
if (!attempt || submitting) return |
||||||
|
clearInterval(timerRef.current!) |
||||||
|
setSubmitting(true) |
||||||
|
|
||||||
|
const payload: QuestionAnswer[] = attempt.questions.map((q) => ({ |
||||||
|
question_id: q.id, |
||||||
|
answer_ids: answers.get(q.id) ?? [], |
||||||
|
})) |
||||||
|
|
||||||
|
try { |
||||||
|
await attemptsApi.submit(attempt.id, payload) |
||||||
|
navigate(`/attempts/${attempt.id}/result`) |
||||||
|
} catch { |
||||||
|
message.error('Ошибка при отправке теста') |
||||||
|
setSubmitting(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const handleAnswer = (questionId: number, answerId: number, isMultiple: boolean) => { |
||||||
|
setAnswers((prev) => { |
||||||
|
const next = new Map(prev) |
||||||
|
if (isMultiple) { |
||||||
|
const current = next.get(questionId) ?? [] |
||||||
|
next.set( |
||||||
|
questionId, |
||||||
|
current.includes(answerId) |
||||||
|
? current.filter((id) => id !== answerId) |
||||||
|
: [...current, answerId], |
||||||
|
) |
||||||
|
} else { |
||||||
|
next.set(questionId, [answerId]) |
||||||
|
} |
||||||
|
return next |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const formatTime = (seconds: number) => { |
||||||
|
const m = Math.floor(seconds / 60) |
||||||
|
const s = seconds % 60 |
||||||
|
return `${m}:${s.toString().padStart(2, '0')}` |
||||||
|
} |
||||||
|
|
||||||
|
if (loading) return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} /> |
||||||
|
if (!attempt) return null |
||||||
|
|
||||||
|
const question = attempt.questions[currentIndex] |
||||||
|
const selectedIds = answers.get(question.id) ?? [] |
||||||
|
const total = attempt.questions.length |
||||||
|
const isLast = currentIndex === total - 1 |
||||||
|
const isTimeCritical = timeLeft !== null && timeLeft < 60 |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 720, margin: '0 auto', padding: 24 }}> |
||||||
|
|
||||||
|
{/* Шапка */} |
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}> |
||||||
|
<Title level={3} style={{ margin: 0 }}> |
||||||
|
{attempt.test_title} |
||||||
|
</Title> |
||||||
|
{timeLeft !== null && ( |
||||||
|
<Tag color={isTimeCritical ? 'red' : 'blue'} style={{ fontSize: 16, padding: '4px 12px' }}> |
||||||
|
⏱ {formatTime(timeLeft)} |
||||||
|
</Tag> |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
|
||||||
|
{/* Прогресс */} |
||||||
|
<Progress |
||||||
|
percent={Math.round(((currentIndex + 1) / total) * 100)} |
||||||
|
format={() => `${currentIndex + 1} / ${total}`} |
||||||
|
style={{ marginBottom: 16 }} |
||||||
|
/> |
||||||
|
|
||||||
|
{isTimeCritical && ( |
||||||
|
<Alert |
||||||
|
message="Осталось меньше минуты!" |
||||||
|
type="warning" |
||||||
|
showIcon |
||||||
|
style={{ marginBottom: 16 }} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Вопрос */} |
||||||
|
<Card |
||||||
|
title={ |
||||||
|
<Text strong style={{ fontSize: 16 }}> |
||||||
|
Вопрос {currentIndex + 1} |
||||||
|
</Text> |
||||||
|
} |
||||||
|
style={{ marginBottom: 24 }} |
||||||
|
> |
||||||
|
<Text style={{ fontSize: 15, display: 'block', marginBottom: 20 }}> |
||||||
|
{question.text} |
||||||
|
</Text> |
||||||
|
|
||||||
|
{question.is_multiple && ( |
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}> |
||||||
|
Выберите все правильные варианты |
||||||
|
</Text> |
||||||
|
)} |
||||||
|
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={10}> |
||||||
|
{question.answers.map((answer) => |
||||||
|
question.is_multiple ? ( |
||||||
|
<Checkbox |
||||||
|
key={answer.id} |
||||||
|
checked={selectedIds.includes(answer.id)} |
||||||
|
onChange={() => handleAnswer(question.id, answer.id, true)} |
||||||
|
style={{ fontSize: 14 }} |
||||||
|
> |
||||||
|
{answer.text} |
||||||
|
</Checkbox> |
||||||
|
) : ( |
||||||
|
<Radio |
||||||
|
key={answer.id} |
||||||
|
checked={selectedIds.includes(answer.id)} |
||||||
|
onChange={() => handleAnswer(question.id, answer.id, false)} |
||||||
|
style={{ fontSize: 14 }} |
||||||
|
> |
||||||
|
{answer.text} |
||||||
|
</Radio> |
||||||
|
), |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
</Card> |
||||||
|
|
||||||
|
{/* Навигация */} |
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}> |
||||||
|
<Button |
||||||
|
icon={<ArrowLeftOutlined />} |
||||||
|
onClick={() => setCurrentIndex((i) => i - 1)} |
||||||
|
disabled={currentIndex === 0 || !attempt.allow_navigation_back} |
||||||
|
> |
||||||
|
Назад |
||||||
|
</Button> |
||||||
|
|
||||||
|
{isLast ? ( |
||||||
|
<Button |
||||||
|
type="primary" |
||||||
|
icon={<SendOutlined />} |
||||||
|
loading={submitting} |
||||||
|
onClick={handleSubmit} |
||||||
|
> |
||||||
|
Завершить тест |
||||||
|
</Button> |
||||||
|
) : ( |
||||||
|
<Button |
||||||
|
type="primary" |
||||||
|
icon={<ArrowRightOutlined />} |
||||||
|
onClick={() => setCurrentIndex((i) => i + 1)} |
||||||
|
> |
||||||
|
Далее |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</Space> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"target": "ES2020", |
||||||
|
"useDefineForClassFields": true, |
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
||||||
|
"module": "ESNext", |
||||||
|
"skipLibCheck": true, |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowImportingTsExtensions": true, |
||||||
|
"resolveJsonModule": true, |
||||||
|
"isolatedModules": true, |
||||||
|
"noEmit": true, |
||||||
|
"jsx": "react-jsx", |
||||||
|
"strict": true, |
||||||
|
"noUnusedLocals": true, |
||||||
|
"noUnusedParameters": true, |
||||||
|
"noFallthroughCasesInSwitch": true |
||||||
|
}, |
||||||
|
"include": ["src"], |
||||||
|
"references": [{ "path": "./tsconfig.node.json" }] |
||||||
|
} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"composite": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"module": "ESNext", |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowSyntheticDefaultImports": true |
||||||
|
}, |
||||||
|
"include": ["vite.config.ts"] |
||||||
|
} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
import { defineConfig } from 'vite' |
||||||
|
import react from '@vitejs/plugin-react' |
||||||
|
|
||||||
|
export default defineConfig({ |
||||||
|
plugins: [react()], |
||||||
|
server: { |
||||||
|
host: '0.0.0.0', |
||||||
|
port: 5173, |
||||||
|
}, |
||||||
|
}) |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
server { |
||||||
|
listen 80; |
||||||
|
|
||||||
|
# Docker внутренний DNS — резолвим хосты в момент запроса, а не при старте nginx |
||||||
|
resolver 127.0.0.11 valid=30s; |
||||||
|
|
||||||
|
# API запросы → FastAPI backend |
||||||
|
location /api/ { |
||||||
|
set $backend http://backend:8000; |
||||||
|
proxy_pass $backend; |
||||||
|
proxy_set_header Host $host; |
||||||
|
proxy_set_header X-Real-IP $remote_addr; |
||||||
|
} |
||||||
|
|
||||||
|
# Всё остальное → Vite dev server (с поддержкой WebSocket для HMR) |
||||||
|
location / { |
||||||
|
set $frontend http://frontend:5173; |
||||||
|
proxy_pass $frontend; |
||||||
|
proxy_http_version 1.1; |
||||||
|
proxy_set_header Upgrade $http_upgrade; |
||||||
|
proxy_set_header Connection "upgrade"; |
||||||
|
proxy_set_header Host $host; |
||||||
|
proxy_cache_bypass $http_upgrade; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue