Compare commits

..

No commits in common. 'master' and 'main' have entirely different histories.
master ... main

  1. 2
      .env.example
  2. 223
      DOC/СПРИНТЫ.md
  3. 121
      DOC/СТЕК.md
  4. 163
      DOC/ТЗ.md
  5. 80
      DOC/ШАГИ/ШАГ_2026-03-21_002.md
  6. 85
      DOC/ШАГИ/ШАГ_2026-03-21_003.md
  7. 96
      DOC/ШАГИ/ШАГ_2026-03-21_004.md
  8. 45
      DOC/ШАГИ/ШАГ_2026-03-21_005.md
  9. 160
      DOC/ШАГИ/ШАГ_2026-03-21_006.md
  10. 97
      DOC/ШАГИ/ШАГ_2026-03-21_007.md
  11. 73
      DOC/ШАГИ/ШАГ_2026-03-21_008.md
  12. 73
      DOC/ШАГИ/ШАГ_2026-03-21_009.md
  13. 98
      DOC/ШАГИ/ШАГ_2026-03-21_010.md
  14. 73
      README.md
  15. 18
      backend/Dockerfile
  16. 39
      backend/alembic.ini
  17. 57
      backend/alembic/env.py
  18. 25
      backend/alembic/script.py.mako
  19. 62
      backend/alembic/versions/001_init.py
  20. 53
      backend/alembic/versions/002_attempts.py
  21. 29
      backend/alembic/versions/003_test_versioning.py
  22. 0
      backend/app/__init__.py
  23. 0
      backend/app/api/__init__.py
  24. 223
      backend/app/api/attempts.py
  25. 236
      backend/app/api/tests.py
  26. 11
      backend/app/config.py
  27. 17
      backend/app/database.py
  28. 27
      backend/app/main.py
  29. 0
      backend/app/models/__init__.py
  30. 49
      backend/app/models/attempt.py
  31. 57
      backend/app/models/test.py
  32. 0
      backend/app/schemas/__init__.py
  33. 89
      backend/app/schemas/attempt.py
  34. 91
      backend/app/schemas/test.py
  35. 13
      backend/entrypoint.sh
  36. 7
      backend/requirements.txt
  37. 46
      docker-compose.yml
  38. 12
      frontend/Dockerfile
  39. 12
      frontend/index.html
  40. 26
      frontend/package.json
  41. 32
      frontend/src/App.tsx
  42. 68
      frontend/src/api/attempts.ts
  43. 7
      frontend/src/api/client.ts
  44. 74
      frontend/src/api/tests.ts
  45. 268
      frontend/src/components/TestForm/index.tsx
  46. 8
      frontend/src/index.css
  47. 10
      frontend/src/main.tsx
  48. 153
      frontend/src/pages/AttemptResult/index.tsx
  49. 45
      frontend/src/pages/TestCreate/index.tsx
  50. 89
      frontend/src/pages/TestDetail/index.tsx
  51. 285
      frontend/src/pages/TestEdit/index.tsx
  52. 123
      frontend/src/pages/TestList/index.tsx
  53. 225
      frontend/src/pages/TestTake/index.tsx
  54. 21
      frontend/tsconfig.json
  55. 10
      frontend/tsconfig.node.json
  56. 10
      frontend/vite.config.ts
  57. 25
      nginx/nginx.conf

2
.env.example

@ -1,2 +0,0 @@
# База данных
DATABASE_URL=postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test

223
DOC/СПРИНТЫ.md

@ -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/
```

121
DOC/СТЕК.md

@ -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 достаточно

163
DOC/ТЗ.md

@ -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

80
DOC/ШАГИ/ШАГ_2026-03-21_002.md

@ -1,80 +0,0 @@
# ШАГ 002 — Создание технического задания
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> переходим к следующему шагу: создание тз
В ходе диалога уточнены требования:
**О ролях:**
> разделение по ролям у нас устроено так: есть руководители подразделений, к примеру руководитель службы администраторов, главный врач, руководитель службы администраторов. это те сотрудники, которые создают тесты. каждый тест это не менее 7 вопросов и не менее трёх вариантов ответов на вопрос, один или несколько из которых правильные. руководители подразделений назначают свои тесты сотрудникам своих подразделений, при этом у меня, как у директора клиники есть руководитель службы HR, который может создать и назначить тест сотрудникам сразу же всех подразделений.
**О времени:**
> обычно мы даём время в днях на прохождение теста. но я подумал, и решил, что некоторые из тестов надо настроить на время.
**О результатах:**
> (выбрано: свой балл/%, свои ошибки)
> мы должны вести трекер всех попыток сдачи теста: какой сотрудник, какой тест, когда начал выполнение теста, когда закончил, с каким результатом. преодолел ли сотрудник заданный порог правильных ответов.
---
## Ответ
### Подход
Перед написанием ТЗ проведён диалог с заказчиком (директором клиники) для уточнения требований по ролям, формату теста, ограничениям по времени и логике результатов.
ТЗ сохранено в отдельный файл: `DOC/ТЗ.md`
### Ключевые решения, принятые в ТЗ
**Роли:**
- `HR-менеджер` — создаёт тесты и назначает их всем сотрудникам клиники
- `Руководитель подразделения` — создаёт тесты и назначает только своему подразделению
- `Сотрудник` — проходит назначенные тесты
**Формат теста:**
- Минимум 7 вопросов
- Минимум 3 варианта ответа на вопрос
- Один или несколько правильных ответов (смешанный тип)
- Порог зачёта — задаётся в % при создании теста
**Временны́е ограничения:**
- Срок сдачи — дедлайн в днях (обязательно при назначении)
- Таймер прохождения — лимит в минутах (опционально, настраивается в тесте)
**После завершения теста сотрудник видит:**
- Свой балл и процент
- Зачёт / незачёт
- Разбор ошибок (где ответил неверно и правильный ответ)
**Трекер попыток фиксирует:**
- Сотрудник, тест, № попытки
- Время начала и завершения
- Результат и факт зачёта
### Что создано
- `DOC/ТЗ.md` — полное техническое задание
### Открытые вопросы (зафиксированы в ТЗ)
Ряд деталей требует уточнения на следующих шагах:
- Роль директора клиники
- Редактирование теста после назначения
- Случайный или фиксированный порядок вопросов
- Возможность вернуться к предыдущему вопросу
- Уведомления о назначении и дедлайне
- Экспорт отчётов
### Следующие шаги
- [x] Написать ТЗ
- [ ] Обсудить и закрыть открытые вопросы из ТЗ
- [ ] Выбрать технологический стек
- [ ] Составить план спринтов

85
DOC/ШАГИ/ШАГ_2026-03-21_003.md

@ -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] Закрыть открытые вопросы ТЗ
- [ ] Выбрать технологический стек
- [ ] Составить план спринтов

96
DOC/ШАГИ/ШАГ_2026-03-21_004.md

@ -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] Выбрать технологический стек
- [ ] Составить план спринтов

45
DOC/ШАГИ/ШАГ_2026-03-21_005.md

@ -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

160
DOC/ШАГИ/ШАГ_2026-03-21_006.md

@ -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

97
DOC/ШАГИ/ШАГ_2026-03-21_007.md

@ -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

73
DOC/ШАГИ/ШАГ_2026-03-21_008.md

@ -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

73
DOC/ШАГИ/ШАГ_2026-03-21_009.md

@ -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

98
DOC/ШАГИ/ШАГ_2026-03-21_010.md

@ -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

73
README.md

@ -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`.

18
backend/Dockerfile

@ -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"]

39
backend/alembic.ini

@ -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

57
backend/alembic/env.py

@ -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())

25
backend/alembic/script.py.mako

@ -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"}

62
backend/alembic/versions/001_init.py

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

53
backend/alembic/versions/002_attempts.py

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

29
backend/alembic/versions/003_test_versioning.py

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

0
backend/app/__init__.py

0
backend/app/api/__init__.py

223
backend/app/api/attempts.py

@ -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,
)

236
backend/app/api/tests.py

@ -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}

11
backend/app/config.py

@ -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()

17
backend/app/database.py

@ -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

27
backend/app/main.py

@ -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"}

0
backend/app/models/__init__.py

49
backend/app/models/attempt.py

@ -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]

57
backend/app/models/test.py

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

0
backend/app/schemas/__init__.py

89
backend/app/schemas/attempt.py

@ -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}

91
backend/app/schemas/test.py

@ -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}

13
backend/entrypoint.sh

@ -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

7
backend/requirements.txt

@ -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

46
docker-compose.yml

@ -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:

12
frontend/Dockerfile

@ -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"]

12
frontend/index.html

@ -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>

26
frontend/package.json

@ -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"
}
}

32
frontend/src/App.tsx

@ -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>
)
}

68
frontend/src/api/attempts.ts

@ -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`),
}

7
frontend/src/api/client.ts

@ -1,7 +0,0 @@
import axios from 'axios'
const client = axios.create({
baseURL: '/api',
})
export default client

74
frontend/src/api/tests.ts

@ -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`),
}

268
frontend/src/components/TestForm/index.tsx

@ -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>
)
}

8
frontend/src/index.css

@ -1,8 +0,0 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f5f5f5;
}

10
frontend/src/main.tsx

@ -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>,
)

153
frontend/src/pages/AttemptResult/index.tsx

@ -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>
)
}

45
frontend/src/pages/TestCreate/index.tsx

@ -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('/')}
/>
)
}

89
frontend/src/pages/TestDetail/index.tsx

@ -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>
)
}

285
frontend/src/pages/TestEdit/index.tsx

@ -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>
)
}

123
frontend/src/pages/TestList/index.tsx

@ -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>
)
}

225
frontend/src/pages/TestTake/index.tsx

@ -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>
)
}

21
frontend/tsconfig.json

@ -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" }]
}

10
frontend/tsconfig.node.json

@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
frontend/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,
},
})

25
nginx/nginx.conf

@ -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…
Cancel
Save