Compare commits
No commits in common. 'master' and 'main' have entirely different histories.
57 changed files with 0 additions and 4041 deletions
@ -1,2 +0,0 @@ |
|||||||
# База данных |
|
||||||
DATABASE_URL=postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test |
|
||||||
@ -1,223 +0,0 @@ |
|||||||
# План спринтов |
|
||||||
|
|
||||||
**Дата:** 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/ |
|
||||||
``` |
|
||||||
@ -1,121 +0,0 @@ |
|||||||
# Технологический стек |
|
||||||
|
|
||||||
**Дата:** 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 достаточно |
|
||||||
@ -1,163 +0,0 @@ |
|||||||
# Техническое задание |
|
||||||
## Система тестирования сотрудников клиники |
|
||||||
|
|
||||||
**Версия:** 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 |
|
||||||
@ -1,85 +0,0 @@ |
|||||||
# ШАГ 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] Закрыть открытые вопросы ТЗ |
|
||||||
- [ ] Выбрать технологический стек |
|
||||||
- [ ] Составить план спринтов |
|
||||||
@ -1,96 +0,0 @@ |
|||||||
# ШАГ 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] Выбрать технологический стек |
|
||||||
- [ ] Составить план спринтов |
|
||||||
@ -1,45 +0,0 @@ |
|||||||
# ШАГ 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 |
|
||||||
@ -1,160 +0,0 @@ |
|||||||
# ШАГ 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 |
|
||||||
@ -1,97 +0,0 @@ |
|||||||
# ШАГ 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 |
|
||||||
@ -1,73 +0,0 @@ |
|||||||
# ШАГ 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 |
|
||||||
@ -1,73 +0,0 @@ |
|||||||
# ШАГ 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 |
|
||||||
@ -1,98 +0,0 @@ |
|||||||
# ШАГ 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 |
|
||||||
@ -1,73 +0,0 @@ |
|||||||
# 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`. |
|
||||||
@ -1,18 +0,0 @@ |
|||||||
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"] |
|
||||||
@ -1,39 +0,0 @@ |
|||||||
[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 |
|
||||||
@ -1,57 +0,0 @@ |
|||||||
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()) |
|
||||||
@ -1,25 +0,0 @@ |
|||||||
"""${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"} |
|
||||||
@ -1,62 +0,0 @@ |
|||||||
"""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") |
|
||||||
@ -1,53 +0,0 @@ |
|||||||
"""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") |
|
||||||
@ -1,29 +0,0 @@ |
|||||||
"""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") |
|
||||||
@ -1,223 +0,0 @@ |
|||||||
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, |
|
||||||
) |
|
||||||
@ -1,236 +0,0 @@ |
|||||||
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} |
|
||||||
@ -1,11 +0,0 @@ |
|||||||
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() |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
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 |
|
||||||
@ -1,27 +0,0 @@ |
|||||||
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"} |
|
||||||
@ -1,49 +0,0 @@ |
|||||||
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] |
|
||||||
@ -1,57 +0,0 @@ |
|||||||
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") |
|
||||||
@ -1,89 +0,0 @@ |
|||||||
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} |
|
||||||
@ -1,91 +0,0 @@ |
|||||||
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} |
|
||||||
@ -1,13 +0,0 @@ |
|||||||
#!/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 |
|
||||||
@ -1,7 +0,0 @@ |
|||||||
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 |
|
||||||
@ -1,46 +0,0 @@ |
|||||||
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: |
|
||||||
@ -1,12 +0,0 @@ |
|||||||
FROM node:20-alpine |
|
||||||
|
|
||||||
WORKDIR /app |
|
||||||
|
|
||||||
COPY package*.json ./ |
|
||||||
RUN npm install |
|
||||||
|
|
||||||
COPY . . |
|
||||||
|
|
||||||
EXPOSE 5173 |
|
||||||
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] |
|
||||||
@ -1,12 +0,0 @@ |
|||||||
<!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> |
|
||||||
@ -1,26 +0,0 @@ |
|||||||
{ |
|
||||||
"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" |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,32 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,68 +0,0 @@ |
|||||||
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`), |
|
||||||
} |
|
||||||
@ -1,7 +0,0 @@ |
|||||||
import axios from 'axios' |
|
||||||
|
|
||||||
const client = axios.create({ |
|
||||||
baseURL: '/api', |
|
||||||
}) |
|
||||||
|
|
||||||
export default client |
|
||||||
@ -1,74 +0,0 @@ |
|||||||
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`), |
|
||||||
} |
|
||||||
@ -1,268 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,8 +0,0 @@ |
|||||||
* { |
|
||||||
box-sizing: border-box; |
|
||||||
} |
|
||||||
|
|
||||||
body { |
|
||||||
margin: 0; |
|
||||||
background: #f5f5f5; |
|
||||||
} |
|
||||||
@ -1,10 +0,0 @@ |
|||||||
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>, |
|
||||||
) |
|
||||||
@ -1,153 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,45 +0,0 @@ |
|||||||
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('/')} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,89 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,285 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,123 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,225 +0,0 @@ |
|||||||
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> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,21 +0,0 @@ |
|||||||
{ |
|
||||||
"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" }] |
|
||||||
} |
|
||||||
@ -1,10 +0,0 @@ |
|||||||
{ |
|
||||||
"compilerOptions": { |
|
||||||
"composite": true, |
|
||||||
"skipLibCheck": true, |
|
||||||
"module": "ESNext", |
|
||||||
"moduleResolution": "bundler", |
|
||||||
"allowSyntheticDefaultImports": true |
|
||||||
}, |
|
||||||
"include": ["vite.config.ts"] |
|
||||||
} |
|
||||||
@ -1,10 +0,0 @@ |
|||||||
import { defineConfig } from 'vite' |
|
||||||
import react from '@vitejs/plugin-react' |
|
||||||
|
|
||||||
export default defineConfig({ |
|
||||||
plugins: [react()], |
|
||||||
server: { |
|
||||||
host: '0.0.0.0', |
|
||||||
port: 5173, |
|
||||||
}, |
|
||||||
}) |
|
||||||
@ -1,25 +0,0 @@ |
|||||||
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