Compare commits

...

16 Commits

Author SHA1 Message Date
Константин Лебединский 1c4dacbc85 Merge branch 'dev-redisign' into dev 2 weeks ago
Константин Лебединский c3bdb406d6 docs(qa): уточнить — для тестирования подойдёт любая учётка 2 weeks ago
Константин Лебединский 47d673496b docs(qa): переписать инструкцию для тестировщика — только UI, без консолей и SQL 2 weeks ago
Константин Лебединский 2d6d75fb3c ui(mobile): полировка расположения редактора и каталога 2 weeks ago
Константин Лебединский 547840d671 docs(qa): tester guide for versioning and AI features 2 weeks ago
Константин Лебединский 4b0d56ff0e feat(flask): E1.0–E1.3, E1.8 — миграция на Python/Flask + AI v2 2 weeks ago
Константин Лебединский 31b51b7768 docs: move user guide out of sprints; sync design docs with UI 2 weeks ago
Константин Лебединский 5cd94c05ad docs: user guide for cabinet, sprint 4, SVG placeholders; drop token sprint 2 weeks ago
Константин Лебединский f1f5223076 feat(test detail): regroup sections, copy, and editor affordances 2 weeks ago
Константин Лебединский 72a5863871 fix(mobile): version card height, assign list, header safe-area 2 weeks ago
Константин Лебединский f7d9cbb1c4 fix(tests list): stack card on mobile, full-width title and Pass button 2 weeks ago
Константин Лебединский 9a85a13e08 fix(tests list): full-height link in split row for mobile tap 2 weeks ago
Константин Лебединский 3e70f4322d Sprint 2: attempt cards, file import button, question layout, radio/check, sticky save 2 weeks ago
Константин Лебединский 1db3653e66 docs: sprint checklist use [x]/[ ] task list format 2 weeks ago
Константин Лебединский 5db12c2348 Mobile UI sprint 1: actions-bar, version cards, meta line, safe area 2 weeks ago
Константин Лебединский 2a05f41b65 Redesign test editor: meta, content, AI shape, command bar 2 weeks ago
  1. BIN
      .DS_Store
  2. 20
      DOC/ШАГИ/ШАГ_2026-04-27_001.md
  3. 5
      DOC/ШАГИ/ШАГ_2026-04-27_002.md
  4. 4
      DOC/ШАГИ/ШАГ_2026-04-27_003.md
  5. 6
      README.md
  6. 12
      docker-compose.dev.yml
  7. 11
      docs/DEV_CONTOUR_USER_GUIDE.md
  8. 14
      docs/PROJECT_STATUS.md
  9. 414
      docs/QA-versioning-and-ai.md
  10. 291
      docs/migration-final-inventory.md
  11. 100
      docs/migration-final.md
  12. 189
      docs/migration-to-tgflaskform.md
  13. 158
      docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md
  14. 56
      docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md
  15. 67
      docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md
  16. 5
      docs/шаги/05-test-management.md
  17. 3
      docs/шаги/README.md
  18. 24
      flask_app/.env.example
  19. 15
      flask_app/README.md
  20. 64
      flask_app/app/__init__.py
  21. 5
      flask_app/app/auth/__init__.py
  22. 69
      flask_app/app/auth/decorators.py
  23. 13
      flask_app/app/auth/hr_role.py
  24. 107
      flask_app/app/auth/routes.py
  25. 217
      flask_app/app/auth/services.py
  26. 0
      flask_app/app/blueprints/__init__.py
  27. 31
      flask_app/app/blueprints/main.py
  28. 33
      flask_app/app/blueprints/settings.py
  29. 39
      flask_app/app/config.py
  30. 141
      flask_app/app/db.py
  31. 25
      flask_app/app/messages.py
  32. 0
      flask_app/app/services/__init__.py
  33. 352
      flask_app/app/services/ai_editor.py
  34. 89
      flask_app/app/services/document_extract.py
  35. 72
      flask_app/app/services/document_gen.py
  36. 105
      flask_app/app/services/draft_validator.py
  37. 95
      flask_app/app/services/editor_content.py
  38. 156
      flask_app/app/services/llm_client.py
  39. 108
      flask_app/app/services/test_access.py
  40. 22
      flask_app/app/services/test_chain.py
  41. 234
      flask_app/app/services/test_draft.py
  42. 17
      flask_app/app/static/css/app.css
  43. 546
      flask_app/app/static/js/editor.js
  44. 9
      flask_app/app/templates/404.html
  45. 9
      flask_app/app/templates/500.html
  46. 58
      flask_app/app/templates/auth/login.html
  47. 115
      flask_app/app/templates/base.html
  48. 54
      flask_app/app/templates/index.html
  49. 101
      flask_app/app/templates/settings.html
  50. 240
      flask_app/app/templates/tests/editor.html
  51. 141
      flask_app/app/templates/tests/list.html
  52. 2
      flask_app/app/tests/__init__.py
  53. 465
      flask_app/app/tests/routes.py
  54. 14
      flask_app/requirements.txt
  55. 933
      frontend/src/pages/TestDetail.jsx
  56. 12
      frontend/src/pages/TestsList.jsx
  57. 599
      frontend/src/styles/cabinet-theme.css

BIN
.DS_Store vendored

Binary file not shown.

20
DOC/ШАГИ/ШАГ_2026-04-27_001.md

@ -0,0 +1,20 @@
# Шаг 2026-04-27 — редизайн формы редактора теста (ветка `dev-redisign`)
## Сделано
- Создана ветка `dev-redisign` от `dev` в репозитории `TestingWebApp`.
- Страница автора `frontend/src/pages/TestDetail.jsx` приведена к структуре из `docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md` (адаптация под существующий React/JSX, без Ant Design):
- блок **«Метаинформация»** — название, описание, порог зачёта;
- блок **«Содержание»** — мини-панель ИИ (тема, число вопросов 1…30, число вариантов 2…8, кнопка генерации) и список вопросов с локальными кнопками ИИ;
- панель **«Команды»** — «Сохранить черновик» (основная), «К списку»; строка статуса черновика под панелью.
- Кнопка **«Сгенерировать тест (ИИ)»** убрана из шапки; генерация строит `shape` из введённых чисел, тема — из поля «Тема» с запасным вариантом на «Название»; после ответа API варианты в каждом вопросе нормализуются к выбранному числу (добор/обрезка, минимум один верный).
- Копирование темы при загрузке редактора и при применении импорта/черновика LLM (`setAiGenTopic` при `applyGeneratedDraft`).
## Бэкенд
- Менять не требовалось: `POST .../ai/generate-test` уже принимает `shape` с `optionsCount` (см. `backend/src/services/aiEditorService.js`).
## Проверка
- `npm run lint` и `npm run build` в `TestingWebApp/frontend` — без ошибок.
- Ручной прогон `docker compose` по чек-листу из предложения — остаётся на стороне исполнителя.

5
DOC/ШАГИ/ШАГ_2026-04-27_002.md

@ -0,0 +1,5 @@
# Шаг 2026-04-27 — спринты мобильного UI и правки
- Документ спринтов: [`docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md`](../../docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) (спринт 1 выполнен в коде).
- Стили: `actions-bar`, `version-card-list`, `list-row__meta-tail`, `inline-actions--block-mobile`, safe-area у `.cabinet-main`, `.btn--sm` / `.btn-ghost`, `assign-list` без пустой «коробки`.
- Страницы: `TestDetail.jsx` (карточки версий, панель команд, назначение), `TestsList.jsx` (мета-строка).

4
DOC/ШАГИ/ШАГ_2026-04-27_003.md

@ -0,0 +1,4 @@
# Шаг 2026-04-27 — спринт 2 (мобильный UI)
- См. [`docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md`](../../docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md): пункты 2.1–2.5 отмечены выполненными.
- Реализация: `TestDetail.jsx` (прогоны карточками, импорт через label+input, заголовок вопроса, radio/checkbox, фикс-футер), `cabinet-theme.css` (классы спринта 2).

6
README.md

@ -6,8 +6,10 @@
**Дата:** 2026-03-21 **Дата:** 2026-03-21
**Статус:** Согласовано **Статус:** Согласовано
**Актуальное состояние кода (не ТЗ, а «что уже есть»):** [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) · [инструкция для проверяющих на dev](docs/DEV_CONTOUR_USER_GUIDE.md). **Актуальное состояние кода (не ТЗ, а «что уже есть»):** [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) · [инструкция для проверяющих на dev](docs/DEV_CONTOUR_USER_GUIDE.md) · [кабинет: коротко для врачей/кураторов](docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md).
**Перенос на стек кабинета / мини-приложения:** [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md) · [простым языком](docs/migration-to-tgflaskform-plain.md). Отдельный Flask-контур: [flask_app/README.md](flask_app/README.md). **Спринты мобильного UI (чек-лист для разработки):** [docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
**Унификация стека (текущий этап) и слияние с HR-кабинетом (на будущее):** план и журнал — [docs/migration-final.md](docs/migration-final.md). Этап 1 — Express → Flask + React → Jinja **внутри TestingWebApp** (БД остаётся `clinic_tests`). Этап 2 (на будущее) — слияние с `HR_TG_Bot/tgFlaskForm`: [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md) · [простым языком](docs/migration-to-tgflaskform-plain.md). Карта Express-функционала и справочный gap-analysis с уже существующим модулем HR-кабинета: [docs/migration-final-inventory.md](docs/migration-final-inventory.md).
**Заготовка `flask_app/`** (отдельный Flask) больше **не развивается** — выбран сценарий «модуль внутри `tgFlaskForm`».
--- ---

12
docker-compose.dev.yml

@ -59,11 +59,21 @@ services:
WEB_USE_WAITRESS: "1" WEB_USE_WAITRESS: "1"
FLASK_DEBUG: "0" FLASK_DEBUG: "0"
SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me} SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me}
# БД (clinic_tests). Хост postgres — в общей сети hr_postgres_dev_net.
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://app:app@postgres:5432/clinic_tests}
# Опц. HR-кабинет (E1.1): включается флагом + URL базы hr_bot_test.
HR_AUTH: ${HR_AUTH:-0}
HR_DATABASE_URL: ${HR_DATABASE_URL:-}
# LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта.
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_MODEL: ${LLM_MODEL:-}
ports: ports:
- "3108:3108" - "3108:3108"
networks: networks:
- app - app
# когда понадобится БД из контейнера — добавьте сеть postgres (hr_postgres_dev_net) - postgres
networks: networks:
app: app:

11
docs/DEV_CONTOUR_USER_GUIDE.md

@ -31,11 +31,12 @@
## 3. Карточка теста (автор) ## 3. Карточка теста (автор)
- **Содержание:** название, порог зачёта, вопросы и варианты, отметка верных ответов. **Сохранить черновик** — записывает правки. Если по тесту **уже были прогоны**, при изменении **содержимого** система заведёт **новую версию** (и предупредит, что так и задумано). Блоки **сворачиваются** (заголовок — тап). Внизу экрана на телефоне часто закреплены **«Сохранить черновик»** и **«К списку»**.
- **История версий** — посмотреть все версии, **сделать активной** другую (с подтверждением). Новые «Пройти» пойдут с активной.
- **Публикация** — скрыть цепочку из общего списка или вернуть обратно. - **О тесте** — название, описание, **порог зачёта**. **Сохранить черновик** — записывает правки. Если по тесту **уже были прогоны**, при изменении **содержимого** система заведёт **новую версию** (и предупредит, что так и задумано).
- **Прогоны и разбор** (если есть завершённые попытки) — таблица; можно открыть **разбор** по вопросам. - **Вопросы** — тексты вопросов и варианты, отметка верных, **ИИ** (если есть), внизу секции — **«Документ в вопросы»** (загрузка файла и вставка в черновик, если на стенде настроен LLM).
- **Импорт из файла** (если на стенде настроен ключ к LLM) — загрузить документ, получить **черновик**, вставить в редактор, затем снова **сохранить черновик** по правилам версий. - **История****Версии** (все версии, **сделать активной** с подтверждением) и **Прохождения** (кто сдавал; **Разбор** по вопросам).
- **Показ в каталоге****Видимость** (скрыть из общего списка или снова показать); при включённом назначении — **Кому выдать** (поиск, **«Назначить выбранных»**).
Автор **не** запускает экзамен **с карточки** в том же сценарии, что сотрудник: для самопрохождения — **«Пройти»** в **списке** «Тесты». Автор **не** запускает экзамен **с карточки** в том же сценарии, что сотрудник: для самопрохождения — **«Пройти»** в **списке** «Тесты».

14
docs/PROJECT_STATUS.md

@ -21,7 +21,10 @@
- **Пока никто не проходил** этот тест — автор правит **на месте**: сохраняет черновик, и меняется текущая активная версия **без** лишнего дублирования строк в истории. - **Пока никто не проходил** этот тест — автор правит **на месте**: сохраняет черновик, и меняется текущая активная версия **без** лишнего дублирования строк в истории.
- **Как только по цепочке появилась хотя бы одна завершённая попытка** — каждое **содержательное** сохранение с изменениями создаёт **новую версию** (новый номер, старая остаётся в истории). Старые результаты остаются привязаны к **той** версии, с которой человек реально отвечал. - **Как только по цепочке появилась хотя бы одна завершённая попытка** — каждое **содержательное** сохранение с изменениями создаёт **новую версию** (новый номер, старая остаётся в истории). Старые результаты остаются привязаны к **той** версии, с которой человек реально отвечал.
- **Активная версия** — та, с которой сейчас стартуют новые попытки. Автор может **вручную** переключить активную версию в таблице истории (с подтверждением), если бизнесу так нужно. - **Активная версия** — та, с которой сейчас стартуют новые попытки. Автор может **вручную** переключить активную версию в таблице истории (с подтверждением), если бизнесу так нужно.
- **Публикация:** тест можно **скрыть из общего списка** (цепочка остаётся в базе; автор видит скрытые в отдельном блоке и может вернуть в список). - **Публикация / видимость:** в кабинете (аккордеон **«Показ в каталоге»**, подсекция **«Видимость»**) тест можно **скрыть из общего списка** (цепочка остаётся в базе) или **снова показать**; **назначения** (подсекция **«Кому выдать»**) — при включённой фиче, см. раздел «Назначения» ниже.
- **Мобильный UI** кабинета (колонка списка на узком экране, фикс-футер, группировка разделов, копи): [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) · [РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) (тезисы для врачей/кураторов).
- **Унификация стека (Этап 1, текущий)**: Express → Flask + React → Jinja **внутри TestingWebApp** (`flask_app/`). БД остаётся `clinic_tests`, схема не меняется. План и журнал — [migration-final.md](migration-final.md).
- **Слияние с HR-кабинетом (Этап 2, на будущее, без сроков)**: перенос в `HR_TG_Bot/tgFlaskForm` как blueprint `cabinet/testing`, ETL `clinic_tests → hr_bot_test`. План — [migration-to-tgflaskform.md](migration-to-tgflaskform.md). Карта Express-функционала и справочный gap-analysis с уже существующим модулем HR-кабинета — [migration-final-inventory.md](migration-final-inventory.md).
### Список тестов и доступ ### Список тестов и доступ
@ -32,16 +35,16 @@
### Прохождение и результат ### Прохождение и результат
- Открывается экран вопросов (один или несколько верных вариантов); после **«Завершить тест»** — итог: сколько верно, процент, **зачёт** по порогу. - Открывается экран вопросов (один или несколько верных вариантов); после **«Завершить тест»** — итог: сколько верно, процент, **зачёт** по порогу.
- **Разбор:** после сдачи показывается, по **каждому вопросу**, что выбрал пользователь и какие варианты верны. Отдельная страница разбора доступна по ссылке; **автор** на карточке теста видит раздел **«Прогоны и разбор»** по завершённым попыткам. - **Разбор:** после сдачи показывается, по **каждому вопросу**, что выбрал пользователь и какие варианты верны. Отдельная страница разбора доступна по ссылке; **автор** в аккордеоне **«История»** (подсекция **«Прохождения»**) видит **завершённые** попытки и кнопку **«Разбор»** (раньше секция называлась **«Прогоны и разбор»**).
### Импорт и ИИ (MVP) ### Импорт и ИИ (MVP)
- Можно загрузить **файл** (PDF, DOCX, текст): сервер **извлекает текст** и при настроенном ключе **LLM** (например, `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` в окружении) предлагает **черновик** вопросов. Дальше тот же поток, что и при ручном редактировании: правки → **сохранить черновик** (с учётом правил версий выше). - Можно загрузить **файл** (PDF, DOCX, текст): сервер **извлекает текст** и при настроенном ключе **LLM** (например, `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` в окружении) предлагает **черновик** вопросов. В UI: подсекция **«Документ в вопросы»** внутри **«Вопросы»** (раньше — отдельный блок «Импорт из файла»). Дальше тот же поток, что и при ручном редактировании: правки → **сохранить черновик** (с учётом правил версий выше).
- **Полный** набор сценариев из ТЗ (отдельная страница настроек ключа, «проверить тест целиком», модалки с чекбоксами и т.д.) — в [sprint-02](revision_task/sprint-02.md); часть уже заложена в сервисах, UI доводится. - **Полный** набор сценариев из ТЗ (отдельная страница настроек ключа, «проверить тест целиком», модалки с чекбоксами и т.д.) — в [sprint-02](revision_task/sprint-02.md); часть уже заложена в сервисах, UI доводится.
### Назначения (MVP) ### Назначения (MVP)
- **Автор** на карточке теста может **назначить** сотрудников из справочника (в dev — через поиск/каталог, если фича включена в `docker-compose` / `.env`). Назначение **не** перепривязывается автоматически к каждой новой версии контента: **старт попытки** всегда берёт **текущую активную** версию на момент нажатия **«Пройти»**. - **Автор** в **«Показ в каталоге»** → **«Кому выдать»** может **назначить** сотрудников из справочника (поиск, фильтры, **«Выбрать всех»** в текущем списке; в dev — при включённой фиче в `docker-compose` / `.env`). Назначение **не** перепривязывается автоматически к каждой новой версии контента: **старт попытки** всегда берёт **текущую активную** версию на момент нажатия **«Пройти»**.
### Интеграция с HR (в зачатке) ### Интеграция с HR (в зачатке)
@ -68,5 +71,8 @@
## Связанные файлы ## Связанные файлы
- [Руководство пользователя dev-контура](DEV_CONTOUR_USER_GUIDE.md) - [Руководство пользователя dev-контура](DEV_CONTOUR_USER_GUIDE.md)
- [Руководство кабинета (простыми словами)](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md)
- [Спринты: мобильный UI](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md)
- [Предложение по дизайну (ист. + актуализация)](ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md)
- [README с установкой](../README.md) - [README с установкой](../README.md)
- [Карта задач card1](revision_task/card1.md) - [Карта задач card1](revision_task/card1.md)

414
docs/QA-versioning-and-ai.md

@ -0,0 +1,414 @@
# Инструкция для тестировщика: версионирование тестов и AI
Сайт: **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)**
Учётка: подойдёт **любая** учётная запись на сайте — никаких особых ролей
не требуется. Любой залогиненный пользователь, который создаёт тест,
автоматически становится его автором и может его редактировать. Если
учётки нет — попросите её у разработчика.
> Всё, что описано ниже, проверяется **только через сайт**. Если в каком-то
> сценарии написано «недоступно сейчас» — это **не баг**, это значит, что
> функция UI ещё не сделана и появится позже. Просто пропускайте.
---
## Часть 1. Версии теста — что меняется при правках
### О чём вообще задача
Когда автор правит тест, в системе важно не сломать историю прохождений
сотрудников. Поэтому правила такие:
- Пока **никто не прошёл** тест — автор правит на месте, версия одна.
- Как только **хотя бы один сотрудник прошёл** тест — следующее сохранение
изменений создаёт **новую версию** (v2, v3, …), старая сохраняется.
- В каталоге всегда видна **только одна** активная версия.
- Автор может **скрыть** тест целиком (чекбокс «Цепочка активна»).
- Автор может **переключить** активную версию на другую из истории.
> Сейчас на сайте нельзя пройти тест сотруднику и нельзя из UI открыть
> историю версий — это будет в следующих спринтах. Поэтому из шести
> правил тестировщик пока проверяет четыре, остальные помечены ниже.
---
### 1.1. Создание нового теста
**Что проверяем:** автор может создать тест с нуля.
**Как проверять:**
1. Откройте [https://edullm.pirogov.ai/](https://edullm.pirogov.ai/) и войдите.
2. Нажмите в шапке иконку **«Тесты»** (список) → попадаете в каталог.
3. Нажмите кнопку **«Создать тест»**.
4. В появившемся окне заполните **Название** (например, «Тест A»),
при желании — Описание.
5. Нажмите **«Создать»**.
**Что должно произойти:**
- Открывается экран редактора нового теста.
- В поле «Название» — то, что вы ввели.
- Список вопросов пуст, счётчик «Вопросы (0)».
- Внизу — кнопка **«Сохранить»** и чекбокс **«Цепочка активна»** (по
умолчанию включён).
**Если что-то не так — баг:**
- Окно «Создать тест» не открывается.
- После «Создать» никуда не перенаправило.
- В поле «Название» в редакторе пусто, хотя ввели текст.
- Список тестов в каталоге не обновился (не появилась новая карточка).
---
### 1.2. Правка теста до прохождений (версия не растёт)
**Что проверяем:** пока никто не проходил тест, автор может править его
сколько угодно — это всё одна и та же версия.
**Как проверять:**
1. Откройте только что созданный «Тест A» (если уже не открыт): шапка → **«Тесты»** → нажмите на карточку теста.
2. Нажмите **«Добавить вопрос»** — появится карточка вопроса.
3. Введите текст вопроса.
4. Заполните 3–4 варианта ответа в поле «Вариант ответа», у одного из них поставьте чекбокс «Правильный» (квадратик слева от текста).
5. Добавьте ещё один-два вопроса тем же способом.
6. Нажмите **«Сохранить»** в нижней панели.
**Что должно произойти:**
- Под шапкой появляется надпись **«Сохранено.»** (без слов про новую версию).
- Если перезагрузить страницу — все вопросы и варианты на месте.
**Повторите правку:**
1. На том же экране **измените** текст одного вопроса, **добавьте** ещё один вариант к другому, **удалите** третий вопрос (кнопка «корзина» справа в карточке вопроса).
2. Нажмите **«Сохранить»**.
**Что должно произойти:**
- Снова надпись **«Сохранено.»**.
- Никаких слов «создана новая версия».
- Перезагрузка страницы — изменения сохранились.
**Если что-то не так — баг:**
- Появляется сообщение про «новую версию» (его быть не должно — попыток ещё нет).
- Изменения не сохранились после перезагрузки страницы.
- Сообщение «Сохранено.» не появилось вовсе.
---
### 1.3. Правка после прохождений (создаётся v2)
> ⏳ **Сейчас недоступно для проверки.**
>
> На сайте пока нет страницы для прохождения теста сотрудником, поэтому
> тестировщик не может «накопить» попытки и проверить эту логику через UI.
> Сценарий вернётся в инструкцию вместе со следующим спринтом, когда
> появится страница прохождения.
---
### 1.4. Каталог показывает только активные тесты
**Что проверяем:** в каталоге нет неактивных/скрытых тестов.
**Как проверять:**
1. На экране редактора любого теста снимите внизу чекбокс
**«Цепочка активна»** и нажмите **«Сохранить»**.
2. Перейдите в шапке на **«Тесты»** (каталог).
**Что должно произойти:**
- Карточки этого теста в основном списке нет.
- Внизу страницы каталога есть раскрывающийся блок **«Скрытые вами цепочки (N)»**. Раскройте его — там видно ваш тест.
- Нажмите **«Открыть»** рядом с тестом — снова попадаете в редактор.
- Поставьте обратно галку **«Цепочка активна»** → **«Сохранить»**.
- Снова перейдите в **«Тесты»** — тест опять в основном списке каталога.
**Если что-то не так — баг:**
- После снятия галки тест остаётся в обычном каталоге.
- Тест полностью пропал и его не видно нигде, даже в «Скрытых».
- После возврата галки тест не вернулся в основной каталог.
---
### 1.5. Ручное переключение активной версии
> ⏳ **Сейчас недоступно для проверки.**
>
> Страница с историей версий теста (где можно нажать «сделать активной»)
> ещё не сделана. Появится в следующем спринте.
---
### 1.6. История прохождений после правок
> ⏳ **Сейчас недоступно для проверки** — см. 1.3 и 1.5.
---
## Часть 2. AI-функции в редакторе теста
### Как проверять, что AI вообще работает
**Что проверяем:** ключ к AI задан и сервис отвечает.
**Как проверять:**
1. В шапке нажмите иконку **«Настройки»** (шестерёнка).
2. Посмотрите блок «Подключение к LLM»:
- **Статус ключа** — должно быть зелёное **«Задан»**.
- **Провайдер** и **Модель** — заполнены.
3. Нажмите кнопку **«Проверить подключение»**.
**Что должно произойти:**
- В течение 1–10 секунд под кнопкой появляется **зелёный** блок
с текстом вида **«OK · deepseek / deepseek-chat · 1234 мс»**.
**Если что-то не так:**
- Если статус **«Не задан»** (красный) — сообщите разработчику, не задан ключ. Тестировать AI-функции в этом режиме не нужно, кроме одного сценария ниже (2.7).
- Если кнопка отдала **красный** блок «Ошибка» при заданном ключе — это баг, прикладывайте текст ошибки к тикету.
---
### 2.1. Сгенерировать тест по названию
**Простыми словами:** автор пишет только тему, AI сам придумывает вопросы.
**Как проверять:**
1. **«Тесты»** → **«Создать тест»** → название, например, **«Гигиена рук»**, описание можно оставить пустым → **«Создать»**.
2. В редакторе нажмите кнопку **«По названию»** (фиолетовая, в блоке «AI-помощник» → «Создать вопросы»).
3. На вопрос «Сколько вопросов сгенерировать?» введите, например, `8`**OK**.
4. На вопрос «Сколько вариантов в каждом вопросе?» введите, например, `4`**OK**.
5. Подождите 5–20 секунд.
6. Появится подтверждение «Готово: «…», вопросов — N. Применить как черновик? Текущие вопросы будут заменены». Нажмите **OK**.
**Что должно произойти:**
- В списке появилось примерно 8 вопросов на тему гигиены рук, в каждом примерно 4 варианта ответа на русском языке.
- В каждом вопросе хотя бы один вариант помечен как «Правильный» (галка слева от текста варианта).
- Внизу можно нажать **«Сохранить»** — тест сохраняется.
**Дополнительно (что блокировка названия работает):**
1. Создайте ещё один тест, в редакторе **очистите поле «Название»**.
2. Нажмите **«По названию»**.
3. Должен появиться алерт **«Сначала заполните название теста.»**, курсор перейдёт в поле «Название». Никакой генерации не происходит.
**Если что-то не так — баг:**
- Кнопка **«По названию»** работает при пустом названии (без алерта).
- Сгенерированные вопросы — не на русском или не по теме названия.
- В вопросе нет ни одного правильного варианта.
- Подтверждение «Применить?» не появилось — вопросы заменились молча.
- Отказ в подтверждении (Cancel) всё равно заменил вопросы.
---
### 2.2. Сгенерировать тест по сетке
**Простыми словами:** автор сам задаёт «скелет» — сколько вопросов и
сколько вариантов; AI заполняет вопросы по этому скелету.
**Как проверять:**
1. Создайте новый тест с названием **«Тест по сетке»**.
2. Нажмите **«Добавить вопрос»** пять раз — получится 5 пустых карточек.
3. В **третьей и пятой** карточках поставьте галку **«Несколько правильных ответов»**.
4. В каждом вопросе по умолчанию 0 вариантов — нажмите **«Добавить вариант»** в каждом вопросе по 3 раза, чтобы стало по 3 варианта.
5. Нажмите кнопку **«По текущей сетке»** в блоке «AI-помощник».
**Что должно произойти:**
- В списке снова 5 вопросов (не больше, не меньше).
- В каждом — по 3 варианта.
- В третьем и пятом вопросах несколько вариантов помечены правильными;
в остальных — ровно один.
- Тексты — на русском, по теме названия.
**Если что-то не так — баг:**
- Стало другое число вопросов или вариантов (не 5×3).
- В третьем/пятом вопросе только один правильный ответ, а в остальных — несколько.
- Пришла ошибка типа **«AI: ошибка»** без понятного объяснения.
---
### 2.3. Проверить тест
**Простыми словами:** AI читает весь тест и пишет, что в нём не так.
**Как проверять:**
1. Откройте любой тест с 3+ вопросами (например, «Гигиена рук» из 2.1).
2. Желательно специально испортить пару вопросов: переписать
формулировку расплывчато («что-то про что-то»), сделать варианты
ответа очень похожими друг на друга или явно дурацкими.
3. Нажмите **«Сохранить»**.
4. Нажмите кнопку **«Проверить»** в блоке «AI-помощник» → «Улучшить существующее».
**Что должно произойти:**
- Открывается окно «Проверка теста».
- Сверху — цветная плашка с одним из вердиктов: **«Годен»** (зелёный),
**«Есть замечания»** (жёлтый) или **«Серьёзные проблемы»** (красный)
+ одно-два предложения резюме.
- Ниже — список разделов: «Чёткость формулировок», «Качество дистракторов»,
«Охват темы», «Сбалансированность сложности». Под каждым — конкретные
пункты, что улучшить.
- Закрытие крестиком сверху или кнопкой «Закрыть» внизу — работает.
- В тесте при этом **ничего не меняется**, AI только советует.
**Если что-то не так — баг:**
- Окно пустое или текст не на русском.
- Все вопросы AI признал хорошими, хотя вы их специально испортили.
- После закрытия окна в тесте появились/исчезли вопросы.
---
### 2.4. Улучшить весь тест
**Простыми словами:** AI предлагает улучшенные формулировки и варианты;
автор отмечает галками, что применить.
**Как проверять:**
1. Откройте тест из 2.3 (с теми же намеренно слабыми вопросами).
2. Нажмите **«Сохранить»** на всякий случай.
3. Нажмите кнопку **«Улучшить»**.
**Что должно произойти:**
- Открывается окно «Улучшение теста».
- Сверху подпись «Отметьте вопросы… N из M» (M — всего вопросов, N — где AI предложил изменения).
- Каждый изменённый вопрос — отдельный блок:
- Чекбокс **«Вопрос #N»** (по умолчанию **отмечен**).
- Слева — «Было» (старый текст и варианты, изменённые куски зачёркнуты).
- Справа — «Стало» (новые формулировки, выделены).
- Правильные варианты помечены галочкой **✓**.
- Внизу — две кнопки: **«Отмена»** и **«Применить выбранное»**.
**Проверьте применение по выбору:**
1. Снимите галки у двух вопросов из списка.
2. Нажмите **«Применить выбранное»**.
**Что должно произойти:**
- Окно закрывается.
- В редакторе **только** отмеченные вопросы заменены на улучшенные;
два вопроса, у которых вы сняли галки, остались в прежнем виде.
- Появляется подсказка «Изменения применены. Не забудьте сохранить.»
- После **«Сохранить»** — обычное «Сохранено.»
**Если что-то не так — баг:**
- Поменялось **число** вопросов или вариантов в каких-то вопросах
(должно остаться как было).
- В вопросе изменилось значение «Несколько правильных ответов» (галка
переключилась сама).
- Изменились вопросы, у которых вы сняли галку.
- Кнопка «Отмена» всё равно применила изменения.
- В колонках «Было» и «Стало» одинаковый текст (нет смысла предлагать).
---
### 2.5. AI: вопрос/переформулировать
**Простыми словами:** работа с одним вопросом. Если поле пустое — AI его
придумает; если уже заполнено — переформулирует красивее, варианты не трогает.
**Как проверять (новый вопрос):**
1. В любом тесте нажмите **«Добавить вопрос»** — появилась пустая карточка.
2. **Не трогайте** поле «Формулировка вопроса».
3. Нажмите **«Добавить вариант»** 4 раза — должно стать 4 пустых варианта.
4. Нажмите кнопку **«AI: вопрос/переформулировать»** в этой карточке.
**Что должно произойти:**
- Поле «Формулировка вопроса» заполнено осмысленным текстом по теме теста.
- Все 4 варианта заполнены.
- Ровно один помечен как правильный.
- Внизу появляется строка статуса **«AI: вопрос сгенерирован.»**
**Как проверять (переформулировать существующий):**
1. Возьмите готовый вопрос с уже заполненной формулировкой.
2. Нажмите **«AI: вопрос/переформулировать»** в его карточке.
**Что должно произойти:**
- Меняется **только текст вопроса** — варианты ответа остаются прежними,
правильные варианты те же.
- Статус **«AI: формулировка обновлена.»**
**Если что-то не так — баг:**
- На пустом вопросе AI ничего не сгенерировал.
- На заполненном вопросе AI поменял варианты ответа или правильность —
должен трогать только формулировку.
- Получилось 0 или 1 правильных вариантов в новом вопросе (надо ровно 1
для одиночного выбора).
---
### 2.6. Импорт документа
**Простыми словами:** автор загружает PDF/Word/текст со статьёй —
AI читает файл и сам предлагает черновик теста.
**Подготовьте файл:** возьмите PDF или DOCX на 1–3 страницы со связным
русским текстом (например, любую методичку или статью). Лимит — 16 МБ.
**Как проверять:**
1. В редакторе любого нового теста (можно пустого) → блок «AI-помощник»
**«Импортировать»** → нажмите большую кнопку **«Загрузить документ (PDF, DOCX, TXT, MD)»** → выберите файл.
2. Подождите 5–30 секунд.
**Что должно произойти:**
- Появляется подтверждение «Сгенерировано: «…», вопросов: N. Применить как новый черновик? Текущие вопросы будут заменены». Нажмите **OK**.
- Тест заполнен вопросами по содержанию загруженного документа.
- Можно сохранить кнопкой **«Сохранить»**.
**Дополнительно — отказ:**
1. Повторите загрузку, но в подтверждении нажмите **«Отмена»**.
2. Тест должен остаться **в прежнем виде**, ничего не подменилось.
**Дополнительно — большой файл:**
1. Возьмите файл больше 16 МБ.
2. Загрузите его.
**Что должно произойти:** ошибка о слишком большом файле; вопросы не
подменились.
**Дополнительно — неподдерживаемый тип:**
1. Возьмите файл `.xlsx` или картинку `.png`.
2. Попробуйте загрузить.
**Что должно произойти:** алерт «Неподдерживаемый формат…»; вопросы не
подменились.
**Если что-то не так — баг:**
- Подтверждение не появилось — вопросы заменились молча.
- Отказ в подтверждении всё равно подменил вопросы.
- Большой файл не вызвал ошибку, а как-то «прошёл».
- Файл не на русском дал тест с непонятной кашей вместо вопросов.
---
### 2.7. Поведение, если ключа AI нет
> Этот сценарий обычно проверять не нужно — он сработает автоматически, если разработчик уберёт ключ.
> Но если в каталоге AI вообще не работает, то проверьте именно это, чтобы понять — это баг или просто ключ не настроен.
**Как проверять:**
1. **«Настройки»** → если статус **«Не задан»** (красный) — это и есть тестируемая ситуация.
2. Откройте любой тест и нажмите по очереди:
- **«По названию»**
- **«По текущей сетке»**
- **«Проверить»**
- **«Улучшить»**
- **«AI: вопрос/переформулировать»** в любой карточке
- **«Загрузить документ»**
**Что должно произойти:** в каждом случае появляется понятное сообщение,
что AI не настроен, **и предложение открыть «Настройки»**. После согласия
— открывается страница `/settings`. Никакого «AI: ошибка» без объяснений.
**Если что-то не так — баг:**
- Сообщение об ошибке без слов «Настройки».
- Ссылка/кнопка «Открыть Настройки» не ведёт на нужную страницу.
- Сайт молча ничего не делает после нажатия AI-кнопки.
---
## Памятка: общий алгоритм отчёта о баге
Если что-то идёт не так, к тикету приложите:
1. **URL страницы**, на которой воспроизвели баг (полностью, из адресной
строки).
2. **Шаги** — что именно нажимали по порядку (1-2-3).
3. **Что увидели** против **что ожидали увидеть** (по описанию выше).
4. **Скриншот** экрана с проблемой (для модалок — со всем содержимым окна).
5. Если ошибка — **точный текст** сообщения.
6. **Учётная запись**, под которой воспроизвели (логин, без пароля).
Этого достаточно — лезть в консоль/код/базу не нужно и не надо.

291
docs/migration-final-inventory.md

@ -0,0 +1,291 @@
# Инвентаризация backend и справочный gap-analysis с `tgFlaskForm`
**Назначение.** §1–§9 — полная карта того, что **сейчас живёт** в `backend/` (Node.js / Express). Используется в [Этапе 1](migration-final.md#этап-1-текущий--единый-стек-express--flask-react--jinja-внутри-testingwebapp) как чек-лист «что переписать на Flask внутри `flask_app/`». §10 — **справочный gap-analysis** между этим и уже готовым модулем `cabinet/testing` в `HR_TG_Bot/tgFlaskForm` — пригодится в [Этапе 2](migration-to-tgflaskform.md) при слиянии.
**Связано:** [migration-final.md](migration-final.md) (главный трекер двух этапов), [migration-to-tgflaskform.md](migration-to-tgflaskform.md) (план Этапа 2 — слияние с tgFlaskForm), [PROJECT_STATUS.md](PROJECT_STATUS.md), [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
> **Прочитайте сначала [migration-final.md](migration-final.md)** — там зафиксировано разделение на Этап 1 (унификация стека внутри TestingWebApp, БД остаётся `clinic_tests`) и Этап 2 (слияние с HR-кабинетом, БД мигрирует на `hr_bot_test`). Содержимое §10 этого документа — материал для Этапа 2.
---
## 1. Карта HTTP-эндпоинтов (всё, что зовёт фронтенд)
Источник: `backend/src/app.js`, `backend/src/routes/*.js`. Перепроверено по фактическим вызовам из `frontend/src/**` (`frontend/src/api.js` — общий `fetch` с `credentials: 'include'`).
| # | Метод | Путь | Зовёт из фронта | Auth | Сервис(ы) | Особенности |
|---|------|------|----------------|------|----------|-------------|
| 1 | GET | `/api/health` | — | нет | inline | smoke-проверка |
| 2 | POST | `/api/auth/login` | `Login.jsx` | нет | `utils/auth`, `db/db`, `db/hrPool`, `utils/werkzeugPassword`, `utils/hrRoleMap`, `config/authConstants` | bcrypt (dev) **+** Werkzeug (HR) ветки; UPSERT `users` по `staff_id`; кладёт JWT в HTTP-only cookie |
| 3 | POST | `/api/auth/logout` | `CabinetLayout.jsx` | нет | inline | очистить cookie |
| 4 | GET | `/api/auth/me` | `CabinetLayout.jsx` | cookie JWT | `middleware/auth` + `featureFlags` | возвращает пользователя + флаги UI (`devUi`, `assignmentUi`) |
| 5 | GET | `/api/auth/dev/assignment-directory` | `TestDetail.jsx` (assign-блок) | cookie JWT, feature-flag | `services/assignmentDirectoryService` | сливает HR (`staff_members`, `employees_departments`) + `clinic_tests.users`; query: `q`, `department`, `clinic` |
| 6 | POST | `/api/tests/import/document` | `TestDetail.jsx` («Документ в вопросы») | cookie JWT | `services/documentExtractService` (PDF/DOCX/TXT), `services/documentGenService``services/llmClient` | `multer` (10 МБ, OS tmpdir); удаляет файл после извлечения |
| 7 | GET | `/api/tests` | `TestsList.jsx` | cookie JWT | `services/testAccessService.queryTestsVisibleToUser` + inline (hiddenByYou) | каталог + список «скрытые мной» |
| 8 | POST | `/api/tests` | `TestsList.jsx` | cookie JWT | `services/testDraftService.createTestWithVersion` | создаёт цепочку + версию 1 |
| 9 | GET | `/api/tests/:id/summary` | `TestDetail.jsx` | cookie JWT | inline + `testAccessService.userHasTestAccess` | карточка цепочки (одна строка) |
| 10 | GET | `/api/tests/:id/versions` | `TestDetail.jsx` | cookie JWT, **только автор** | inline + `testChainService.hasAnyAttemptForTest` | список версий + флаг `hasAttempts` |
| 11 | GET | `/api/tests/:id/editor` | `TestDetail.jsx` | cookie JWT, **только автор** | `testAttemptService.getEditorContent` | вопросы активной версии **с правильными ответами** |
| 12 | POST | `/api/tests/:id/ai/generate-test` | `TestDetail.jsx` (ИИ — целиком) | cookie JWT, **только автор** | `aiEditorService.generateFullTestByShape``llmClient` | строгая сетка `shape: [{optionsCount, hasMultipleAnswers}]`; до 40 вопросов |
| 13 | POST | `/api/tests/:id/ai/generate-question` | `TestDetail.jsx` (ИИ — один вопрос) | cookie JWT, **только автор** | `aiEditorService.generateOrRephraseQuestion``llmClient` | пустой текст → новый вопрос; непустой → переформулировка |
| 14 | POST | `/api/tests/:id/versions/:vid/activate` | `TestDetail.jsx` | cookie JWT, **только автор** | inline | транзакция; снимает `is_active` со всех версий, потом ставит на `:vid` |
| 15 | PATCH | `/api/tests/:id` | `TestDetail.jsx` | cookie JWT, **только автор** | inline | `chainActive` (true/false) — публикация в каталоге |
| 16 | POST | `/api/tests/:id/assign` | `TestDetail.jsx` (назначение) | cookie JWT, **только автор**, feature-flag | `assignmentUserService.ensureClinicUserIdForStaff` | принимает `userIds[]`/`staffIds[]`/legacy `userId`/`staffId`; одна строка `test_assignments` + N строк `test_assignment_targets` |
| 17 | POST | `/api/tests/:id/draft` | `TestDetail.jsx` («Сохранить») | cookie JWT, **только автор** | `testDraftService.saveTestDraft` | если есть попытки и переданы `questions` — fork новой версии (V.3) |
| 18 | POST | `/api/tests/:id/attempts/start` | `TestsList.jsx` | cookie JWT (доступ через `userHasTestAccess`) | `testAccessService.userHasTestAccess` + inline | новая попытка по активной версии |
| 19 | GET | `/api/tests/:id/attempts` | `TestDetail.jsx` («Прохождения») | cookie JWT, **только автор** | `testAttemptService.listTestAttemptsForAuthor` | до 200 попыток по всем версиям |
| 20 | GET | `/api/tests/:id/attempts/:aid/review` | `TestAttemptReview.jsx` | cookie JWT (владелец **или** автор) | `testAttemptService.getAttemptReviewForUser``buildReviewFromDb` | разбор попытки |
| 21 | GET | `/api/tests/:id/attempts/:aid/play` | `TestAttempt.jsx` | cookie JWT (владелец) | `testAttemptService.getPlayContent` | вопросы **без** правильных ответов |
| 22 | POST | `/api/tests/:id/attempts/:aid/submit` | `TestAttempt.jsx` | cookie JWT (владелец) | `testAttemptService.submitAttempt` | `FOR UPDATE`, проверка ответов, перезапись `user_answers`, статус `completed` |
| 23 | GET | `/api/tests/:id/chain-info` | `TestDetail.jsx` | cookie JWT (через `userHasTestAccess`) | `testAccessService` + `testChainService` | флаг `hasAnyAttempt` |
> **Итого: 22 функциональных эндпоинта** (без `/api/health`). Все ответы — JSON. Все входы — JSON или `multipart/form-data` (только `/import/document`).
---
## 2. Сервисный уровень (что должно появиться в Flask)
| Файл (Node) | Что делает | Что нужно в Python |
|------------|-----------|---------------------|
| `services/testAccessService.js` | Запросы каталога; `userHasTestAccess(testId, userId)` | SQLAlchemy/`psycopg2` версия двух запросов |
| `services/testDraftService.js` | `createTestWithVersion`, `saveTestDraft`, `forkNewVersion`, `replaceVersionContent`, `copyQuestionTree` | Транзакции на `psycopg2`/SQLAlchemy; следить за частичным уникальным индексом `uq_test_versions_one_active_per_test` |
| `services/testAttemptService.js` | `getEditorContent`, `getPlayContent`, `submitAttempt`, `buildReviewFromDb`, `getAttemptReviewForUser`, `listTestAttemptsForAuthor`; вычисление баллов | Самый объёмный модуль; внимательно с массивами UUID (`user_answers.selected_options uuid[]`) |
| `services/testChainService.js` | `hasAnyAttemptForTest` | Один SQL `EXISTS` |
| `services/aiEditorService.js` | `parseAndValidateShape`, `generateFullTestByShape`, `generateOrRephraseQuestion`, `assertDraftMatchesShape` | Чистый Python; зависит от `llmClient` и `documentGenService.validate*` |
| `services/documentGenService.js` | `parseJsonFromLlmText`, `validateAndNormalizeDraft`, `generationForImportDocument` | Чистый Python (`json.loads` + валидация формы) |
| `services/documentExtractService.js` | `resolveDocumentKind`, `extractTextFromFile`, `extractTextFromBuffer` | **Замены пакетов:** `mammoth``python-docx` или `mammoth.py`; `pdf-parse``pypdf` (или `pdfminer.six`) |
| `services/llmClient.js` | OpenAI-совместимый Chat Completions, JSON-mode, таймаут 120 с | `httpx` / `requests` + явный `timeout` |
| `services/assignmentDirectoryService.js` | Слияние `clinic_tests.users` ↔ HR (`staff_members`, `employees_departments`); фильтры `q/department/clinic` | Два пула; `psycopg2` достаточно |
| `services/assignmentUserService.js` | `ensureClinicUserIdForStaff` (UPSERT по `staff_id`) | UPSERT с `ON CONFLICT (staff_id)` |
| `utils/auth.js` | bcrypt + JWT + (Werkzeug fallback) | `passlib[bcrypt]` или `bcrypt`; **`flask-jwt-extended`** или ручной `pyjwt` |
| `utils/werkzeugPassword.js` | `scrypt:$N:$r:$p$salt$hex`, `pbkdf2:sha256:iter$salt$hex` | Уже **родной** Python: `werkzeug.security.check_password_hash` |
| `utils/hrRoleMap.js` | строка HR-роли → `'hr'/'manager'/'employee'` | Один `def map_hr_role()` |
| `middleware/auth.js` | `authenticate`, `requireRole`, `requireDepartment`, `optionalAuth` | Flask-декоратор `@login_required`/`@roles_required` (или `before_request`) |
| `config/featureFlags.js` | `isAssignmentFeatureEnabled` | Простая функция от env |
| `config/devAuthor.js` | `isTestAuthor(createdBy, userId)` | Один сравнительный helper |
| `config/authConstants.js` | `HR_MANAGED_PASSWORD_PLACEHOLDER`, `isHrAuthEnabled` | Без изменений |
| `messages/ru.js` | Текстовые сообщения API | `app/messages/ru.py` (dict) |
> **Особое внимание:** `testAttemptService.submitAttempt` использует `FOR UPDATE` и выгружает все `answer_options` версии разом. Простую построчную проверку «правильно/нет» делает Python-функция `same_selection(set, set)`. На больших тестах помогает индекс `idx_answer_options_question_id` — он уже есть.
---
## 3. Базы данных и схемы
### 3.1 Основная — `clinic_tests` (`DATABASE_URL`)
Из `backend/src/db/migrations/001_initial.sql`:
- `departments` (UUID, name)
- `users` (UUID, login UNIQUE, password_hash, full_name, role `user_role`, department_id FK, is_active, **`staff_id`** — миграция 003, **UNIQUE**)
- `tests` (UUID, title, description, passing_threshold, time_limit, allow_back, is_active, is_versioned, created_by FK)
- `test_versions` (UUID, test_id FK, version, is_active; миграция 002 добавляет `parent_id` + частичный уникальный индекс «одна активная версия на цепочку»)
- `questions` (UUID, test_version_id FK, text, question_order, has_multiple_answers)
- `answer_options` (UUID, question_id FK, text, is_correct, option_order)
- `test_assignments` (UUID, test_version_id FK, assigned_by FK, deadline DATE, max_attempts)
- `test_assignment_targets` (UUID, assignment_id FK, target_type `'department'|'user'`, target_id UUID)
- `test_attempts` (UUID, test_version_id FK, user_id FK, attempt_number, status `attempt_status`, started_at, completed_at, correct_count, total_questions, passed; UNIQUE(test_version_id, user_id, attempt_number))
- `user_answers` (UUID, attempt_id FK, question_id FK, selected_options **UUID[]**)
- `migrations` (служебная, имена применённых SQL)
**Расширения:** `uuid-ossp`. **Кастомные типы:** `user_role`, `target_type`, `attempt_status`.
### 3.2 Дополнительная — `hr_bot_test` (`HR_DATABASE_URL`, опционально)
Используется только на чтение через `db/hrPool.js`:
- `users` — для входа (HR_AUTH=1): `id`, `username`, `password_hash` (Werkzeug), `role`
- `staff_members``id`, `fio`, `web_login`
- `employees_departments``staff_id`, `department`
### 3.3 Миграции
3 SQL-файла в `backend/src/db/migrations/`. Простой `npm run migrate` пишет в служебную таблицу `migrations`. В Flask эквивалент: **Alembic** или ручной runner на `psycopg2`. Файлы `.sql` можно переиспользовать **как есть** — никакого Node-специфичного синтаксиса в них нет.
---
## 4. Зависимости Node → Python (план замены)
| Node-пакет | Python-замена | Комментарий |
|-----------|----------------|-------------|
| `express`, `cors`, `cookie-parser` | **Flask** + `flask-cors` | сессии встроены |
| `multer` | `Flask` (`request.files`) + `werkzeug.utils.secure_filename` + `tempfile` | лимит 10 МБ — `MAX_CONTENT_LENGTH` |
| `dotenv` | `python-dotenv` (уже есть в `flask_app/run.py`) | — |
| `pg` | `psycopg2-binary` (или `psycopg[binary]` v3) | — |
| `bcryptjs` | `bcrypt` (`pip install bcrypt`) | формат `$2b$…` совместим |
| `jsonwebtoken` | `pyjwt` или `flask-jwt-extended` | важен **тот же** алгоритм/секрет, чтобы старые cookie работали в переходный период |
| `mammoth` | `mammoth` (PyPI: `pip install mammoth`) или `python-docx` | API почти идентичен |
| `pdf-parse` | `pypdf` (`pip install pypdf`) или `pdfminer.six` | `pypdf.PdfReader().pages[].extract_text()` |
| `fetch` (LLM) | `httpx` (рекомендую — есть `timeout`, async) или `requests` | сохранить `Authorization: Bearer …`, `response_format: json_object` |
**Уже в `tgFlaskForm` есть готовое** — `werkzeug.security.check_password_hash` (бесплатно), Sentry, Jinja2-шаблоны, обмен с `staff_members`. Переиспользовать, если выберем сценарий «общий кабинет».
---
## 5. Переменные окружения (полный список)
| Переменная | Где читается | Назначение | Обязательная |
|-----------|--------------|-----------|--------------|
| `NODE_ENV` | `app.js`, `auth.js`, `featureFlags.js`, `db.js` | dev vs production | да (нет → dev) |
| `PORT` | `server.js` | Express, по умолчанию 3001 | нет |
| `FRONTEND_URL` | `app.js` (CORS) | разрешённый origin в prod | в prod — да |
| `DATABASE_URL` **или** `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USER`/`DB_PASSWORD` | `db/poolConfig.js` | основная БД `clinic_tests` | да |
| `DB_POOL_MAX`, `DB_IDLE_TIMEOUT`, `DB_CONNECTION_TIMEOUT` | `db/poolConfig.js` | пул | нет |
| `HR_DATABASE_URL` | `db/poolConfig.js` (`getHrPoolConfig`) | hr_bot_test (read-only) | при HR_AUTH=1 — да |
| `HR_DB_POOL_MAX` | то же | пул HR | нет |
| `HR_AUTH` (1/true) | `config/authConstants.js` | вход по HR-логину | нет |
| `JWT_SECRET` | `utils/auth.js` | подпись JWT | **да** |
| `JWT_EXPIRES_IN` | `utils/auth.js` | дефолт `7d` | нет |
| `DEEPSEEK_API_KEY` | `services/llmClient.js` | LLM, приоритет №1 | для AI/импорта — да |
| `OPENAI_API_KEY` | то же | LLM, приоритет №2 | альтернатива |
| `LLM_BASE_URL` | то же | сменить хост (proxy / vLLM) | нет |
| `LLM_MODEL` | то же | по умолчанию `deepseek-chat` или `gpt-4o-mini` | нет |
| `LLM_NO_JSON` (1) | то же | отключить `response_format: json_object` (для моделей без поддержки) | нет |
| `CLINIC_ASSIGNMENT_ENABLED` (1) | `config/featureFlags.js` | прод: включить assign | в prod — да |
В `flask_app/.env` нужно перенести **те же ключи** (имена можно сохранить, чтобы не плодить вариации).
---
## 6. Что вызывает фронтенд (карта зависимостей React → API)
Используется один тонкий клиент `frontend/src/api.js`: `fetch` с `credentials: 'include'`, базовый путь — пустой (т.е. **`/api/...` относительно текущего origin**, что в dev резолвит Vite-proxy, а в prod — Nginx). Это значит:
- **Менять фронтенд при смене бэкенда не нужно**, если новый сервис отвечает по тем же путям.
- В dev сейчас `vite.config.js` проксирует `/api` на Express (`localhost:3001`). После переноса — заменить адрес/порт на Flask (см. `flask_app/run.py`, по умолчанию `3108`).
Список путей (отсортирован по убыванию использований):
```
/api/auth/login
/api/auth/logout
/api/auth/me
/api/auth/dev/assignment-directory?q&department&clinic
/api/tests
/api/tests/:id/summary
/api/tests/:id/versions
/api/tests/:id/editor
/api/tests/:id/chain-info
/api/tests/:id/draft
/api/tests/:id/assign
/api/tests/:id/ai/generate-test
/api/tests/:id/ai/generate-question
/api/tests/:id (PATCH)
/api/tests/:id/versions/:vid/activate
/api/tests/:id/attempts
/api/tests/:id/attempts/start
/api/tests/:id/attempts/:aid/play
/api/tests/:id/attempts/:aid/review
/api/tests/:id/attempts/:aid/submit
/api/tests/import/document
```
---
## 7. Тесты, которые тянутся за собой
В `backend/src/**/*.test.js` (Node test runner):
- `apiSmoke.test.js` — smoke-проверки HTTP.
- `services/testChainService.test.js`
- `services/aiEditorService.test.js`
- `services/documentGenService.test.js`
- `services/documentExtractService.test.js`
- `utils/werkzeugPassword.test.js`
- `integration/v9card1.test.js` (требует `CLINIC_TESTS_INTEGRATION=1`)
После переноса нужны их Python-аналоги (`pytest`). Часть (валидация LLM-формы, разбор Werkzeug) тривиально превращается в юниты.
---
## 8. Чего сейчас в `flask_app/` НЕТ (чтобы не повторяться в Спринте 2)
`flask_app/` содержит только: `run.py`, `app/__init__.py``/health` и `/`), `app/templates/`, `app/static/`. Не реализовано **ничего** из перечня §1–§4. Это значит, что в Спринте 2 нужно с нуля поднять:
1. Подключение к БД (минимум — основной пул `clinic_tests`).
2. Декоратор аутентификации (cookie JWT) и эндпоинты `/api/auth/*`.
3. Роуты `/api/tests/*` поэтапно — начать с **read-only** (list, summary, versions, editor, attempts, review), потом write (draft, activate, patch, assign, attempts/start/submit), потом AI/import.
4. CORS (в dev совпадает с тем, что у Express).
5. Запуск под Vite/Nginx — обновить proxy/upstream.
---
## 9. Критерии «можно удалять `backend/`» (Спринт 4)
- ETL-скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py` прогнан **на копии прод**, агрегаты совпадают (тесты, версии, вопросы, попытки) — затем прогон на проде в окне.
- Все сценарии §1 (TestingWebApp) либо имеют **рабочий аналог** в `cabinet/testing/`, либо явно **признаны не-MVP** (см. §10).
- Внутренние ссылки и закладки сотрудников переключены на URL общего кабинета (`/cabinet/testing/...`).
- Документация (`README.md`, `DEV_CONTOUR_USER_GUIDE.md`, `шаги/*`) перестала ссылаться на `backend/` и `frontend/` TestingWebApp.
- Резерв: ветка `legacy/clinic-tests-node` (или `legacy/express-backend`) зафиксирована перед удалением.
---
## 10. Gap-analysis: `tgFlaskForm/cabinet/testing` vs Express
Сверка проведена по фактическому коду `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/*` и `HR_TG_Bot/tgFlaskForm/db/queries/testing_queries.py` на 2026-04-27. Колонка «Что нужно сделать» — это и есть остаток Спринта 2 из [migration-to-tgflaskform.md](migration-to-tgflaskform.md).
### 10.1 Маппинг сущностей и ID
| TestingWebApp (`clinic_tests`) | tgFlaskForm (`hr_bot_test`, схема `testing_*`) | Замечание |
|---|---|---|
| `tests` (UUID) | `testing_tests` (Integer PK) | UUID → integer; маппинг хранится в `_clinic_tests_migration_map` |
| `test_versions` (UUID, `version`, `is_active`, `parent_id`) | `testing_test_versions` (Integer, `version_number`, `is_active_version`) — **+** `passing_score_percent`, `time_limit_minutes`, `allow_back_navigation` (нормализовано на версию) | В Express часть полей лежала на `tests` (`passing_threshold`, `time_limit`, `allow_back`); ETL поднимает их на версию |
| `questions` (`text`, `has_multiple_answers`, `question_order`) | `testing_questions` (`question_text`, `question_type` `'single'\|'multiple'`, `sort_order`) | `has_multiple_answers: true``question_type='multiple'` |
| `answer_options` (`text`, `is_correct`, `option_order`) | `testing_answers` (`answer_text`, `is_correct`, `sort_order`) | прямой маппинг |
| `users` + `users.staff_id` | `staff_members.id` напрямую | Express держит «свою» строку `users` со ссылкой на `staff_id`; в HR — только `staff_members` |
| `test_assignments` + `test_assignment_targets` (target_type='user', target_id=UUID) | `testing_assignments` (одна строка = одна пара тест×сотрудник, `assigned_to=staff_id`) | **Существенное расхождение модели** — см. §10.3 |
| `test_attempts` (привязан к `test_version_id`, `user_id`) | `testing_attempts` (привязан к `assignment_id` + `test_version_id`, попытка от **сотрудника**) | Если в clinic попытка не имеет связанного assignment — ETL создаёт синтетическое назначение (`max_attempts=99`) |
| `user_answers` (`selected_options uuid[]`) | `testing_attempt_answers` (одна строка на каждый выбранный `answer_id`) | Развёртка массива в N строк |
| (нет аналога) | `testing_settings` (key-value), `testing_head_positions` (право назначения по должности) | в Express не было — RBAC проще |
### 10.2 Эндпоинты Express → роуты `tgFlaskForm`
| # | Express | Соответствие в `tgFlaskForm` | Статус | Что сделать |
|---|---------|------------------------------|--------|-------------|
| 1 | `POST /api/auth/login` | `webApp/auth/*` (общая сессия HR) | ✅ есть аналог | Express-сессии не переносим; пользователи входят как обычно в HR-кабинет |
| 2 | `POST /api/auth/logout` | `webApp/auth/*` | ✅ — | — |
| 3 | `GET /api/auth/me` | session + `helpers._get_staff_id` | ✅ — | — |
| 4 | `GET /api/auth/dev/assignment-directory` | `routes_assignments.assign_form` (страница) + `testing_get_employees_for_assignment` | ✅ — | На UI: добавить «Выбрать всех» (см. Спринт 3 React-кабинета), мульти-фильтры |
| 5 | `GET /api/health` | `webApp/__init__.py` | ✅ — | — |
| 6 | `GET /api/tests` (каталог + `hiddenByYou`) | `routes_tests.test_list` (по автору) + `routes_passing.my_tests` (по назначениям) | ⚠ нет «скрытые мной» отдельным разделом | Добавить вкладку/фильтр «скрытые автором» в `cabinet/testing/test_list.html` |
| 7 | `POST /api/tests` (создать пустой) | `routes_tests.test_create` (требует full payload + ≥7 вопросов) | ⚠ другой контракт | Решить продуктово: оставить ограничение `≥7` либо разрешить «пустой тест» как в Express. **Рекомендую** разрешить пустой и валидировать только при публикации |
| 8 | `GET /api/tests/:id/summary` | вшито в `test_edit` | ⚠ нет отдельной summary | Добавить функцию `testing_get_test_summary(test_id)` если нужен лёгкий вариант для списка |
| 9 | `GET /api/tests/:id/versions` | `testing_get_test_for_edit` отдаёт активную; история — внутри `test_edit` | ⚠ — | Вынести список версий в API/шаблон («История» в UX) |
| 10 | `GET /api/tests/:id/editor` | `routes_tests.test_edit` + `testing_get_test_for_edit` | ✅ — | сверить набор полей, отдаваемых шаблону, с тем что показывает React-редактор |
| 11 | `POST /api/tests/:id/ai/generate-test` (строгая `shape: [{optionsCount, hasMultipleAnswers}]`, до 40 вопросов) | `routes_ai.ai_generate` принимает `topic, question_count` (фикс. форма) | ❌ **gap** | Добавить вариант с явной формой (передавать массив `optionsCount/hasMultipleAnswers`); либо принять, что HR-кабинет генерирует «обобщённо» |
| 12 | `POST /api/tests/:id/ai/generate-question` (один вопрос: пусто=новый, текст=переформулировать) | `routes_ai.ai_improve` (улучшить) + `ai_distractors` (варианты) | ❌ **gap** | Добавить ручку «один вопрос со строгой сеткой» (объединить `improve` + `distractors` под единый сценарий) |
| 13 | `POST /api/tests/:id/versions/:vid/activate` | `routes_tests.test_version_activate` | ✅ — | — |
| 14 | `PATCH /api/tests/:id` (`chainActive`) | `routes_tests.test_toggle` | ✅ — | сверить семантику (toggle vs явный флаг) |
| 15 | `POST /api/tests/:id/assign` (массивы `userIds`/`staffIds`) | `routes_assignments.assign_submit` (`employee_ids = staff_id[]`) | ✅ совместимо | — |
| 16 | `POST /api/tests/:id/draft` (с авто-fork версии) | `routes_tests.test_update` (тоже фаркает при `has_attempts`) | ⚠ контракт другой | Сверить, что в HR-кабинете тоже работает «правка без попыток = на месте, после попыток = новая версия». В коде есть, но нужна проверка |
| 17 | `POST /api/tests/:id/attempts/start` | `routes_passing.start_attempt(assignment_id)` | ⚠ другой ключ | Привести к виду «start by test_id» **или** оставить как есть и в UI стартовать по `assignment_id` (логичнее) |
| 18 | `GET /api/tests/:id/attempts` | `routes_results.tracker` (общая лента) — без фильтра по тесту | ⚠ нет per-test ленты | Добавить фильтр `test_id` или отдельный URL `/cabinet/testing/tests/:id/attempts` |
| 19 | `GET /api/tests/:id/attempts/:aid/review` | `routes_results.result(attempt_id)` | ✅ — | сверить, что разбор показывает выбранные/верные варианты по каждому вопросу |
| 20 | `GET /api/tests/:id/attempts/:aid/play` | `routes_passing.take_test(attempt_id)` | ✅ — | — |
| 21 | `POST /api/tests/:id/attempts/:aid/submit` | `routes_passing.finish_attempt(attempt_id)` (+ `save_answer`) | ⚠ контракт другой | Express отправляет все ответы пакетом; HR — пошагово (`save_answer` × N + `finish`). Это **OK для UX** (продолжить позже), но фронт работает иначе |
| 22 | `POST /api/tests/import/document` | `routes_import.import_document` | ✅ — | сверить mime-типы и сообщения об ошибках |
| 23 | `GET /api/tests/:id/chain-info` (`hasAnyAttempt`) | вшито в `testing_get_test_for_edit['has_attempts']` | ✅ — | — |
> **Итог по gap-analysis:** 13 из 22 эндпоинтов закрыты «как есть» или близко; 4 требуют добавления (AI «один вопрос», AI «строгая сетка», per-test attempts, summary); 5 — расхождение контракта, надо принять решение или подровнять.
### 10.3 Расхождения, которые ETL уже обходит
- **Назначения на отдел:** в `clinic_tests.test_assignment_targets` могут быть строки `target_type='department'`. ETL разворачивает их в N строк `testing_assignments` по списку сотрудников отдела на момент миграции (см. шапку `tools/migrate_clinic_tests_to_hr.py`).
- **Попытки без явного назначения:** в Express `test_attempts.user_id` напрямую идёт от пользователя; в HR попытка обязательно имеет `assignment_id`. ETL для каждой пары (тест, сотрудник) создаёт **синтетическое** назначение `max_attempts=99` и привязывает попытку к нему.
- **UUID → Integer:** идемпотентный маппинг лежит в `public._clinic_tests_migration_map` (entity, old_uuid → new_id) — повторный прогон ETL безопасен.
### 10.4 Чего точно нет в `tgFlaskForm` и потребует доработки
1. **AI «один вопрос со строгой сеткой»** (`generate_or_rephrase_question` из `aiEditorService.js`).
2. **AI «целый тест по shape»** с проверкой `optionsCount` и `hasMultipleAnswers` для каждой строки.
3. **«Скрытые автором» цепочки** как отдельный список на UI.
4. **`/tests/:id/summary`** — лёгкая ручка для карточки в списке (если потребуется в новом UI).
5. **Per-test лента попыток** (UI «Прохождения» в аккордеоне «История»).
6. **Мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md):** аккордеоны «О тесте / Вопросы / История / Показ в каталоге», фикс-футер, икон-кнопка удаления варианта, «Выбрать всех» в назначении и т.д. В Этапе 1 это переносится в Jinja-шаблоны `flask_app/app/templates/`. В Этапе 2 уже готовые шаблоны переезжают в `tgFlaskForm/webApp/templates/cabinet/testing/`.
### 10.5 Что точно НЕ переносим
- **Свой JWT/cookie/bcrypt** из `backend/src/utils/auth.js` и `middleware/auth.js`. Авторизация — через сессии общего кабинета.
- **Werkzeug-полифилл в Node** (`utils/werkzeugPassword.js`). В Python это родная функция `werkzeug.security.check_password_hash`.
- **Свои миграции SQL** из `backend/src/db/migrations/`. Целевая схема живёт в `tgFlaskForm/db/models.py` и сопутствующих DDL/Alembic.
- **`flask_app/`** в этом репозитории — каркас уже **не нужен**, в Спринте 4 решим: удалить или оставить как историческую заготовку.
- **`backend/src/**/*.test.js`** (Node test runner). Их предметная нагрузка покрывается **либо** уже существующими тестами `tgFlaskForm`, **либо** новыми pytest-юнитами.

100
docs/migration-final.md

@ -0,0 +1,100 @@
# Унификация стека TestingWebApp с `tgFlaskForm` — план и журнал
> **Корректировка курса от 2026-04-27.**
> Ранее в этом документе фигурировал «полный переезд в HR-кабинет (`tgFlaskForm`) с cutover'ом и удалением React/Express». Это было забеганием вперёд. Текущая фаза — **только унификация стека**, без слияния репозиториев и без миграции данных.
---
## Назначение документа
Зафиксировать разделение работы на **два этапа** и текущий статус каждого. Этот файл — **трекер решения и журнал**, основной план Этапа 1 — здесь же; план Этапа 2 (на будущее) — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md).
**Связано:** [migration-final-inventory.md](migration-final-inventory.md) (карта 22 эндпоинтов Express, БД, env, зависимости, плюс справочный gap-analysis с уже существующим модулем в `tgFlaskForm` — пригодится в Этапе 2), [PROJECT_STATUS.md](PROJECT_STATUS.md), [README](../README.md), [`flask_app/README.md`](../flask_app/README.md), [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
---
## Этап 1 (текущий) — единый стек: Express → Flask, React → Jinja, **внутри TestingWebApp**
### Цель
Привести TestingWebApp к **тому же стеку**, что у `HR_TG_Bot/tgFlaskForm`:
- **Бэкенд:** Python 3 + Flask, точечный SQL/SQLAlchemy в стиле `tgFlaskForm`. Развивается в каталоге [`flask_app/`](../flask_app/) этого репозитория (сейчас — минимальный каркас).
- **Фронтенд:** Jinja-шаблоны в `flask_app/app/templates/`, мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) переносится один в один. React (`frontend/`) уходит **после** того, как Jinja-версия закроет все экраны.
- **БД:** **остаётся `clinic_tests`** (со своими UUID-ключами). Никаких изменений схемы.
- **Авторизация:** JWT/bcrypt + опциональный `HR_AUTH=1` (как в Express) — переносим как есть.
### Что **не** делаем в Этапе 1
- **Не** трогаем `HR_TG_Bot/tgFlaskForm/`. Его модуль `cabinet/testing` живёт своей жизнью.
- **Не** мигрируем данные `clinic_tests → hr_bot_test`. ETL-скрипт `migrate_clinic_tests_to_hr.py` есть, но он — для Этапа 2.
- **Не** удаляем `backend/` и `frontend/` сразу. Они работают параллельно с `flask_app/` до полного паритета. Удаление — последним PR этапа.
### Стартовая точка `flask_app/`
| Что есть | Файл / артефакт |
|---|---|
| Flask-приложение (`create_app`) | `flask_app/app/__init__.py` |
| Точка входа (dev / waitress) | `flask_app/run.py` |
| `/health` | `flask_app/app/__init__.py` |
| Пустой `index.html` | `flask_app/app/templates/index.html` |
| Зависимости | `flask_app/requirements.txt` (Flask, python-dotenv, waitress) |
| Docker-сервис на порту 3108 | `flask_app/Dockerfile`, корневой `docker-compose.dev.yml` (сервис `testing-flask`) |
Всё остальное — **писать**.
### План Этапа 1 (по спринтам)
| Спринт | Цель | Артефакты |
|---|---|---|
| **E1.0 — База Flask-приложения** ✅ | БД-пул (SQLAlchemy + psycopg2), Flask sessions через `SECRET_KEY`, конфиг через `.env`, структура blueprint'ов, шаблон `base.html` в стиле кабинета, обработчики 404/500, `/health` с проверкой БД. **Без бизнес-логики.** | `flask_app/app/db.py`, `flask_app/app/__init__.py`, `flask_app/app/blueprints/main.py`, `flask_app/app/templates/{base,index,404,500}.html`, `flask_app/app/static/css/app.css`, обновлённые `requirements.txt` и `.env.example` |
| **E1.1 — Auth и `/api/me`** ✅ | Flask sessions (signed cookie), bcrypt + Werkzeug (`werkzeug.security.check_password_hash`), опц. `HR_AUTH=1` с UPSERT в `clinic_tests.users` по `staff_id`. UI-страница `/login`, JSON-API `/api/auth/{login,logout,me}`, декораторы `login_required`/`require_role`, `current_user` доступен в шаблонах. | `flask_app/app/auth/{routes,services,decorators,hr_role}.py`, `flask_app/app/{config,messages}.py`, `flask_app/app/templates/auth/login.html`, обновлены `base.html`, `__init__.py`, `requirements.txt` (+`bcrypt`) |
| **E1.2 — Тесты: список и редактор** ✅ | Перенесены 10 эндпоинтов из Express: `GET/POST /api/tests`, `GET /api/tests/:id/{summary,versions,editor}`, `POST /api/tests/:id/draft`, `POST /api/tests/:id/versions/:vid/activate`, `PATCH /api/tests/:id`, `POST /api/tests/:id/ai/{generate-test,generate-question}`. UI: `/tests` (каталог + создание), `/tests/:id/edit` (рабочий редактор с AI). Полная мобильная отполировка UX (4 аккордеона + fixed footer + drag-n-drop) — в **E1.7**. | `flask_app/app/services/{llm_client,draft_validator,ai_editor,test_access,test_chain,test_draft,editor_content}.py`, `flask_app/app/tests/{__init__,routes}.py`, `flask_app/app/templates/tests/{list,editor}.html`, `flask_app/app/static/js/editor.js`, обновлены `base.html`, `index.html`, `__init__.py` |
| **E1.3 — Импорт документов** ✅ | `POST /api/tests/import/document` (PDF/DOCX/TXT/MD извлечение текста через `pypdf` и `python-docx`), интеграция с AI-генерацией черновика (`generation_for_import_document`), кнопка «Импорт документа» в AI-панели редактора, лимит 16 МБ. | `flask_app/app/services/{document_extract,document_gen}.py`, эндпоинт в `flask_app/app/tests/routes.py`, кнопка в `editor.html` + `editor.js`, `requirements.txt` (+`pypdf`, `python-docx`) |
| **E1.4 — Назначение и прохождение** | Эндпоинты `assign`, `attempts/start`, `attempts/:id/play`, `attempts/:id/submit`, `attempts/:id/review`. Шаблоны: `assign.html`, `take_test.html`, `test_result.html`. | `flask_app/app/assignments/`, `flask_app/app/attempts/`, шаблоны |
| **E1.5 — Трекер и настройки** | Трекер прохождений, настройки модуля (ключи AI и т.д.), цепочки тестов. Шаблоны `tracker.html`, `settings.html`. | `flask_app/app/tracker/`, `flask_app/app/settings/` |
| **E1.6 — Cutover внутри репозитория** | `docker-compose.dev.yml` указывает на `flask_app/` как основной сервис; Nginx маршрутизирует `/api` и UI на новый Flask. Удаление `backend/` и `frontend/` отдельным PR. README → актуальные команды. | `docker-compose.dev.yml`, корневой `README.md`, `frontend/` и `backend/` удаляются |
| **E1.7 — UX-полировка редактора** | Перевод базового редактора (E1.2) на мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md): 4 аккордеона (Шапка / AI-помощник / Вопросы / Действия), sticky footer, drag-n-drop вопросов, импорт документа в подразделе AI-блока (после E1.3). | `flask_app/app/templates/tests/editor.html`, `flask_app/app/static/js/editor.js`, новый `static/css/testing.css` |
| **E1.8 — AI-функции v2** ✅ | `/settings` (статус ключа из ENV + ping), `POST /api/llm/ping`, на тесте — `ai/generate-by-title` (без сетки), `ai/check` (рецензия), `ai/improve` (массовое «было → стало» с чекбоксами). На уровне вопроса — уже есть `ai/generate-question` из E1.2 (создаёт вопрос или переформулирует). Все AI-эндпоинты унифицированы: при отсутствии ключа — `{ error, code, settingsUrl: '/settings' }`. | `flask_app/app/services/{ai_editor,llm_client}.py`, `flask_app/app/blueprints/settings.py`, `flask_app/app/templates/settings.html`, ссылка «Настройки» в `base.html`, обновлены `tests/routes.py`, `editor.html`, `editor.js` |
### Критерии готовности Этапа 1
- Все 22 эндпоинта Express (см. [migration-final-inventory.md](migration-final-inventory.md)) реализованы в `flask_app/` и проходят smoke-тесты.
- Все экраны мобильного UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) воспроизведены в Jinja.
- В `docker-compose.dev.yml` остался **один** сервис приложения (Flask). `backend/` и `frontend/` удалены или перенесены в ветку `legacy/clinic-tests-node`.
- БД — по-прежнему `clinic_tests`, схема не менялась.
---
## Этап 2 (на будущее, без сроков) — слияние с `tgFlaskForm`
Когда заказчик решит «вот теперь объединяем» — **вся** разработанная Flask-логика и шаблоны легко переносятся в `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/` и `templates/cabinet/testing/`, потому что **стек уже совпадает**. Это и есть смысл Этапа 1.
Что нужно сделать в Этапе 2 (план — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md)):
1. Перенести код Flask-приложения как blueprint в `tgFlaskForm`.
2. Адаптировать модели под существующие `Testing*` таблицы (`hr_bot_test.testing_*`).
3. Перевести авторизацию на сессии общего HR-кабинета.
4. Прогнать ETL `clinic_tests → hr_bot_test` (скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py` уже готов: 437 строк, режимы `--dry-run`/`--apply`, идемпотентность через `_clinic_tests_migration_map`).
5. Cutover (если к тому моменту появятся реальные пользователи; сейчас TestingWebApp — песочница для тестировщиков).
**Решения, которые относятся к Этапу 2** (зафиксированы заранее, чтобы потом не переоткрывать):
- **`test_assignments`:** переносим 1:1, дописывая отдельный блок в ETL (сейчас скрипт переносит только пары через попытки).
- **Пользователи без `staff_id`:** игнорируем с WARN; по договорённости настоящие пользователи всегда привязаны к `staff_members`.
- **Cutover / окно простоя:** не нужны, пока TestingWebApp остаётся песочницей.
---
## Журнал
| Дата | Что сделано |
|------|-------------|
| 2026-04-27 | Спринт 0 («инвентаризация» в старой нумерации) закрыт: артефакт [migration-final-inventory.md](migration-final-inventory.md) — карта 22 эндпоинтов Express, БД, env, зависимости. |
| 2026-04-27 | Принято решение: **сценарий B + b1** (полный переезд в HR-кабинет). |
| 2026-04-27 | **Курс скорректирован:** Этап 1 = унификация стека внутри TestingWebApp (Express → Flask + React → Jinja, БД остаётся `clinic_tests`). Этап 2 = слияние с `tgFlaskForm` — на будущее. ETL и удаление React переходят в Этап 2. Документы переписаны под двух-этапную картину. Эксперимент с правкой `tgFlaskForm/cabinet/testing/test_editor.html` (ветка `feat/testing-editor-jinja-redesign`) откачен и не оставил следов в HR-репо. |
| 2026-04-27 | **E1.8 закрыт.** AI v2: страница `/settings` (статус ключа из ENV, `Проверить подключение``POST /api/llm/ping`). Три новых эндпоинта на тесте: `POST /api/tests/<id>/ai/generate-by-title` (генерация только по названию + опции «сколько вопросов / сколько вариантов»), `POST /api/tests/<id>/ai/check` (рецензия: вердикт + разделы рекомендаций), `POST /api/tests/<id>/ai/improve` (массовое «было → стало» с проверкой неизменности сетки). UI редактора: кнопки «Сгенерировать по названию», «Проверить тест», «Улучшить тест»; общий `<dialog>` для модалок check/improve; чекбоксы в improve позволяют применять изменения по выбранным вопросам. Все AI-эндпоинты унифицированы: при отсутствии ключа возвращают `{ error, code, settingsUrl: '/settings' }` 502 — фронт предлагает открыть Настройки. |
| 2026-04-27 | **E1.3 закрыт.** Импорт документов: `app/services/document_extract.py` (PDF через `pypdf`, DOCX через `python-docx`, TXT/MD), `app/services/document_gen.py` (`generation_for_import_document` — извлекает текст, при наличии LLM-ключа просит модель собрать draft через `validate_and_normalize_draft`), эндпоинт `POST /api/tests/import/document` под `@login_required` с лимитом 16 МБ. UI редактора: кнопка «Импорт документа» в AI-панели, после загрузки — confirm с предложением применить черновик; если ключа нет — алерт с превью текста. В `requirements.txt` добавлены `pypdf>=4` и `python-docx>=1.1`. |
| 2026-04-27 | **E1.2 закрыт.** Перенесены `backend/src/routes/tests.js` (только E1.2-эндпоинты — без `import/document`/`assign`/`attempts`/`chain-info`, они уйдут в E1.3-E1.5) + сервисы `testDraftService.js`, `testAccessService.js`, `testChainService.js`, `aiEditorService.js`, `documentGenService.js` (только парсер JSON и валидатор draft), `llmClient.js`, `getEditorContent` из `testAttemptService.js`. Эндпоинты регистрируются в blueprint `tests`. AI-генерация: `parseAndValidateShape` 1:1, ошибки LLM (`llm_*`-коды) пробрасываются как 502 с кодом в JSON. UI: каталог тестов с кнопкой создания (модалка `<dialog>`) и рабочий редактор (inline-поля, AI-кнопки «весь тест» / «один вопрос», добавление/удаление/перемещение вопросов и вариантов, сохранение черновика, переключатель «Цепочка активна»). Полный мобильный UX редактора (аккордеоны+fixed footer+drag-n-drop из Спринта 3) вынесен в новый спринт **E1.7** — этот PR закрывает функциональность, не дизайн. |
| 2026-04-27 | **E1.1 закрыт.** Перенесены `backend/src/routes/auth.js` + `middleware/auth.js` + `utils/{auth,werkzeugPassword,hrRoleMap}.js` в `flask_app/app/auth/`. Решение: Flask sessions (signed cookie) вместо JWT, как договорились (вариант A). Поддерживаются bcrypt-хеши (`$2*`) и Werkzeug-хеши (`scrypt:`/`pbkdf2:`). Эндпоинты — те же пути, что в Express: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me` (отдаёт `user`, `devUi`, `assignmentUi`). Дополнительно — HTML-страница `/login` (форма) и `POST /logout`. Декораторы: `@login_required`, `@require_role(...)`. В шаблонах доступны `current_user`, `hr_auth_enabled`, `dev_ui`, `assignment_ui`. Защита от open-redirect в параметре `?next=`. Главная (`/`) теперь требует логин. |
| 2026-04-27 | **E1.0 закрыт.** В `flask_app/`: SQLAlchemy/psycopg2-пул в стиле `tgFlaskForm/db/session.py` (`app/db.py`, основная БД `clinic_tests` + опциональная HR-БД при `HR_AUTH=1`), фабрика `create_app` с регистрацией blueprint'ов, обработчиками 404/500 и Flask sessions, главный blueprint `main` с `/` и `/health` (smoke-проверка БД), `base.html` в стиле кабинета HR (Tailwind CDN + Manrope + Material Symbols, без зависимостей от HR-репо), шаблоны `index/404/500`, минимальный `static/css/app.css`. Бизнес-логика **не** добавлялась. |

189
docs/migration-to-tgflaskform.md

@ -1,175 +1,102 @@
# Перенос TestingWebApp на стек HR_TG_Bot / tgFlaskForm # Этап 2 (на будущее) — слияние TestingWebApp с `HR_TG_Bot/tgFlaskForm`
**Тот же план простым языком (две базы, люди, этапы):** [migration-to-tgflaskform-plain.md](migration-to-tgflaskform-plain.md). > **Не текущая фаза.** Текущая работа — **Этап 1**: унификация стека внутри TestingWebApp (Express → Flask + React → Jinja). См. [migration-final.md](migration-final.md). Этот документ — план **последующего** слияния, когда заказчик решит объединять.
**Назначение документа:** зафиксировать целевую архитектуру, **спринтовый план** доведения функциональности до паритета и **порядок миграции данных** из отдельного приложения (`Express` + `React` + БД `clinic_tests`) в кабинет **`tgFlaskForm`** (Flask, шаблоны, общая БД `hr_bot_test`, таблицы `testing_*`). **Простое объяснение тех же шагов:** [migration-to-tgflaskform-plain.md](migration-to-tgflaskform-plain.md).
**Связанные материалы:** [PROJECT_STATUS.md](PROJECT_STATUS.md), [README.md](../README.md), [TEST_TABLES_ANALYSIS.md](TEST_TABLES_ANALYSIS.md), код модуля в репозитории HR: `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`, модели: `HR_TG_Bot/tgFlaskForm/db/models.py`. **Связано:** [migration-final.md](migration-final.md) (главный трекер двух этапов), [migration-final-inventory.md](migration-final-inventory.md) (карта Express + gap-analysis с уже существующим модулем `tgFlaskForm`), [PROJECT_STATUS.md](PROJECT_STATUS.md), [README](../README.md), код HR-кабинета: `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`, модели: `HR_TG_Bot/tgFlaskForm/db/models.py`.
**Каркас нового контура в этом репозитории:** [../flask_app/README.md](../flask_app/README.md).
--- ---
## 0. Стратегия переходного периода (отдельное приложение, тот же стек) ## 0. Предусловие — Этап 1 закрыт
**Решение:** переписывание с Node/React на **тот же стек, что у мини-приложения и кабинета HR** — Python 3, **Flask**, шаблоны (Jinja2), статический JS, работа с PostgreSQL в духе `tgFlaskForm`. При этом сервис **пока живёт отдельно**: свой процесс, свой URL/порт, **не** обязан совпадать с деплоем полного `HR_TG_Bot/tgFlaskForm`. К моменту, когда этот документ берётся в работу, в TestingWebApp **уже** должно быть:
**Зачем так:** быстрее выйти на паритет по UX и данным, **без** риска «большого взрыва» в едином кабинете; позже либо встраиваете модуль в кабинет (общий `webApp`), либо оставляете отдельный вход — стек уже совпадает. - Бэкенд переписан с Express на Flask внутри [`flask_app/`](../flask_app/), все 22 эндпоинта работают.
- Фронтенд переписан с React на Jinja-шаблоны в `flask_app/app/templates/`.
- БД — по-прежнему `clinic_tests`, схема не менялась.
- В репозитории остался один сервис приложения.
**Обязательно зафиксировать продуктово:** Если что-то из этого ещё не готово — Этап 2 не начинается.
| Вопрос | Рекомендация |
|--------|----------------|
| Где **пишут** тесты и попытки, пока два контура? | Один «канонический» контур на запись; второй read-only или только пилот — иначе разъедутся данные. |
| База | Либо по-прежнему **`clinic_tests`** в новом Flask до ETL, либо сразу **`hr_bot_test`** + `testing_*` (как в кабинете) — одно из двух, не смешивать без миграции. |
| ETL | Скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`: бэкап → `--dry-run` → проверка на копии → короткое окно → `--apply`. |
**Технически:** в репозитории TestingWebApp заведён каталог **`flask_app/`** — минимальное приложение-заготовка; развитие переноса идёт там (или копированием готовых модулей из `HR_TG_Bot/tgFlaskForm`).
--- ---
## 1. Зачем переносить ## 1. Что меняется при слиянии
| Аспект | Сейчас (TestingWebApp) | Цель (tgFlaskForm) | | Аспект | После Этапа 1 (отдельный сервис) | После Этапа 2 (часть HR-кабинета) |
|--------|------------------------|---------------------| |---|---|---|
| Стек | Node.js (Express), React (Vite), отдельный деплой | Python 3, Flask, Jinja/PyPug, статический JS в шаблонах — **единый кабинет** с остальным HR | | Репозиторий | `TestingWebApp/flask_app/` | `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/` |
| База | PostgreSQL, схема `clinic_tests`, UUID-ключи, локальные `users` | Та же инфраструктура Postgres, БД **`hr_bot_test`**, целочисленные `id`, связь с **`staff_members`** | | Деплой | Свой Docker-сервис, свой URL/порт | Часть основного `tgFlaskForm`, общий URL `/cabinet/testing/...` |
| Авторизация | Собственные логин/JWT + опция `HR_AUTH` | Сессии кабинета, RBAC через HR (`testing_head_positions`, флаги HR и т.д.) | | БД | `clinic_tests`, UUID | `hr_bot_test`, integer ID, схема `testing_*` |
| Модуль тестирования | Полный цикл в одном репозитории | В **`tgFlaskForm` уже есть** blueprint `/cabinet/testing`, запросы в `db/queries/testing_queries.py` — задача переноса = **паритет фич + данные + вывод из эксплуатации** старого UI/API | | Авторизация | JWT/bcrypt + опциональный `HR_AUTH` | Сессии общего HR-кабинета, привязка к `staff_members` |
| Модели | Свои (как в Express, но на Python) | Существующие `TestingTest`, `TestingTestVersion`, `TestingQuestion`, `TestingAnswer`, `TestingAssignment`, `TestingAttempt`, `TestingAttemptAnswer`, `TestingSetting`, `TestingHeadPosition` в `db/models.py` |
Итог после **полной** консолидации: один вход для сотрудника, одна БД «истины» по людям, меньше дублирования интеграций с HR. На переходном этапе допустим **отдельный** Flask-инстанс с тем же стеком (см. §0). | UI | Jinja-шаблоны в `flask_app/app/templates/` | Jinja-шаблоны в `tgFlaskForm/webApp/templates/cabinet/testing/` |
--- ---
## 2. Исходный и целевой стек (кратко) ## 2. План Этапа 2 (по спринтам)
**Исходный (TestingWebApp):**
- Backend: `express`, `pg`, миграции SQL в `backend/src/db/migrations/`. ### E2.0 — Сверка кода и моделей
- Frontend: `react`, `react-router-dom`, `vite`.
- Данные: цепочки `tests``test_versions``questions``answer_options`; назначения с `test_assignment_targets` (отдел/пользователь); попытки `test_attempts`, ответы `user_answers` (массив UUID вариантов).
**Целевой (`HR_TG_Bot/tgFlaskForm`) и отдельный контур в этом репозитории (`flask_app/`):** - Сравнить структуру `flask_app/` (после Этапа 1) с уже существующим модулем `tgFlaskForm/webApp/interfaces/testing/`. Где функции называются иначе — выбрать одно имя.
- Сверить модели: `clinic_tests` UUID-схема vs `hr_bot_test` `testing_*` integer-схема. Зафиксировать поле-в-поле соответствия (большая часть уже сделана в [migration-final-inventory.md §10](migration-final-inventory.md#10-gap-analysis-tgflaskformcabinettesting-vs-express)).
- Приложение: `Flask`, точка входа `web_run.py`, фабрика/приложение `webApp/__init__.py`. - **Критерий выхода:** документ соответствий + решение по спорным точкам (например, кто прав — `is_active` на цепочке или на версии).
- Шаблоны: `webApp/templates/cabinet/testing/*.html`, клиентский JS в `templates/static/js/cabinet/testing_*`.
- ORM/запросы: SQLAlchemy-модели `TestingTest`, `TestingTestVersion`, `TestingQuestion`, `TestingAnswer`, `TestingAssignment`, `TestingAttempt`, `TestingAttemptAnswer`, `TestingSetting`, `TestingHeadPosition` в `db/models.py`; бизнес-запросы — `db/queries/testing_queries.py`.
- Сервер: dev `flask run`, prod типично `waitress` (см. `web_run.py`).
- **Отдельный деплой в TestingWebApp:** каталог `flask_app/``run.py`, шаблоны в `flask_app/app/templates/` (см. §0).
---
## 3. Спринтовый план (переписывание = паритет + миграция + снятие стенда) ### E2.1 — Перенос кода как blueprint
Длительность спринта ориентировочно **2 календарные недели**; границы можно сжимать/растягивать под состав команды. Нумерация условная: **Спринт 0** — подготовка, далее функциональные слои. - Скопировать роуты, сервисы, шаблоны из `flask_app/` в `tgFlaskForm`. **Адаптировать**:
- Импорты — на `db/models.py` и `db/queries/testing_queries.py` HR-кабинета.
- Авторизация — сменить с JWT/bcrypt на сессии и `werkzeug.security.check_password_hash`.
- URL-prefix с корневого на `/cabinet/testing/`.
- Шаблоны — наследование от `cabinet/base.html` (хедер, нижний нав-бар).
- **Критерий выхода:** все экраны открываются через HR-кабинет, локальный smoke-тест зелёный.
### Спринт 0 — Инвентаризация и критерии готовности ### E2.2 — Миграция данных (ETL)
**Цель:** зафиксировать разрыв «TestingWebApp ↔ tgFlaskForm» и правила миграции. Скрипт уже готов: [`HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (437 строк, режимы `--dry-run`/`--apply`, идемпотентность через `public._clinic_tests_migration_map`).
- Составить **матрицу сценариев** по [ТЗ.md](ТЗ.md) и [PROJECT_STATUS.md](PROJECT_STATUS.md): редактор теста, версии, назначения, прохождение, разбор, трекер, настройки модуля, AI. Перед прогоном **на актуальных данных** дописать:
- Зафиксировать отличия схемы: UUID vs integer, модель назначений (цель: каждая строка `TestingAssignment` = один `staff_id`).
- Решение по **импорту из PDF/DOCX** (в Node-версии есть извлечение текста для черновика): либо перенос в Python (`tgFlaskForm`), либо явный scope «после миграции».
- **Критерий выхода:** подписанный чек-лист паритета + утверждённый порядок миграции (раздел 4 этого документа).
### Спринт 1 — Данные и идентификаторы - **Перенос `test_assignments` 1:1** — сейчас скрипт переносит только пары «тест-сотрудник» через попытки; нужны и «висящие» назначения без попыток. (Решение Этапа 2.)
- **Логирование пользователей без `staff_id`:** автор → WARN, попытка → WARN; никаких хардовых ошибок. (Решение Этапа 2.)
**Цель:** подготовить перенос без потери смысла связей. **Порядок:**
- Убедиться, что у всех значимых пользователей `clinic_tests.users` есть сопоставление с **`staff_members.id`** (колонка `staff_id` и/или правила сопоставления по логину из HR). 1. Бэкап `clinic_tests` и `hr_bot_test`.
- Спроектировать **таблицы соответствия** для одноразового ETL (например временные таблицы или JSON-маппинги: `old_test_uuid → testing_tests.id`, `old_version_uuid → testing_test_versions.id`, и т.д.). 2. `--dry-run` на копии прод-БД, разбор лога.
- Реализовать **скрипт миграции** — в репозитории HR: [`HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (Python, `psycopg2`, два URL). Режимы: `--dry-run` (только отчёт) и `--apply` (одна транзакция `COMMIT` в `hr_bot_test`). Переменные или флаги: `CLINIC_TESTS_URL`, `HR_BOT_URL`; опция `--skip-missing-staff` пропускает цепочки, у автора нет `users.staff_id`. 3. `--apply` на той же копии, ручная сверка через UI HR-кабинета.
- **Критерий выхода:** dry-run на копии прод-дампа `clinic_tests` + smoke-проверки количества строк (тесты, версии, вопросы, попытки). 4. После приёмки — `--dry-run` + `--apply` на боевой БД.
### Спринт 2 — Паритет бизнес-логики в Flask ### E2.3 — Cutover
**Цель:** закрыть расхождения поведения, а не только UI. Если к этому моменту у TestingWebApp всё ещё «песочница для тестировщиков» (как сейчас) — простое переключение, без окна простоя и баннеров. Если появятся реальные пользователи — добавить пункт E2.3.1: коммуникация и redirect.
- Версионирование: правила «первая правка без попыток / новая версия после попыток», активная версия — согласовать с уже реализованным в `testing_queries.py` и довести до полного соответствия ТЗ при необходимости. - Заморозка записи в `flask_app/` старой инсталляции (read-only).
- Назначения: если в `clinic_tests` остались назначения **на отдел**, описать стратегию **разворачивания** в N строк `TestingAssignment` (по списку `staff_id` отдела на дату миграции) или доработать модель в HR (отдельное решение продукт-оунера). - Прогон ETL на боевом.
- Прохождение: таймер, лимит попыток, дедлайн, случайный порядок вопросов (`question_seed`) — сверка с ТЗ и доработка в Python при расхождении. - Маршрутизация: внешние ссылки `clinic-tests.example.com/*``hr-cabinet.example.com/cabinet/testing/*`.
- **Критерий выхода:** автоматические тесты на критичные запросы (где их ещё нет) + ручной прогон чек-листа из спринта 0. - В корневом репозитории TestingWebApp — ветка `legacy/clinic-tests-flask`, в README — ссылка на этот документ и дату EOL.
### Спринт 3 — UI/UX кабинета и интеграция в меню **Критерий выхода:** мониторинг ошибок (например Sentry, уже подключён в `webApp/__init__.py`), отсутствие P1 в первую неделю.
**Цель:** пользователь не возвращается к старому хосту.
- Пункты меню кабинета, бейджи «назначенные тесты», единый стиль с `cabinet/base.html`.
- Довести страницы: список «мои тесты», редактор, назначение, прохождение, результат/разбор, трекер, настройки — по чек-листу.
- Импорт документов (если включён в scope спринта 0): эндпоинт + UI в шаблоне, ключи API только на сервере (`TestingSetting` / env).
- **Критерий выхода:** UX-приёмка на стенде, совпадающий с ТЗ сценарий для HR / руководителя / сотрудника.
### Спринт 4 — Миграция prod, cutover, архив TestingWebApp
**Цель:** переключить реальных пользователей и зафиксировать артефакты.
- Заморозка записи в TestingWebApp (режим только чтение или техническое окно).
- Прогон ETL на прод-копии → валидация → прогон на боевой БД в согласованное окно.
- Обновление ссылок (внутренние порталы, документация, docker-compose): вместо `:3107` / отдельного сервиса — URL кабинета HR с `/cabinet/testing/...`.
- Репозиторий TestingWebApp: ветка **`legacy/clinic-tests-node`**, в README — ссылка на этот документ и дата end-of-life API/UI.
- **Критерий выхода:** мониторинг ошибок (например Sentry уже в `webApp/__init__.py`), отсутствие P1 по тестам в первую неделю после cutover.
--- ---
## 4. Как происходит миграция данных (пошагово) ## 3. Что трогаем в HR-кабинете до Этапа 2
### 4.1 Предпосылки
1. Доступ к **двум** базам с одной машины (или логическое копирование дампа): `clinic_tests` и `hr_bot_test`. **Ничего.** Существующий модуль `tgFlaskForm/webApp/interfaces/testing/` развивается своим темпом командой HR-кабинета и **не** должен подстраиваться под TestingWebApp до момента слияния. Если в нём появляются полезные правки — переносим в `flask_app/` обратным потоком.
2. Маппинг **пользователь → сотрудник:** для каждой строки `users` в `clinic_tests` должен быть известен **`staff_members.id`**. Если `staff_id` пустой — заранее ручной/полуавтоматический справочник соответствий (логин, email, ФИО).
3. Зафиксированная **версия кода** `tgFlaskForm`, в которой пройдены регрессионные тесты модуля тестирования.
### 4.2 Порядок загрузки сущностей (чтобы не нарушить FK)
Рекомендуемый порядок транзакций/батчей:
1. **`testing_tests`** — из цепочек `tests`: `title`, `description`, `created_by``users.staff_id`, `is_active`, `created_at` (по политике: локальное время vs UTC).
2. **`testing_test_versions`** — из `test_versions`: связь `test_id` через маппинг; `version_number``version`; `passing_score_percent` ← порог из версии/цепочки (в старой схеме часть полей была на `tests` — нормализовать в версию как в SQLAlchemy-модели); `time_limit_minutes`, `allow_back_navigation`, `is_active_version`, флаг единственной активной версии на цепочку.
3. **`testing_questions`** — из `questions`: текст, тип (`single`/`multiple` из `has_multiple_answers`), `sort_order``question_order`.
4. **`testing_answers`** — из `answer_options`: текст, `is_correct`, порядок.
5. **`testing_assignments`** — из `test_assignments` + `test_assignment_targets`:
- для целей типа **пользователь:** одна строка на пару (тест, `staff_id`);
- для целей **отдел:** развернуть в множество строк по сотрудникам отдела на момент миграции (с явным логом «создано из department_id=…»);
- `assigned_by``staff_id` постановщика; `deadline`, `max_attempts`, `assigned_at`.
6. **`testing_attempts`** — из `test_attempts`: связь с новым `assignment_id` (если в старой модели попытка шла от `user_id` без отдельного assignment — потребуется **восстановление** или создание синтетических назначений; зафиксировать правило в спринте 0).
7. **`testing_attempt_answers`** — из `user_answers`: каждый выбранный UUID варианта → строка с новым `answer_id` (через маппинг `answer_options.id``testing_answers.id`).
Везде, где в старой БД использовались **UUID**, скрипт хранит таблицу **`public._clinic_tests_migration_map`** (`entity`, `old_uuid``new_id`) в `hr_bot_test` для идемпотентного повторного прогона.
**Замечание по назначениям:** в текущей версии скрипта строки `clinic_tests.test_assignments` / `test_assignment_targets` **не** переносятся пакетно; для каждой пары (тест HR, сотрудник) при переносе **попыток** создаётся или находится строка `testing_assignments` (синтетическое назначение, `max_attempts = 99`). Полный импорт истории назначений из clinic — отдельная доработка при необходимости.
### 4.3 Валидация после ETL
- Сравнение **агрегатов:** число тестов, версий, вопросов, назначений, завершённых попыток, строк ответов.
- Выборочная сверка: 5–10 последних попыток — ручной разбор «вопрос / выбранные варианты / балл» в старом и новом UI.
- Проверка уникальности «одна активная версия на тест» и отсутствия «висячих» FK.
### 4.4 Cutover (переключение)
1. Объявить **окно**: остановка записи в TestingWebApp.
2. Инкрементальный дамп изменений с последней реплики (если делали пробный перенос ранее) или финальный полный перенос.
3. Прогон ETL в транзакции (или по крупным батчам с чекпоинтами) → `VACUUM ANALYZE` при необходимости.
4. Включить пользователям ссылку на **кабинет**; проверить права `can_create_tests` / HR.
5. Сохранить **бэкап** `clinic_tests` и лог миграции минимум на срок, определённый политикой клиники (типично 30–90 дней).
### 4.5 Откат
- Если после cutover обнаружен блокирующий дефект: вернуть пользователей на временный старый стенд **только для чтения** при наличии бэкапа; новые данные в `hr_bot_test` после cutover при откате не синхронизируются автоматически — риск фиксируется заранее (короткое окно, «freeze» повторных действий).
--- ---
## 5. Риски и как их снимать ## 4. Риски Этапа 2 и как их снимать
| Риск | Мера | | Риск | Мера |
|------|------| |------|------|
| Неполное сопоставление `users``staff_members` | Закрыть в спринте 1; не начинать ETL без процента покрытия, согласованного с заказчиком | | Несовпадение `users.staff_id``staff_members.id` | Проверка перед `--apply`; пользователей без `staff_id` пропускаем по решению. |
| Разная семантика назначений (отдел, версия) | Явные правила в спринте 0 + лог развёртки отделов | | Расхождение моделей (UUID vs integer, поля «на цепочке» vs «на версии») | Закрыть в E2.0; подкрепить unit-тестами на конвертацию. |
| Потеря истории попыток из-за смены модели assignment | Моделирование на копии БД в спринте 1–2 | | Назначения «отдел → N сотрудников» | Логировать развёртку с пометкой `created_from_department=...`. |
| Дублирование разработки UI | Опираться на уже существующий модуль в `tgFlaskForm`, не переписывать с нуля параллельный SPA | | Двойное развитие модуля HR-кабинета | До Этапа 2 — не править `tgFlaskForm/cabinet/testing` под нужды TestingWebApp. |
--- ---
## 6. Итог ## 5. Производительность кабинета (общее)
Переписывание в данном контексте — это не «ещё один greenfield на Flask», а **консолидация** уже начатого модуля в `tgFlaskForm` с **одноразовой миграцией** из `clinic_tests` и выводом из эксплуатации связки React + Express. Спринты 0–4 дают сквозной маршрут от анализа до cutover; детали ETL должны быть закреплены в коде скрипта и журнале прогона к концу **спринта 1**.
**См. также:** если пользователи жалуются на медленную загрузку страниц кабинета/Flask — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md). Если после переноса страницы кабинета или мини-приложения работают медленно — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md). Это вспомогательный документ, не часть этапов миграции.

158
docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md

@ -0,0 +1,158 @@
# Предложение по редизайну страницы «Создание теста»
## Актуализация (кабинет `TestingWebApp`, 2026)
**Фактическая реализация** кабинета (React) — **не** отдельный `TestForm` из текста ниже, а страница **`frontend/src/pages/TestDetail.jsx`** (редактирование существующего теста) и глобальные стили **`frontend/src/styles/cabinet-theme.css`**.
| Блок в UI (как в интерфейсе) | Содержимое |
|------------------------------|------------|
| **О тесте** | Название, описание, порог зачёта |
| **Вопросы** | Панель ИИ «сетка», вопросы/варианты, **«Документ в вопросы»** (импорт) внизу секции |
| Панель в потоке + **фикс-футер** `≤640px` | «Сохранить черновик», «К списку» |
| **История** | Подзаголовки **Версии** / **Прохождения** |
| **Показ в каталоге** | **Видимость**; при включённом назначении — **Кому выдать** (поиск, «Выбрать всех», список) |
Сводка мобильных доработок и чек-листы: [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). Памятка для пользователей без кода: [РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md).
**Ниже** — **исторический** вариант документа (Ant Design, `TestForm` / `TestCreate` из другой ветки/референса). Имеет смысл читать как **идеи** по группировке, пока **не** как текущий путь к файлам.
---
**Ветка (ист.):** `dev-new-design-page-createtest`
**Затронутые файлы (ист.):** `frontend/src/components/TestForm/index.tsx`, `frontend/src/pages/TestCreate/index.tsx` (через общий компонент), частично `frontend/src/api/llm.ts` (расширение сигнатуры `generate`).
**Бэкенд:** менять не обязательно — у нас уже есть `POST /api/llm/generate` (см. `backend/app/api/llm.py`); нужно лишь принять параметры `questions_count` и `answers_count`.
---
## 1. Цель
Сделать форму создания теста читаемее: разбить «полотно» на три смысловые группы и чётко отделить редактируемое содержимое от служебных команд. Заодно — дать пользователю возможность сгенерировать тест сразу нужной формы, не нащёлкивая «+ вопрос» / «+ вариант» вручную.
## 2. Текущее состояние (что есть)
`TestForm/index.tsx` сейчас визуально устроен так:
```
┌─────────────────────────────────────────┐
│ ← Назад Заголовок │
├─────────────────────────────────────────┤
│ Card «Основные настройки» │
│ • название │
│ • описание │
│ • порог зачёта │
│ • таймер │
│ • разрешить возврат │
├─────────────────────────────────────────┤
│ [Сгенерировать с AI] [Проверить тест] │ ← вне карточек, между мета и вопросами
├─────────────────────────────────────────┤
│ Card «Вопрос 1» │
│ ... │
│ Card «Вопрос N» │
│ [+ Добавить вопрос] │
├─────────────────────────────────────────┤
│ [Создать тест] [Отмена] │
└─────────────────────────────────────────┘
```
Замечания:
- AI-кнопки висят «в воздухе» — не видно, к какой части формы они относятся.
- «Сгенерировать с AI» жёстко создаёт **7 вопросов** (см. `TestForm/index.tsx:123``llmApi.generate(title.trim(), 7)`), без выбора структуры.
- В fallback-сообщении ошибки упоминается «API ключ в настройках» (`TestForm/index.tsx:244`).
## 3. Что меняем
### 3.1. Три смысловых блока
| Блок | Содержит | Визуально |
|------|----------|-----------|
| **Метаинформация** | название, описание, порог, таймер, навигация назад | `Card title="Метаинформация"` |
| **Содержание** | кнопка «Сгенерировать с AI», список вопросов, «+ Добавить вопрос» | `Card title="Содержание"` (вложенные карточки вопросов остаются) |
| **Команды** | «Создать тест», «Отмена», «Проверить тест» | блок `Space` снизу страницы |
Кнопка **«Проверить тест»** логически — это команда над всем тестом, поэтому переезжает в нижнюю панель команд. Кнопка **«Сгенерировать с AI»** становится первым элементом блока «Содержание» — у неё там естественное место (она формирует именно содержание).
### 3.2. Wireframe после редизайна
```
┌─────────────────────────────────────────┐
│ ← Назад Создание теста │
├─────────────────────────────────────────┤
│ Card «Метаинформация» │
│ • название │
│ • описание │
│ • порог зачёта │
│ • таймер │
│ • разрешить возврат │
├─────────────────────────────────────────┤
│ Card «Содержание» │
│ ┌─ AI-генерация ────────────────────┐ │
│ │ тема: [_________________] │ │
│ │ вопросов: [7] вариантов: [3] │ │
│ │ [🤖 Сгенерировать] │ │
│ └──────────────────────────────────┘ │
│ │
│ Card «Вопрос 1» ... │
│ Card «Вопрос N» ... │
│ [+ Добавить вопрос] │
├─────────────────────────────────────────┤
│ [Создать тест] [Проверить тест] [Отмена] │
└─────────────────────────────────────────┘
```
### 3.3. Форма AI-генерации с тремя полями
Внутри блока «Содержание» — компактный мини-блок (не модалка) с тремя полями:
| Поле | Тип | По умолчанию | Лимиты |
|------|-----|--------------|--------|
| Тема | `Input` | значение `title` из метаинформации (auto-fill) | непустая |
| Количество вопросов | `InputNumber` | 7 | 1…30 |
| Количество вариантов на вопрос | `InputNumber` | 3 | 2…8 |
Кнопка **«Сгенерировать»** запускает текущий поток `handleGenerate` (модалка-превью → «Применить все вопросы»), но передаёт оба числа в API. Превью показывает то же, что и сейчас.
Поведение существующих кнопок «Улучшить» и «Дистракторы» внутри карточки вопроса **не меняется** — они и так локальные.
### 3.4. Уход от текста про API-ключи
Единственное место в форме, где упоминается ключ, — fallback-сообщение об ошибке:
```ts
// TestForm/index.tsx:244
setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.')
```
Заменяем на нейтральное:
```ts
setReviewText('Не удалось получить рекомендации. Попробуйте позже или обратитесь к администратору.')
```
Сама страница `Settings` остаётся как есть — это её прямое назначение, и её редактируют только администраторы.
## 4. План работ (чек-лист для исполнителя)
- [ ] **Backend** (опционально, если ещё не принимает параметры): расширить схему `LLMGenerateRequest` в `backend/app/schemas/llm.py` полями `questions_count: int = 7`, `answers_count: int = 3`; передать их в промпт сервиса `app/services/llm.py`.
- [ ] **API-клиент**: в `frontend/src/api/llm.ts` обновить сигнатуру `generate(topic, questionsCount, answersCount)`.
- [ ] **TestForm**: обернуть текущий блок «Основные настройки» в `Card title="Метаинформация"`.
- [ ] **TestForm**: создать новый `Card title="Содержание"`, в него поместить:
- мини-блок AI-генерации (3 поля + кнопка),
- текущий `Form.List` вопросов с кнопкой «+ Добавить вопрос».
- [ ] **TestForm**: убрать текущий ряд из двух AI-кнопок между мета и вопросами; «Проверить тест» перенести в нижнюю панель команд рядом с «Создать»/«Отмена».
- [ ] **TestForm**: добавить локальный стейт `aiQuestionsCount`, `aiAnswersCount`, инициализация: 7 / 3.
- [ ] **TestForm**: в `handleGenerate` передавать оба числа; при `Применить` создавать соответствующее число пустых ответов в каждом вопросе (если бэк прислал меньше — добить пустыми, если больше — обрезать).
- [ ] **TestForm**: заменить fallback-текст про API-ключ.
- [ ] Прогнать `docker compose up`, проверить вручную: создание с дефолтами, генерация на 5 вопросов × 4 ответа, проверка теста, отмена.
- [ ] Документ-шаг в `DOC/ШАГИ/ШАГ_<дата>_<NNN>.md` по факту выполнения.
## 5. Что **не** делаем в этой ветке
- Не трогаем форму редактирования (`TestEdit`) — формально использует тот же `TestForm`, но эффект редизайна нужно отдельно проверить с уже заполненными вопросами и опциями версионирования.
- Не меняем поведение `Form.List` валидации (минимум 7 вопросов / 3 варианта) — это отдельная история.
- Не вводим drag-and-drop переупорядочивание вопросов.
## 6. Открытые вопросы для согласования
1. Нужно ли визуально подсвечивать мини-блок генерации (фон, бордер), чтобы он отличался от карточек вопросов?
2. После применения сгенерированных вопросов — нужно ли скрывать мини-блок, чтобы он не путал?
3. Лимиты «1…30 вопросов / 2…8 вариантов» — устраивают или нужны другие?

56
docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md

@ -0,0 +1,56 @@
# Кабинет тестов: коротко, как пользоваться
*Для врачей, заведующих, кураторов — без IT-терминов.
Иллюстрации: [images/cabinet-ui/](images/cabinet-ui/) (схемы-заглушки, можно заменить на скриншоты, см. `README` в той папке).*
---
### 1. Список тестов
Все тесты, к которым у вас есть доступ. **Название** (слева) ведёт в **редактирование** или просмотр, **«Пройти»** (справа) — **сдать** тест, если вам тест **назначили** или открыт самопроход. Редактирование — не у всех ролей.
![Схема: список](images/cabinet-ui/placeholder-spisok.svg)
---
### 2. О тесте
Название, **описание** для коллег, **порог зачёта** (%). «Паспорт» теста: **что проверяете** и **с какой планкой** зачёт/незачёт.
---
### 3. Вопросы
**Вопросы и варианты** пишите здесь. Слева от варианта — **верные** отметки: один вариант как контрольный; несколько верных — чекбокс **«Несколько верных ответов»**.
- **+ вопрос** / **+ вариант** — добавить. **Крестик** у варианта — убрать лишний ответ.
- **Документ в вопросы** — при необходимости загрузить файл (PDF, Word, текст) и вставить в черновик; не обязательно, если ввели всё вручную.
- **ИИ** (если включён) — подсказка, не готовый клинический документ: **проверьте и исправьте** перед публикацией.
---
### 4. Сохранить
**«Сохранить черновик»** (часто **внизу** на телефоне) — чтобы не потерять правки. **«К списку»** — выход; если **уже сохранялись** — данные в черновике записаны.
![Схема: кнопки внизу](images/cabinet-ui/placeholder-chernovik.svg)
---
### 5. История
- **Версии** — когда и как менялся тест. Актуальная отмечена. **«Сделать активной»** — редко, обычно согласуя с IT/методистом.
- **Прохождения** — кто уже **сдавал**; **«Разбор»** — ответы по вопросам (если вам **открыт** доступ).
---
### 6. Показ в каталоге
- **Видимость** — показать в **общем** списке тестов или **скрыть** (тест **не** удаляется, просто **не** светится в ленте). «Старые» **ссылки** у кого-то **могут** ещё открываться, если **переадресовали** вручную.
- **Кому выдать** (если раздел есть) — **назначение** сотрудникам: **поиск, фильтры, галочки**; **«Выбрать всех»** — только в **текущем** отфильтрованном списке; затем **«Назначить выбранных»**. Это **про людей**, не про редактуру вопросов.
![Схема: видимость и назначение](images/cabinet-ui/placeholder-pokaz.svg)
---
**В одном движении:** написали **вопросы****«Сохранить»** → при **необходимости** **показали** в списке и/или **кому-то** **выдали** тест. Остальное — **по ситуации**.

67
docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md

@ -0,0 +1,67 @@
# Спринты: мобильный UI кабинета тестов
---
## Спринт 1 — быстрые исправления
**Цель:** выровнять кнопки, мета-строку списка, историю версий, назначение и safe-area; без смены контентной модели страниц.
- [x] **1.1** Панель «Сохранить черновик / К списку»: убрать конфликт `inline-actions .btn { width: auto }` с `btn-primary` — колонка на всю ширину (`.actions-bar`)
- [x] **1.2** Touch: `min-height` у `.btn--sm` (убрать, удалить вопрос, сделать активной…)
- [x] **1.3** Список тестов: не разбивать «· v1» — хвост в `list-row__meta-tail` + `white-space: nowrap`
- [x] **1.4** «История версий»: вместо `<table>` — карточки (`surface-card` + flex)
- [x] **1.5** «Назначение»: не рендерить пустой `.assign-list` (убрать «коробку» без людей)
- [x] **1.6** Сильнее рамка `.btn-ghost` (согласование с полями)
- [x] **1.7** `padding-bottom` у `.cabinet-main` + `env(safe-area-inset-bottom)`
- [x] **1.8** «Публикация»: на узком экране — кнопка на всю ширину (`.inline-actions--block-mobile`)
**Файлы:** `frontend/src/styles/cabinet-theme.css`, `frontend/src/pages/TestDetail.jsx`, `frontend/src/pages/TestsList.jsx`.
---
## Спринт 2 — карточки, импорт, вопрос, радио/чек, фикс-футер
- [x] **2.1** «Прогоны и разбор»: таблица заменена на список карточек (`.attempts-card-list`)
- [x] **2.2** «Импорт из файла»: скрытый `input` + `label` с `.btn` (`.import-file-input` / `.import-file-label`)
- [x] **2.3** «Вопрос N» + «Сгенерировать вопрос (ИИ)»: колонка на мобилке, ряд от `min-width: 520px` (`.question-editor-block__header`)
- [x] **2.4** Варианты: `type="radio"` при одном верном, `checkbox` при нескольких
- [x] **2.5** Моб. фикс-футер `≤640px` с «Сохранить» / «К списку» + статус черновика; панель в потоке скрыта
**Файлы:** `frontend/src/styles/cabinet-theme.css`, `frontend/src/pages/TestDetail.jsx`.
---
## Спринт 3 — структура «карточки теста», копи, доводка списка и кабинета
**Цель:** человекочитаемые названия и группировка разделов; согласованные подсказки и тап-зоны; доводка списка тестов, истории, назначения и редактора по замечаниям.
### Страница теста (`TestDetail`)
- [x] **3.1** `AccSection`: подзаголовок под заголовком (`subtitle` + стили `cabinet-disclosure__summary-*`)
- [x] **3.2** У «тела» аккордеона — верхний внутренний отступ: `.cabinet-disclosure__body` (контент не «прилипает» к header)
- [x] **3.3** Переименования: «О тесте», «Вопросы» (вместо мета/содержания); подзаголовки-описания по смыслу
- [x] **3.4** **Импорт** встроен в «Вопросы»: подсекция «Документ в вопросы»; отдельного аккордеона «Импорт из файла» нет
- [x] **3.5** **История**: одна секция, подпункты «Версии» + «Прохождения»; пустое/ошибочное состояние у прогонов
- [x] **3.6** Ошибка загрузки прогонов: не в герое страницы, а внутри «Прохождения»
- [x] **3.7** **Показ в каталоге:** подсекция «Видимость» + (при `assignmentUi`) «Кому выдать»; кнопка видимости **не** на всю ширину (`.publication-visibility__actions`)
- [x] **3.8** Панель ИИ-генерации: `.test-detail-ai-panel` (фон/рамка в духе кабинета, без «лишней» карточки)
- [x] **3.9** Вопрос: блоки `.question-editor-block` + первый вопрос без лишнего верхнего бордера (`.question-editor-block--first`)
- [x] **3.10** Варианты: удаление — **иконка** «закрыть» + `aria-label` (вместо текста «убрать»)
- [x] **3.11** Одна панель `.question-editor__footer`: «+ вариант» и «Удалить вопрос»; «+ вопрос» вынесен в `.test-detail-add-question` над блоком импорта
- [x] **3.12** **Назначение:** кнопка **«Выбрать всех (N)»** по текущему отфильтрованному списку; подсказки в подсекциях
- [x] **3.13** Стили подсекций: `.test-detail-subsection`, `.test-detail-subsection__title`, `.test-detail-hint`
- [x] **3.14** Импорт: на мобилке **полная ширина** кнопки «Выбрать файл» (`.import-file-row--block`); ровнее отступы у превью
- [x] **3.15** Классы списка версий: на узком экране у `.version-card-list__main` `flex-grow: 0` в column — **без пустой «вытянутой» карточки v1**
### Список тестов и шапка
- [x] **3.16** **Список:** на `≤640px` карточка в **колонку** (заголовок на ширину экрана, **«Пройти»** снизу на **всю ширину**; без пустой полосы)
- [x] **3.17** `hover` у строк списка — только при `@media (hover: hover) and (pointer: fine)`; лёгкий **`:active`** на ссылке (тач-фидбек)
- [x] **3.18** **Шапка** `cabinet-header__inner`: учёт **safe-area** сверху и по бокам
- [x] **3.19** **Назначение (кабинет):** у `.assign-list` — выше область прокрутки на мобилке; у строк — линия раздела чуть заметнее; **центр чекбокса** + **line-clamp** у мета; `accent-color` у чекбокса
- [x] **3.20** Ритм **аккордеонов** на `test-detail-page` — чуть больше `margin` между `cabinet-brick`; **нижний отступ** у страницы под **фикс-футер** увеличен
- [x] **3.21** **Поле поиска** в назначении: более «строчный» вид (min/max `height` + `padding` у `.assign-toolbar__search`)
**Файлы:** `frontend/src/styles/cabinet-theme.css`, `frontend/src/pages/TestDetail.jsx`, `frontend/src/pages/TestsList.jsx` (только косвенно через стили, разметка списка ранее).
**Памятка для пользователей (не-разработчиков), иллюстрации:** [РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) · [images/cabinet-ui/](images/cabinet-ui/README.md)

5
docs/шаги/05-test-management.md

@ -49,6 +49,11 @@
*Фактические маршруты API в `TestingWebApp`: префикс `/api/tests`, версии и черновик — см. [../PROJECT_STATUS.md](../PROJECT_STATUS.md).* *Фактические маршруты API в `TestingWebApp`: префикс `/api/tests`, версии и черновик — см. [../PROJECT_STATUS.md](../PROJECT_STATUS.md).*
### 5.6. UI кабинета (React, `TestDetail`)
- Редактор теста: **`frontend/src/pages/TestDetail.jsx`**, стили **`frontend/src/styles/cabinet-theme.css`**: аккордеоны **«О тесте»**, **«Вопросы»** (в т.ч. **«Документ в вопросы»**), **«История»** (внутри: **Версии** + **Прохождения**), **«Показ в каталоге»** (**Видимость**; при `assignmentUi`**Кому выдать**), фикс-футер **«Сохранить / К списку»** на моб. ширинах.
- Сводка мобильного UI и чек-лист: [../СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](../СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
## Результат ## Результат
- Полноценный конструктор тестов - Полноценный конструктор тестов

3
docs/шаги/README.md

@ -3,6 +3,7 @@
Файлы **01**–**11** — **проектные шаги** (целевое поведение и API), а не автоматическая копия кода. Фактическое состояние фич, сценарии «как у пользователя» и ветка **`dev`** описаны в: Файлы **01**–**11** — **проектные шаги** (целевое поведение и API), а не автоматическая копия кода. Фактическое состояние фич, сценарии «как у пользователя» и ветка **`dev`** описаны в:
- [../PROJECT_STATUS.md](../PROJECT_STATUS.md) — что сделано и что в планах; - [../PROJECT_STATUS.md](../PROJECT_STATUS.md) — что сделано и что в планах;
- [../DEV_CONTOUR_USER_GUIDE.md](../DEV_CONTOUR_USER_GUIDE.md) — инструкция для проверки на dev-стенде. - [../DEV_CONTOUR_USER_GUIDE.md](../DEV_CONTOUR_USER_GUIDE.md) — инструкция для проверки на dev-стенде;
- [../РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md](../РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) — тезисы для врачей/кураторов; [../СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](../СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) — чек-лист моб. UI.
Журнал приёмки: [../revision_task/TESTING_JOURNAL.md](../revision_task/TESTING_JOURNAL.md). Журнал приёмки: [../revision_task/TESTING_JOURNAL.md](../revision_task/TESTING_JOURNAL.md).

24
flask_app/.env.example

@ -1,7 +1,25 @@
# Порт HTTP (не пересекать с :3107 текущего compose) # ─── HTTP сервер ─────────────────────────────────────────────────
# Порт (не пересекать с :3107 текущего docker-compose.dev.yml)
PORT=3108 PORT=3108
FLASK_DEBUG=1 FLASK_DEBUG=1
SECRET_KEY=change-me-in-dev-only
# В Docker задайте WEB_USE_WAITRESS=1 (см. docker-compose.dev.yml) # В Docker задайте WEB_USE_WAITRESS=1 (см. docker-compose.dev.yml)
# WEB_USE_WAITRESS=1 # WEB_USE_WAITRESS=1
# Секрет для подписи cookies / Flask sessions.
# В dev можно оставить заглушку — будет сгенерирован случайный, но при рестарте
# процесса сессии "обнулятся" (это критично для логина/CSRF).
SECRET_KEY=change-me-in-dev-only
# ─── База данных (clinic_tests, та же, что у Express-бэкенда) ────
# Этап 1: продолжаем работать с clinic_tests, схему не меняем.
# Локально:
# DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests
# В Docker рядом с HR (общая сеть Postgres_TG_Bots):
# DATABASE_URL=postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests
DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests
# ─── Опциональная HR-аутентификация (как в Express-бэкенде) ──────
# Если HR_AUTH=1, используем БД hr_bot_test для проверки логина/пароля
# (Werkzeug-хеш в public.users.password по web_login = username).
# HR_AUTH=1
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test

15
flask_app/README.md

@ -44,11 +44,18 @@ python run.py
## Дальнейшие шаги (код) ## Дальнейшие шаги (код)
1. Подключить БД (`clinic_tests` **или** `hr_bot_test` + `testing_*` — одно из двух, см. [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md) §0). Этот каталог — место разработки **Этапа 1** ([migration-final.md](../docs/migration-final.md)).
2. Переносить маршруты и шаблоны по образцу `tgFlaskForm` (blueprint `testing`, `db/queries/testing_queries.py`, шаблоны `cabinet/testing/`).
3. ETL при переходе на HR-БД: `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`. 1. Подключить БД **`clinic_tests`** (схема не меняется), psycopg2-пул в стиле `tgFlaskForm/db/`.
2. Перенести 22 эндпоинта Express из `backend/` в blueprint'ы Flask, ориентируясь на чек-лист в [migration-final-inventory.md](../docs/migration-final-inventory.md).
3. Перенести экраны React (`frontend/src/pages/*`) в Jinja-шаблоны `app/templates/`, повторяя мобильный UX [Спринта 3](../docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
4. Когда паритет закрыт — `docker-compose.dev.yml` указывает на этот сервис как основной, `backend/` и `frontend/` уходят.
ETL `clinic_tests → hr_bot_test` и слияние с `tgFlaskForm` — это **Этап 2**, на будущее ([migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md)).
## Связанные документы ## Связанные документы
- [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md) - [docs/migration-final.md](../docs/migration-final.md) — главный трекер двух этапов.
- [docs/migration-final-inventory.md](../docs/migration-final-inventory.md) — карта Express-функционала, чек-лист Этапа 1.
- [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md) — план Этапа 2.
- [README корня репозитория](../README.md) - [README корня репозитория](../README.md)

64
flask_app/app/__init__.py

@ -1,8 +1,16 @@
# -*- coding: utf-8 -*- """Фабрика Flask-приложения.
Этап 1:
E1.0 фундамент (БД-пул, sessions, base.html).
E1.1 авторизация (cookie-сессии Flask, bcrypt + Werkzeug, опц. HR_AUTH).
"""
from __future__ import annotations
import os import os
import secrets import secrets
from datetime import timedelta
from flask import Flask, jsonify from flask import Flask, jsonify, render_template, request
def create_app() -> Flask: def create_app() -> Flask:
@ -13,17 +21,55 @@ def create_app() -> Flask:
static_folder='static', static_folder='static',
static_url_path='/static', static_url_path='/static',
) )
sk = (os.environ.get('SECRET_KEY') or '').strip() sk = (os.environ.get('SECRET_KEY') or '').strip()
app.config['SECRET_KEY'] = sk or secrets.token_hex(32) app.config['SECRET_KEY'] = sk or secrets.token_hex(32)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB upload limit
app.config.update(
SESSION_COOKIE_NAME='testing_session',
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax',
SESSION_COOKIE_SECURE=(os.environ.get('FLASK_ENV') == 'production'),
PERMANENT_SESSION_LIFETIME=timedelta(days=7),
)
@app.route('/health') from .blueprints.main import main_bp
def health(): from .blueprints.settings import settings_bp
return jsonify(status='ok', service='testing-flask-app') from .auth import auth_bp
from .tests import tests_bp
@app.route('/') app.register_blueprint(main_bp)
def index(): app.register_blueprint(auth_bp)
from flask import render_template app.register_blueprint(tests_bp)
app.register_blueprint(settings_bp)
return render_template('index.html') from .config import is_assignment_feature_enabled, is_dev_ui, is_hr_auth_enabled
from .auth.decorators import current_user as _current_user
@app.context_processor
def _inject_globals():
return {
'current_user': _current_user(),
'hr_auth_enabled': is_hr_auth_enabled(),
'dev_ui': is_dev_ui(),
'assignment_ui': is_assignment_feature_enabled(),
}
@app.errorhandler(404)
def _not_found(_e):
if _is_api_path():
return jsonify(error='not_found'), 404
return render_template('404.html'), 404
@app.errorhandler(500)
def _internal_error(_e):
if _is_api_path():
return jsonify(error='internal_error'), 500
return render_template('500.html'), 500
return app return app
def _is_api_path() -> bool:
return request.path.startswith('/api/')

5
flask_app/app/auth/__init__.py

@ -0,0 +1,5 @@
"""Auth: логин/логаут/me — пара UI-страниц и JSON-API.
См. routes.py, services.py, decorators.py.
"""
from .routes import auth_bp # noqa: F401

69
flask_app/app/auth/decorators.py

@ -0,0 +1,69 @@
"""Декораторы доступа: подгружают пользователя из сессии.
`g.current_user` доступен в шаблонах как `current_user` (см. context_processor в `app/__init__.py`).
"""
from __future__ import annotations
from functools import wraps
from typing import Iterable
from flask import g, jsonify, redirect, request, session, url_for
from ..messages import RU
from .services import AuthUser, load_user_by_id
def _wants_json() -> bool:
if request.path.startswith('/api/'):
return True
accept = request.headers.get('Accept', '')
return 'application/json' in accept and 'text/html' not in accept
def _load_current_user() -> AuthUser | None:
if hasattr(g, 'current_user'):
return g.current_user
user_id = session.get('user_id')
user = load_user_by_id(user_id) if user_id else None
g.current_user = user
return user
def login_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
user = _load_current_user()
if user is None:
if _wants_json():
return jsonify(error=RU['authRequired']), 401
return redirect(url_for('auth.login_page', next=request.full_path or '/'))
return fn(*args, **kwargs)
return wrapper
def require_role(roles: str | Iterable[str]):
allowed = {roles} if isinstance(roles, str) else set(roles)
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
user = _load_current_user()
if user is None:
if _wants_json():
return jsonify(error=RU['authRequired']), 401
return redirect(url_for('auth.login_page', next=request.full_path or '/'))
if user.role not in allowed:
if _wants_json():
return jsonify(error=RU['insufficientPermissions']), 403
return ('Доступ запрещён.', 403)
return fn(*args, **kwargs)
return wrapper
return decorator
def current_user() -> AuthUser | None:
"""Хелпер для шаблонов и view-функций."""
return _load_current_user()

13
flask_app/app/auth/hr_role.py

@ -0,0 +1,13 @@
"""Маппинг роли HR → роль модуля тестов (порт `backend/src/utils/hrRoleMap.js`)."""
from __future__ import annotations
def map_hr_role_to_app(hr_role: str | None) -> str:
r = (hr_role or '').strip().lower()
if not r:
return 'employee'
if r == 'admin' or 'hr' in r or 'дире' in r:
return 'hr'
if 'manager' in r or 'рук' in r or 'завед' in r:
return 'manager'
return 'employee'

107
flask_app/app/auth/routes.py

@ -0,0 +1,107 @@
"""Маршруты auth: HTML (`/login`, `/logout`) и JSON (`/api/auth/*`)."""
from __future__ import annotations
import logging
from flask import (
Blueprint,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
from ..config import is_assignment_feature_enabled, is_dev_ui
from ..messages import RU
from .decorators import login_required, current_user
from .services import AuthError, authenticate_credentials
log = logging.getLogger(__name__)
auth_bp = Blueprint('auth', __name__)
def _safe_next(default: str = '/') -> str:
"""Защита от open-redirect: разрешаем только относительные пути."""
nxt = request.values.get('next') or default
if not nxt.startswith('/') or nxt.startswith('//'):
return default
return nxt
def _do_login(login: str, password: str):
user = authenticate_credentials(login, password)
session.clear()
session['user_id'] = user.id
session.permanent = True
return user
# ─── HTML ────────────────────────────────────────────────────────────
@auth_bp.route('/login', methods=['GET'])
def login_page():
if current_user() is not None:
return redirect(_safe_next('/'))
return render_template('auth/login.html', next=_safe_next('/'))
@auth_bp.route('/login', methods=['POST'])
def login_submit():
login = (request.form.get('login') or '').strip()
password = request.form.get('password') or ''
try:
_do_login(login, password)
except AuthError as e:
flash(e.message, 'error')
return render_template('auth/login.html', next=_safe_next('/'), login=login), e.status
except Exception:
log.exception('login_submit failed')
flash(RU['loginFailed'], 'error')
return render_template('auth/login.html', next=_safe_next('/'), login=login), 500
return redirect(_safe_next('/'))
@auth_bp.route('/logout', methods=['POST', 'GET'])
def logout():
session.clear()
if request.method == 'GET':
return redirect(url_for('auth.login_page'))
return redirect(url_for('auth.login_page'))
# ─── JSON API ────────────────────────────────────────────────────────
@auth_bp.route('/api/auth/login', methods=['POST'])
def api_login():
data = request.get_json(silent=True) or {}
login = (data.get('login') or '').strip()
password = data.get('password') or ''
try:
user = _do_login(login, password)
except AuthError as e:
return jsonify(error=e.message), e.status
except Exception:
log.exception('api_login failed')
return jsonify(error=RU['loginFailed']), 500
return jsonify(user=user.to_public_dict())
@auth_bp.route('/api/auth/logout', methods=['POST'])
def api_logout():
session.clear()
return jsonify(message=RU['loggedOut'])
@auth_bp.route('/api/auth/me', methods=['GET'])
@login_required
def api_me():
user = current_user()
return jsonify(
user=user.to_public_dict() if user else None,
devUi=is_dev_ui(),
assignmentUi=is_assignment_feature_enabled(),
)

217
flask_app/app/auth/services.py

@ -0,0 +1,217 @@
"""Бизнес-логика логина — порт `backend/src/routes/auth.js` + `utils/auth.js`.
Поддерживает оба режима:
- Локальный (по умолчанию): bcrypt в `clinic_tests.users.password_hash`.
- HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (Werkzeug scrypt/pbkdf2),
затем находим запись `staff_members` по `web_login` и UPSERT-им в
`clinic_tests.users` по `staff_id`.
Werkzeug-хеши проверяются через `werkzeug.security.check_password_hash`,
bcrypt-хеши через пакет `bcrypt`.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import bcrypt
from sqlalchemy import text
from werkzeug.security import check_password_hash as _werkzeug_check
from ..config import (
HR_MANAGED_PASSWORD_PLACEHOLDER,
is_hr_auth_enabled,
)
from ..db import get_engine, get_hr_engine
from ..messages import RU
from .hr_role import map_hr_role_to_app
@dataclass
class AuthUser:
id: str # UUID в виде строки
login: str
full_name: str | None
role: str
department_id: Optional[str]
staff_id: Optional[int]
def to_public_dict(self) -> dict:
out = {
'id': str(self.id),
'login': self.login,
'fullName': self.full_name,
'role': self.role,
'departmentId': self.department_id,
}
out['staffId'] = self.staff_id
return out
class AuthError(Exception):
"""Ошибка авторизации с HTTP-кодом и сообщением для пользователя."""
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
def _verify_password(plain: str, hashed: str | None) -> bool:
"""Универсальная проверка пароля: bcrypt + Werkzeug (scrypt/pbkdf2)."""
if not hashed:
return False
if hashed == HR_MANAGED_PASSWORD_PLACEHOLDER:
return False
if hashed.startswith('scrypt:') or hashed.startswith('pbkdf2:'):
try:
return _werkzeug_check(hashed, plain)
except Exception:
return False
if hashed.startswith('$2'):
try:
return bcrypt.checkpw(plain.encode('utf-8'), hashed.encode('utf-8'))
except Exception:
return False
try:
return _werkzeug_check(hashed, plain)
except Exception:
return False
def authenticate_credentials(login: str, password: str) -> AuthUser:
"""Главная точка входа. Возвращает AuthUser или поднимает AuthError."""
login = (login or '').strip()
password = password or ''
if not login or not password:
raise AuthError(400, RU['loginAndPasswordRequired'])
if is_hr_auth_enabled():
return _authenticate_via_hr(login, password)
return _authenticate_local(login, password)
# ─── локальный режим ────────────────────────────────────────────────
def _authenticate_local(login: str, password: str) -> AuthUser:
eng = get_engine()
with eng.connect() as conn:
row = conn.execute(
text(
'SELECT id, login, password_hash, full_name, role, department_id, staff_id '
'FROM users WHERE login = :login AND is_active = true'
),
{'login': login},
).mappings().first()
if not row:
raise AuthError(401, RU['invalidCredentials'])
if row['password_hash'] == HR_MANAGED_PASSWORD_PLACEHOLDER:
raise AuthError(401, RU['useHrLogin'])
if not _verify_password(password, row['password_hash']):
raise AuthError(401, RU['invalidCredentials'])
return AuthUser(
id=str(row['id']),
login=row['login'],
full_name=row['full_name'],
role=row['role'],
department_id=row['department_id'],
staff_id=row['staff_id'],
)
# ─── HR_AUTH=1 ──────────────────────────────────────────────────────
def _authenticate_via_hr(login: str, password: str) -> AuthUser:
hr_eng = get_hr_engine()
if hr_eng is None:
raise AuthError(500, RU['hrDatabaseUrlMissing'])
with hr_eng.connect() as hr_conn:
u = hr_conn.execute(
text(
'SELECT id, username, password_hash, role FROM users '
'WHERE LOWER(TRIM(username)) = LOWER(TRIM(:login))'
),
{'login': login},
).mappings().first()
if not u or not u['password_hash']:
raise AuthError(401, RU['invalidCredentials'])
if not _verify_password(password, u['password_hash']):
raise AuthError(401, RU['invalidCredentials'])
s = hr_conn.execute(
text(
'SELECT id, fio FROM staff_members '
"WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM(:login))"
),
{'login': login},
).mappings().first()
if not s:
raise AuthError(403, RU['noStaffForLogin'])
staff_id = int(s['id'])
fio = s['fio'] or login
app_role = map_hr_role_to_app(u['role'])
eng = get_engine()
with eng.begin() as conn:
row = conn.execute(
text(
"""
INSERT INTO users (login, password_hash, full_name, role,
department_id, is_active, staff_id)
VALUES (:login, :ph, :fn, :role, NULL, true, :staff_id)
ON CONFLICT (staff_id) DO UPDATE SET
login = EXCLUDED.login,
full_name = EXCLUDED.full_name,
role = EXCLUDED.role,
password_hash = EXCLUDED.password_hash
RETURNING id, login, full_name, role, department_id, staff_id
"""
),
{
'login': login,
'ph': HR_MANAGED_PASSWORD_PLACEHOLDER,
'fn': fio,
'role': app_role,
'staff_id': staff_id,
},
).mappings().first()
return AuthUser(
id=str(row['id']),
login=row['login'],
full_name=row['full_name'],
role=row['role'],
department_id=row['department_id'],
staff_id=row['staff_id'],
)
def load_user_by_id(user_id: str) -> Optional[AuthUser]:
"""Догружает пользователя из `clinic_tests.users` (используется при каждом запросе)."""
if not user_id:
return None
eng = get_engine()
with eng.connect() as conn:
row = conn.execute(
text(
'SELECT id, login, full_name, role, department_id, staff_id '
'FROM users WHERE id = :id AND is_active = true'
),
{'id': user_id},
).mappings().first()
if not row:
return None
return AuthUser(
id=str(row['id']),
login=row['login'],
full_name=row['full_name'],
role=row['role'],
department_id=row['department_id'],
staff_id=row['staff_id'],
)

0
flask_app/app/blueprints/__init__.py

31
flask_app/app/blueprints/main.py

@ -0,0 +1,31 @@
"""Главный blueprint — посадочная страница и health-чек.
В E1.0 здесь нет бизнес-логики; страницы и эндпоинты добавляются в следующих
спринтах (E1.1 auth, E1.2 тесты, и т.д.).
"""
from __future__ import annotations
from flask import Blueprint, jsonify, render_template
from .. import db as app_db
from ..auth.decorators import login_required
main_bp = Blueprint('main', __name__)
@main_bp.route('/health')
def health():
"""Smoke-проверка приложения и подключений к БД (без авторизации)."""
db_status = app_db.ping()
overall = 'ok' if db_status.get('main') == 'ok' else 'degraded'
return jsonify(
status=overall,
service='testing-flask-app',
db=db_status,
)
@main_bp.route('/')
@login_required
def index():
return render_template('index.html')

33
flask_app/app/blueprints/settings.py

@ -0,0 +1,33 @@
"""Страница настроек: статус LLM-ключа и проверка подключения (E1.8).
Ключ общий, читается из ENV (`DEEPSEEK_API_KEY` / `OPENAI_API_KEY`). Здесь
только просмотр статуса и smoke-проверка. Изменение ключа через `.env` и
рестарт процесса.
"""
from __future__ import annotations
from flask import Blueprint, jsonify, render_template
from ..auth.decorators import login_required
from ..services.llm_client import get_llm_config, ping_llm
settings_bp = Blueprint('settings', __name__)
@settings_bp.route('/settings', methods=['GET'])
@login_required
def settings_page():
cfg = get_llm_config()
return render_template(
'settings.html',
configured=cfg is not None,
provider=cfg.provider if cfg else None,
model=cfg.model if cfg else None,
base_url=cfg.base_url if cfg else None,
)
@settings_bp.route('/api/llm/ping', methods=['POST', 'GET'])
@login_required
def api_llm_ping():
return jsonify(ping_llm())

39
flask_app/app/config.py

@ -0,0 +1,39 @@
"""Точечные настройки и feature-флаги (1:1 с Express-бэкендом)."""
from __future__ import annotations
import os
HR_MANAGED_PASSWORD_PLACEHOLDER = '$HR$MANAGED$NO_LOCAL$'
"""Заглушка пароля для пользователей, попавших в clinic_tests через HR-апсёрт.
При локальном входе compare всегда даёт False (см. authenticate_local)."""
def _truthy(val: str | None) -> bool:
return (val or '').strip().lower() in ('1', 'true', 'yes', 'on')
def is_hr_auth_enabled() -> bool:
"""`HR_AUTH=1` → логин через `hr_bot_test.users` (Werkzeug)."""
return _truthy(os.environ.get('HR_AUTH'))
def is_assignment_feature_enabled() -> bool:
"""API/UI назначения тестов сотрудникам (см. backend/src/config/featureFlags.js)."""
if (os.environ.get('FLASK_ENV') or '').lower() == 'development':
return True
if (os.environ.get('FLASK_DEBUG') or '').strip() == '1':
return True
raw = (os.environ.get('CLINIC_ASSIGNMENT_ENABLED') or '').strip().lower()
if raw in ('1', 'true', 'yes'):
return True
if raw in ('0', 'false', 'no'):
return False
return False
def is_dev_ui() -> bool:
"""В Express это `NODE_ENV=development`. У нас — FLASK_ENV/FLASK_DEBUG."""
if (os.environ.get('FLASK_ENV') or '').lower() == 'development':
return True
return (os.environ.get('FLASK_DEBUG') or '').strip() == '1'

141
flask_app/app/db.py

@ -0,0 +1,141 @@
"""Подключение к PostgreSQL — тот же паттерн, что в HR_TG_Bot/tgFlaskForm/db/session.py.
В Этапе 1 работаем с БД `clinic_tests` (схема не меняется). Опционально доступна
вторая БД `hr_bot_test` для HR-аутентификации (см. .env.example, флаг HR_AUTH).
"""
from __future__ import annotations
import os
import threading
from typing import Optional
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool
_lock = threading.Lock()
_engine: Optional[Engine] = None
_session_local: Optional[sessionmaker] = None
_hr_engine: Optional[Engine] = None
_hr_session_local: Optional[sessionmaker] = None
def get_database_url() -> str:
"""URL основной БД (`clinic_tests`).
Приоритет: DATABASE_URL отдельные DB_*-переменные.
"""
if db_url := os.environ.get('DATABASE_URL'):
return db_url.strip()
db_host = os.environ.get('DB_HOST', 'localhost')
db_port = os.environ.get('DB_PORT', '5432')
db_name = os.environ.get('DB_NAME', 'clinic_tests')
db_user = os.environ.get('DB_USER', 'hr_bot_user')
db_password = os.environ.get('DB_PASSWORD', 'hrbot123')
return f'postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}'
def get_hr_database_url() -> Optional[str]:
"""URL БД HR (`hr_bot_test`) — только если включён HR_AUTH."""
if not _hr_auth_enabled():
return None
if url := os.environ.get('HR_DATABASE_URL'):
return url.strip()
return None
def _hr_auth_enabled() -> bool:
val = (os.environ.get('HR_AUTH') or '').strip().lower()
return val in ('1', 'true', 'yes', 'on')
def get_engine() -> Engine:
"""Возвращает общий engine основной БД (singleton на процесс)."""
global _engine
if _engine is not None:
return _engine
with _lock:
if _engine is not None:
return _engine
_engine = create_engine(
get_database_url(),
poolclass=QueuePool,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
return _engine
def get_session():
"""Создаёт новую ORM-сессию поверх общего engine."""
global _session_local
if _session_local is None:
with _lock:
if _session_local is None:
_session_local = sessionmaker(bind=get_engine())
return _session_local()
def get_hr_engine() -> Optional[Engine]:
"""Engine для HR-БД. Возвращает None, если HR_AUTH не включён."""
if not _hr_auth_enabled():
return None
global _hr_engine
if _hr_engine is not None:
return _hr_engine
url = get_hr_database_url()
if not url:
return None
with _lock:
if _hr_engine is not None:
return _hr_engine
_hr_engine = create_engine(
url,
poolclass=QueuePool,
pool_size=3,
max_overflow=5,
pool_pre_ping=True,
)
return _hr_engine
def get_hr_session():
"""Сессия для HR-БД (или None при выключенном HR_AUTH)."""
eng = get_hr_engine()
if eng is None:
return None
global _hr_session_local
if _hr_session_local is None:
with _lock:
if _hr_session_local is None:
_hr_session_local = sessionmaker(bind=eng)
return _hr_session_local()
def ping() -> dict:
"""Smoke-проверка подключения к БД (используется в /health)."""
out: dict = {'main': 'unknown'}
try:
with get_engine().connect() as conn:
conn.exec_driver_sql('SELECT 1')
out['main'] = 'ok'
except Exception as e:
out['main'] = f'error: {type(e).__name__}: {e}'
if _hr_auth_enabled():
out['hr'] = 'unknown'
try:
eng = get_hr_engine()
if eng is None:
out['hr'] = 'disabled (HR_DATABASE_URL not set)'
else:
with eng.connect() as conn:
conn.exec_driver_sql('SELECT 1')
out['hr'] = 'ok'
except Exception as e:
out['hr'] = f'error: {type(e).__name__}: {e}'
return out

25
flask_app/app/messages.py

@ -0,0 +1,25 @@
"""Русские сообщения API (порт `backend/src/messages/ru.js`)."""
RU = {
'loginAndPasswordRequired': 'Укажите логин и пароль.',
'invalidCredentials': 'Неверный логин или пароль.',
'useHrLogin': 'Войдите через учётную запись кадровой системы (тот же логин, что в HR).',
'hrDatabaseUrlMissing': 'База кадровой системы не настроена: задайте HR_DATABASE_URL.',
'hrDatabaseNotConfigured': 'База кадровой системы не настроена.',
'noStaffForLogin': (
'К учётной записи не привязан сотрудник: в HR в карточке сотрудника '
'должно совпадать поле веб-логина (web_login) с логином входа.'
),
'loggedOut': 'Вы вышли из системы.',
'logoutFailed': 'Не удалось выйти. Повторите попытку.',
'userDataFailed': 'Не удалось загрузить данные пользователя.',
'loginFailed': 'Ошибка входа. Повторите попытку.',
'authRequired': 'Требуется вход в систему.',
'tokenInvalid': 'Сессия истекла или недействительна. Войдите снова.',
'userNotFound': 'Пользователь не найден.',
'authError': 'Ошибка проверки доступа.',
'insufficientPermissions': 'Недостаточно прав.',
'departmentAccessDenied': 'Нет доступа к этому подразделению.',
'notFound': 'Не найдено.',
'internal': 'Внутренняя ошибка сервера.',
}

0
flask_app/app/services/__init__.py

352
flask_app/app/services/ai_editor.py

@ -0,0 +1,352 @@
"""AI-генерация теста/вопроса в редакторе (порт `services/aiEditorService.js`)."""
from __future__ import annotations
from typing import Any
from .draft_validator import (
assert_draft_matches_shape,
parse_json_from_llm_text,
validate_and_normalize_draft,
)
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
class HttpError(Exception):
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
self.message = message
def parse_and_validate_shape(s: Any) -> list[dict]:
if not isinstance(s, list) or not s:
raise HttpError(400, 'Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].')
if len(s) > 40:
raise HttpError(400, 'Не более 40 вопросов за раз.')
out = []
for i, row in enumerate(s):
if not isinstance(row, dict):
raise HttpError(400, f'shape[{i}]: ожидается объект.')
try:
n = int(float(row.get('optionsCount')))
except (TypeError, ValueError):
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
if n < 2 or n > 12:
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
out.append({'optionsCount': n, 'hasMultipleAnswers': bool(row.get('hasMultipleAnswers'))})
return out
def _require_cfg():
cfg = get_llm_config()
if cfg is None:
raise HttpError(503, 'Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.')
return cfg
def generate_full_test_by_shape(test_title: str, test_description: str, shape: list[dict]) -> dict:
cfg = _require_cfg()
title = (test_title or '').strip() or 'Тест'
desc = (test_description or '').strip()
lines = []
for i, sh in enumerate(shape):
if sh['hasMultipleAnswers']:
tail = 'несколько вариантов помечены как верные (hasMultipleAnswers: true).'
else:
tail = 'ровно один верный вариант (hasMultipleAnswers: false).'
lines.append(f'Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов ответа; {tail}')
system = (
'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. '
'Схема: {"title": string, "description": string (может быть пустой строкой), '
'"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", '
'"options": [{ "text", "isCorrect" }]}.'
)
user = (
'Составь тест по теме.\n\n'
f'Название (можно уточнить, но смысл сохранить): {title}\n'
f'Краткое описание / контекст темы: '
f'{desc or "не указано; придумай согласованную тему с названием."}\n\n'
f'Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше):\n'
+ '\n'.join(lines)
+ '\n\nПравила: варианты — осмысленные, по теме; отметь isCorrect согласно '
'hasMultipleAnswers; для одного правильного — ровна одна true.'
)
raw = chat_completion_text_content(cfg, system, user, 0.35)
parsed = parse_json_from_llm_text(raw)
draft = validate_and_normalize_draft(parsed)
assert_draft_matches_shape({'questions': draft['questions']}, shape)
return {
'title': draft['title'],
'description': draft['description'],
'questions': draft['questions'],
}
# ─── E1.8: AI v2 ──────────────────────────────────────────────────────
def generate_test_by_title(
test_title: str,
test_description: str = '',
questions_count: int = 10,
options_count: int = 4,
has_multiple_answers: bool = False,
) -> dict:
"""Генерация теста ТОЛЬКО по названию: AI сам предлагает вопросы.
Сетка не задаётся жёстко: пользователю даётся подсказка о желаемом числе
вопросов и вариантов, но мы валидируем мягко (не assert_draft_matches_shape).
"""
cfg = _require_cfg()
title = (test_title or '').strip()
if not title:
raise HttpError(400, 'Укажите название теста.')
desc = (test_description or '').strip()
n_q = max(3, min(40, int(questions_count or 10)))
n_opt = max(2, min(12, int(options_count or 4)))
system = (
'Ты опытный методист, составляешь учебные тесты. Отвечай ТОЛЬКО одним '
'JSON-объектом на русском. Схема: {"title", "description", "questions": ['
'{"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect"}]}'
']}. Минимум 2 варианта, отметь хотя бы один isCorrect: true.'
)
user = (
'Составь учебный тест по этой теме.\n\n'
f'Название теста: {title}\n'
f'Описание/контекст: {desc or "не указано — определи по названию."}\n\n'
f'Подсказка по сетке: примерно {n_q} вопросов, в каждом по {n_opt} вариантов '
f'ответа; '
f'тип ответа — {"несколько правильных" if has_multiple_answers else "один правильный"} '
f'(но если по смыслу нужно отступить — отступи). '
'Покрой ключевые подтемы. Дистракторы делай правдоподобными, не очевидно '
'неверными. Текст — короткий, понятный.'
)
raw = chat_completion_text_content(cfg, system, user, 0.45)
parsed = parse_json_from_llm_text(raw)
draft = validate_and_normalize_draft(parsed)
return {
'title': draft['title'],
'description': draft['description'],
'questions': draft['questions'],
}
def check_test_quality(test_title: str, test_description: str, questions: list[dict]) -> dict:
"""AI-рецензия теста: общий вердикт + список рекомендаций по разделам."""
cfg = _require_cfg()
title = (test_title or '').strip() or 'Тест'
desc = (test_description or '').strip()
qs = questions or []
if not qs:
raise HttpError(400, 'В тесте нет вопросов — нечего проверять.')
system = (
'Ты ревьюер учебных тестов. Отвечай ТОЛЬКО JSON: {"verdict": "ok"|"warn"|"bad", '
'"summary": string (1-2 предложения), '
'"sections": [{"title": string, "items": [string, ...]}]}. '
'Разделы рекомендаций: «Чёткость формулировок», «Качество дистракторов», '
'"Охват темы», «Сбалансированность сложности». Пропусти раздел, если '
'претензий нет. Вердикт: ok — годен; warn — есть замечания; bad — серьёзные '
'проблемы. Все тексты — на русском, короткие и предметные.'
)
test_dump = {
'title': title,
'description': desc,
'questions': [
{
'text': q.get('text', ''),
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
'options': [
{'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))}
for o in (q.get('options') or [])
],
}
for q in qs
],
}
import json as _json
user = 'Проверь качество теста и дай рекомендации:\n\n' + _json.dumps(
test_dump, ensure_ascii=False
)
raw = chat_completion_text_content(cfg, system, user, 0.25)
parsed = parse_json_from_llm_text(raw)
if not isinstance(parsed, dict):
raise LlmError('Неверный формат ответа модели.', code='llm_shape')
verdict = str(parsed.get('verdict') or '').strip().lower()
if verdict not in ('ok', 'warn', 'bad'):
verdict = 'warn'
summary = str(parsed.get('summary') or '').strip()
raw_sections = parsed.get('sections') or []
sections: list[dict] = []
if isinstance(raw_sections, list):
for s in raw_sections:
if not isinstance(s, dict):
continue
t = str(s.get('title') or '').strip()
items = s.get('items') or []
if not t or not isinstance(items, list) or not items:
continue
clean_items = [str(x).strip() for x in items if str(x).strip()]
if clean_items:
sections.append({'title': t, 'items': clean_items})
return {'verdict': verdict, 'summary': summary, 'sections': sections}
def improve_test_full(test_title: str, test_description: str, questions: list[dict]) -> dict:
"""AI-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса.
Для каждого вопроса: original + suggested, флаги textChanged/optionsChanged.
UI решает, что применить (чекбоксы).
"""
cfg = _require_cfg()
title = (test_title or '').strip() or 'Тест'
desc = (test_description or '').strip()
qs = questions or []
if not qs:
raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.')
system = (
'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь '
'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку '
'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и '
'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. '
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
'"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.'
)
test_dump = {
'title': title,
'description': desc,
'questions': [
{
'text': q.get('text', ''),
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
'options': [
{'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))}
for o in (q.get('options') or [])
],
}
for q in qs
],
}
import json as _json
user = 'Улучши тест без изменения сетки:\n\n' + _json.dumps(
test_dump, ensure_ascii=False
)
raw = chat_completion_text_content(cfg, system, user, 0.3)
parsed = parse_json_from_llm_text(raw)
shape = [
{
'optionsCount': len(q.get('options') or []),
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
}
for q in qs
]
assert_draft_matches_shape(parsed, shape)
draft = validate_and_normalize_draft(
{'title': title, 'questions': parsed.get('questions') or []}
)
suggested_qs = draft['questions']
items = []
for i, (orig, sug) in enumerate(zip(qs, suggested_qs)):
orig_opts = [
{'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))}
for o in (orig.get('options') or [])
]
sug_opts = sug['options']
text_changed = (str(orig.get('text', '')).strip() != sug['text'])
options_changed = (
len(orig_opts) != len(sug_opts)
or any(
a['text'] != b['text'] or a['isCorrect'] != b['isCorrect']
for a, b in zip(orig_opts, sug_opts)
)
)
items.append(
{
'index': i,
'original': {
'text': str(orig.get('text', '')).strip(),
'hasMultipleAnswers': bool(orig.get('hasMultipleAnswers')),
'options': orig_opts,
},
'suggested': {
'text': sug['text'],
'hasMultipleAnswers': sug['hasMultipleAnswers'],
'options': sug_opts,
},
'textChanged': text_changed,
'optionsChanged': options_changed,
'changed': text_changed or options_changed,
}
)
return {'items': items}
def generate_or_rephrase_question(
test_title: str,
test_description: str,
question_text: str,
options_count: Any,
has_multiple_answers: bool,
) -> dict:
cfg = _require_cfg()
try:
n = int(float(options_count))
except (TypeError, ValueError):
raise HttpError(400, 'optionsCount: от 2 до 12.')
if n < 2 or n > 12:
raise HttpError(400, 'optionsCount: от 2 до 12.')
topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip()
qt = (question_text or '').strip()
if qt:
system = (
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — '
'чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости '
'от сложности исходного черновика, без вариантов ответа.'
)
user = (
f'Тема теста: {topic}\n\n'
f'Исходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n{qt}'
)
raw = chat_completion_text_content(cfg, system, user, 0.3)
parsed = parse_json_from_llm_text(raw)
text = str((parsed or {}).get('text') or '').strip()
if not text:
raise LlmError('Пустой text в ответе модели.', code='llm_shape')
return {'mode': 'rephrase', 'text': text}
system = (
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", '
'"options": [{ "text", "isCorrect" }]}. Все на русском.'
)
multi_clause = (
'true (несколько верных, минимум 2 isCorrect: true, остальные false).'
if has_multiple_answers
else 'false (ровно один isCorrect: true).'
)
user = (
f'Тема теста: {topic}\n\n'
f'Сформулируй ОДИН вопрос по этой теме с ровно {n} вариантами ответа. '
f'hasMultipleAnswers = {multi_clause}'
)
raw = chat_completion_text_content(cfg, system, user, 0.35)
parsed = parse_json_from_llm_text(raw)
shape = [{'optionsCount': n, 'hasMultipleAnswers': bool(has_multiple_answers)}]
assert_draft_matches_shape({'questions': [parsed]}, shape)
draft = validate_and_normalize_draft({'title': 'временно', 'questions': [parsed]})
return {
'mode': 'full',
'text': draft['questions'][0]['text'],
'hasMultipleAnswers': draft['questions'][0]['hasMultipleAnswers'],
'options': draft['questions'][0]['options'],
}

89
flask_app/app/services/document_extract.py

@ -0,0 +1,89 @@
"""Извлечение текста из PDF/DOCX/TXT/MD (порт `services/documentExtractService.js`)."""
from __future__ import annotations
from io import BytesIO
from typing import Optional
class HttpError(Exception):
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
self.message = message
SUPPORTED_MIME = {
'application/pdf': 'pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'text/plain': 'text',
'text/markdown': 'text',
}
SUPPORTED_EXT = {
'.pdf': 'pdf',
'.docx': 'docx',
'.txt': 'text',
'.md': 'text',
}
def resolve_document_kind(mimetype: str | None, original_name: str | None = '') -> Optional[str]:
m = (mimetype or '').lower()
n = (original_name or '').lower()
if m in SUPPORTED_MIME:
return SUPPORTED_MIME[m]
for ext, kind in SUPPORTED_EXT.items():
if n.endswith(ext):
return kind
return None
def extract_text_from_buffer(kind: str, buf: bytes) -> str:
if kind == 'text':
try:
return buf.decode('utf-8')
except UnicodeDecodeError:
return buf.decode('utf-8', errors='replace')
if kind == 'docx':
try:
from docx import Document
except ImportError:
raise HttpError(500, 'python-docx не установлен (см. requirements.txt).')
doc = Document(BytesIO(buf))
parts = []
for p in doc.paragraphs:
if p.text:
parts.append(p.text)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
if cell.text:
parts.append(cell.text)
return '\n'.join(parts).replace('\r\n', '\n').strip()
if kind == 'pdf':
try:
from pypdf import PdfReader
except ImportError:
raise HttpError(500, 'pypdf не установлен (см. requirements.txt).')
reader = PdfReader(BytesIO(buf))
parts = []
for page in reader.pages:
try:
t = page.extract_text() or ''
except Exception:
t = ''
if t:
parts.append(t)
return '\n'.join(parts).replace('\r\n', '\n').strip()
return ''
def extract_text_from_file(mimetype: str | None, file_storage, original_name: str | None) -> str:
"""`file_storage` — werkzeug FileStorage. Читает целиком в память (≤16 МБ)."""
kind = resolve_document_kind(mimetype, original_name)
if not kind:
raise HttpError(400, 'Неподдерживаемый формат. Допустимы: PDF, DOCX, TXT, MD.')
buf = file_storage.read()
return extract_text_from_buffer(kind, buf)

72
flask_app/app/services/document_gen.py

@ -0,0 +1,72 @@
"""Генерация черновика теста из извлечённого текста (порт части `documentGenService.js`)."""
from __future__ import annotations
from .draft_validator import (
parse_json_from_llm_text,
validate_and_normalize_draft,
)
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
MAX_EXTRACT_CHARS = 14000
def generation_for_import_document(extracted_text: str) -> dict:
text = (extracted_text or '').strip()
if not text:
return {
'available': False,
'message': 'Нет извлечённого текста — нечего передавать в модель.',
}
cfg = get_llm_config()
if cfg is None:
return {
'available': False,
'message': (
'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY '
'в .env. Превью текста ниже — можно вставить вручную.'
),
'textPreview': text[:4000],
}
if len(text) > MAX_EXTRACT_CHARS:
slice_ = text[:MAX_EXTRACT_CHARS] + '\n\n[…фрагмент обрезан для API]'
else:
slice_ = text
try:
system = (
'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом '
'без пояснений. Схема: {"title": string, "description"?: string, '
'"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, '
'"options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. '
'Для одиночного выбора ровно один isCorrect: true. '
'Текст и формулировки — на русском, по содержанию входного материала.'
)
user = (
'Составь тест с вопросами с одним или несколькими правильными ответами '
'на основе текста:\n\n' + slice_
)
raw = chat_completion_text_content(cfg, system, user, 0.25)
parsed = parse_json_from_llm_text(raw)
draft = validate_and_normalize_draft(parsed)
return {
'available': True,
'message': (
f'Сгенерировано: «{draft["title"]}», вопросов: '
f'{len(draft["questions"])}. Нажмите «Применить сгенерированный черновик».'
),
'draft': draft,
}
except LlmError as e:
return {
'available': False,
'message': f'Генерация не удалась: {e}',
'errorCode': e.code,
'textPreview': text[:4000],
}
except Exception as e:
return {
'available': False,
'message': f'Генерация не удалась: {e}',
'errorCode': 'unknown',
'textPreview': text[:4000],
}

105
flask_app/app/services/draft_validator.py

@ -0,0 +1,105 @@
"""Парсер JSON от LLM и валидатор draft (порт частей `documentGenService.js`)."""
from __future__ import annotations
import json as _json
import re
from typing import Any
from .llm_client import LlmError
_FENCE_RE = re.compile(r'^```(?:json)?\s*([\s\S]*?)```$', re.MULTILINE)
def parse_json_from_llm_text(text: str) -> Any:
if not isinstance(text, str) or not text.strip():
raise LlmError('Пустой ответ модели.', code='llm_empty')
t = text.strip()
if m := _FENCE_RE.match(t):
t = m.group(1).strip()
try:
return _json.loads(t)
except _json.JSONDecodeError:
raise LlmError('Ответ модели не является корректным JSON.', code='llm_json_parse')
def validate_and_normalize_draft(o: Any) -> dict:
if not isinstance(o, dict):
raise LlmError('JSON не содержит объекта с данными.', code='llm_shape')
title = str(o.get('title') or '').strip()
if not title:
raise LlmError('В ответе нет поля title.', code='llm_shape')
desc = o.get('description')
description = str(desc).strip() if desc and str(desc).strip() else None
raw_qs = o.get('questions')
if not isinstance(raw_qs, list) or not raw_qs:
raise LlmError('В ответе нет вопросов (questions).', code='llm_shape')
if len(raw_qs) > 40:
raise LlmError('Слишком много вопросов в ответе (макс. 40).', code='llm_shape')
questions = []
for i, q in enumerate(raw_qs):
if not isinstance(q, dict):
raise LlmError(f'Вопрос {i + 1}: неверный формат.', code='llm_shape')
text = str(q.get('text') or '').strip()
if not text:
raise LlmError(f'Вопрос {i + 1}: пустой текст.', code='llm_shape')
has_multi = bool(q.get('hasMultipleAnswers'))
raw_opts = q.get('options')
if not isinstance(raw_opts, list) or len(raw_opts) < 2:
raise LlmError(f'Вопрос {i + 1}: нужны минимум 2 варианта ответа.', code='llm_shape')
if len(raw_opts) > 12:
raise LlmError(f'Вопрос {i + 1}: слишком много вариантов (макс. 12).', code='llm_shape')
options = []
for j, op in enumerate(raw_opts):
if not isinstance(op, dict):
raise LlmError(f'Вопрос {i + 1}, вариант {j + 1}: неверный формат.', code='llm_shape')
options.append(
{
'text': (str(op.get('text') or '').strip() or f'Вариант {j + 1}'),
'isCorrect': bool(op.get('isCorrect')),
}
)
correct_n = sum(1 for x in options if x['isCorrect'])
if correct_n == 0:
raise LlmError(
f'Вопрос {i + 1}: отметьте минимум один правильный вариант.',
code='llm_shape',
)
if not has_multi and correct_n > 1:
raise LlmError(
f'Вопрос {i + 1}: с одним правильным ответом должен быть один вариант '
f'isCorrect, либо укажите hasMultipleAnswers: true.',
code='llm_shape',
)
questions.append({'text': text, 'hasMultipleAnswers': has_multi, 'options': options})
return {'title': title, 'description': description, 'questions': questions}
def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None:
"""Проверяет, что число вопросов и вариантов = ровно как в shape."""
qs = o.get('questions') if isinstance(o, dict) else None
if not isinstance(qs, list):
raise LlmError('В ответе нет questions.', code='llm_shape')
if len(qs) != len(shape):
raise LlmError(
f'Ожидалось вопросов: {len(shape)}, в ответе: {len(qs)}.',
code='llm_shape',
)
for i, (q, sh) in enumerate(zip(qs, shape)):
opts = q.get('options') if isinstance(q, dict) else None
if not isinstance(opts, list):
raise LlmError(f'Вопрос {i + 1}: нет options.', code='llm_shape')
if len(opts) != sh['optionsCount']:
raise LlmError(
f'Вопрос {i + 1}: ожидалось вариантов {sh["optionsCount"]}, в ответе: {len(opts)}.',
code='llm_shape',
)
if bool(q.get('hasMultipleAnswers')) != sh['hasMultipleAnswers']:
raise LlmError(
f'Вопрос {i + 1}: hasMultipleAnswers должен быть {sh["hasMultipleAnswers"]}.',
code='llm_shape',
)

95
flask_app/app/services/editor_content.py

@ -0,0 +1,95 @@
"""Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами.
Порт `getEditorContent` + `loadQuestionsForVersion` (только includeCorrect=true вариант)
из `services/testAttemptService.js`.
"""
from __future__ import annotations
from sqlalchemy import text
from ..db import get_engine
from ..messages import RU
from .test_access import is_test_author
class HttpError(Exception):
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
self.message = message
def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]:
qrows = conn.execute(
text(
'SELECT id, text, question_order, has_multiple_answers '
'FROM questions WHERE test_version_id = :v ORDER BY question_order'
),
{'v': test_version_id},
).mappings().all()
out = []
for r in qrows:
orows = conn.execute(
text(
'SELECT id, text, is_correct, option_order '
'FROM answer_options WHERE question_id = :q ORDER BY option_order'
),
{'q': r['id']},
).mappings().all()
options = []
for o in orows:
base = {
'id': str(o['id']),
'text': o['text'],
'optionOrder': o['option_order'],
}
if include_correct:
base['isCorrect'] = bool(o['is_correct'])
options.append(base)
out.append(
{
'id': str(r['id']),
'text': r['text'],
'questionOrder': r['question_order'],
'hasMultipleAnswers': bool(r['has_multiple_answers']),
'options': options,
}
)
return out
def get_editor_content(user_id: str, test_id: str) -> dict:
eng = get_engine()
with eng.connect() as conn:
tr = conn.execute(
text(
'SELECT id, title, description, passing_threshold, created_by '
'FROM tests WHERE id = :id'
),
{'id': test_id},
).mappings().first()
if not tr:
raise HttpError(404, 'Тест не найден.')
if not is_test_author(tr['created_by'], user_id):
raise HttpError(403, 'Доступ запрещён.')
tv = conn.execute(
text(
'SELECT id FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1'
),
{'id': test_id},
).mappings().first()
if not tv:
raise HttpError(400, 'Нет активной версии теста.')
version_id = tv['id']
questions = load_questions_for_version(conn, version_id, include_correct=True)
return {
'test': {
'id': str(tr['id']),
'title': tr['title'],
'description': tr['description'],
'passingThreshold': tr['passing_threshold'],
},
'activeVersionId': str(version_id),
'questions': questions,
}

156
flask_app/app/services/llm_client.py

@ -0,0 +1,156 @@
"""OpenAI-совместимый клиент Chat Completions (порт `services/llmClient.js`)."""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
import urllib.request
import urllib.error
import json as _json
class LlmError(Exception):
"""Ошибка работы с LLM API."""
def __init__(self, message: str, code: str = 'llm_error', status: int | None = None):
super().__init__(message)
self.code = code
self.status = status
@dataclass
class LlmConfig:
provider: str
api_key: str
base_url: str
model: str
def get_llm_config() -> Optional[LlmConfig]:
if k := os.environ.get('DEEPSEEK_API_KEY'):
return LlmConfig(
provider='deepseek',
api_key=k,
base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.deepseek.com/v1').rstrip('/'),
model=os.environ.get('LLM_MODEL') or 'deepseek-chat',
)
if k := os.environ.get('OPENAI_API_KEY'):
return LlmConfig(
provider='openai',
api_key=k,
base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.openai.com/v1').rstrip('/'),
model=os.environ.get('LLM_MODEL') or 'gpt-4o-mini',
)
return None
def chat_completion_text_content(
cfg: LlmConfig,
system: str,
user: str,
temperature: float = 0.25,
timeout: int = 120,
) -> str:
"""Возвращает `assistant.message.content` (строку)."""
body: dict = {
'model': cfg.model,
'messages': [
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
'temperature': temperature,
}
if (os.environ.get('LLM_NO_JSON') or '').strip() != '1':
body['response_format'] = {'type': 'json_object'}
req = urllib.request.Request(
f'{cfg.base_url}/chat/completions',
data=_json.dumps(body).encode('utf-8'),
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {cfg.api_key}',
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = _json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
text = ''
try:
text = e.read().decode('utf-8', errors='replace')
except Exception:
pass
raise LlmError(
f'LLM {e.code}: {(text or "").replace(chr(10), " ")[:280]}',
code='llm_http',
status=e.code,
)
except (urllib.error.URLError, TimeoutError) as e:
msg = str(getattr(e, 'reason', '') or e)
if 'timed out' in msg.lower():
raise LlmError('Превышен таймаут ожидания ответа LLM (120 с).', code='llm_timeout')
raise LlmError(f'Сбой сети при обращении к LLM: {msg}', code='llm_network')
try:
content = data['choices'][0]['message']['content']
except (KeyError, IndexError, TypeError):
content = None
if not isinstance(content, str) or not content.strip():
raise LlmError('Пустой content в ответе API.', code='llm_empty')
return content
def ping_llm(timeout: int = 30) -> dict:
"""Smoke-проверка подключения к LLM. Не бросает исключений — всё в результате.
Возвращает: {'ok': bool, 'provider', 'model', 'error'?, 'latencyMs'?, 'sample'?}
"""
import time
cfg = get_llm_config()
if cfg is None:
return {
'ok': False,
'configured': False,
'error': 'Ключ не задан. Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY в .env.',
}
started = time.monotonic()
try:
raw = chat_completion_text_content(
cfg,
'Отвечай ТОЛЬКО JSON: {"ok": true}.',
'ping',
temperature=0.0,
timeout=timeout,
)
ms = int((time.monotonic() - started) * 1000)
return {
'ok': True,
'configured': True,
'provider': cfg.provider,
'model': cfg.model,
'latencyMs': ms,
'sample': raw[:120],
}
except LlmError as e:
ms = int((time.monotonic() - started) * 1000)
return {
'ok': False,
'configured': True,
'provider': cfg.provider,
'model': cfg.model,
'latencyMs': ms,
'error': str(e),
'code': e.code,
}
except Exception as e:
return {
'ok': False,
'configured': True,
'provider': cfg.provider,
'model': cfg.model,
'error': f'{type(e).__name__}: {e}',
'code': 'unknown',
}

108
flask_app/app/services/test_access.py

@ -0,0 +1,108 @@
"""Кто видит тест: автор + назначенные пользователи (порт `services/testAccessService.js`)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import text
from ..db import get_engine
def is_test_author(created_by, user_id) -> bool:
"""`tests.created_by` — UUID. Сравниваем по строковому представлению."""
if created_by is None or user_id is None:
return False
return str(created_by) == str(user_id)
@dataclass
class AccessResult:
ok: bool
is_author: bool
not_found: bool
def user_has_test_access(user_id: str, test_id: str) -> AccessResult:
eng = get_engine()
with eng.connect() as conn:
row = conn.execute(
text('SELECT created_by FROM tests WHERE id = :id'),
{'id': test_id},
).mappings().first()
if not row:
return AccessResult(ok=False, is_author=False, not_found=True)
if is_test_author(row['created_by'], user_id):
return AccessResult(ok=True, is_author=True, not_found=False)
ar = conn.execute(
text(
"""
SELECT 1
FROM test_assignments ta
INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id
WHERE tv_a.test_id = :test_id
AND tat.target_type = 'user'
AND tat.target_id = :user_id
LIMIT 1
"""
),
{'test_id': test_id, 'user_id': user_id},
).first()
return AccessResult(ok=ar is not None, is_author=False, not_found=False)
def list_visible_tests(user_id: str) -> list[dict]:
"""Каталог: только активная цепочка + (автор OR назначен)."""
eng = get_engine()
with eng.connect() as conn:
rows = conn.execute(
text(
"""
SELECT DISTINCT t.id, t.title, t.description, t.is_active AS chain_active,
t.created_at, t.updated_at,
tv.id AS active_version_id, tv.version,
t.created_by, u.full_name AS author_full_name
FROM tests t
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
INNER JOIN users u ON u.id = t.created_by
WHERE t.is_active = true
AND (
t.created_by = :uid
OR EXISTS (
SELECT 1
FROM test_assignments ta
INNER JOIN test_versions tv2 ON tv2.id = ta.test_version_id
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id
WHERE tv2.test_id = t.id
AND tat.target_type = 'user'
AND tat.target_id = :uid
)
)
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC
"""
),
{'uid': user_id},
).mappings().all()
return [dict(r) for r in rows]
def list_hidden_by_author(user_id: str) -> list[dict]:
"""Скрытые автором цепочки (`is_active = false`) — видны только автору."""
eng = get_engine()
with eng.connect() as conn:
rows = conn.execute(
text(
"""
SELECT t.id, t.title, t.description, t.is_active AS chain_active,
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version,
t.created_by, u.full_name AS author_full_name
FROM tests t
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
INNER JOIN users u ON u.id = t.created_by
WHERE t.is_active = false AND t.created_by = :uid
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC
"""
),
{'uid': user_id},
).mappings().all()
return [dict(r) for r in rows]

22
flask_app/app/services/test_chain.py

@ -0,0 +1,22 @@
"""Утилиты по цепочке теста (попытки/версии)."""
from __future__ import annotations
from sqlalchemy import text
def has_any_attempt_for_test(conn, test_id: str) -> bool:
"""`conn` может быть Connection или Engine — обе поддерживают .execute()."""
row = conn.execute(
text(
"""
SELECT EXISTS (
SELECT 1
FROM test_attempts ta
INNER JOIN test_versions tv ON ta.test_version_id = tv.id
WHERE tv.test_id = :test_id
) AS has_any
"""
),
{'test_id': test_id},
).first()
return bool(row[0])

234
flask_app/app/services/test_draft.py

@ -0,0 +1,234 @@
"""Создание/правка теста, fork версии при наличии попыток (порт `testDraftService.js`)."""
from __future__ import annotations
from typing import Any
from sqlalchemy import text
from ..db import get_engine
from ..messages import RU
from .test_access import is_test_author
from .test_chain import has_any_attempt_for_test
class HttpError(Exception):
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
self.message = message
def create_test_with_version(author_id: str, *, title: str, description: str | None) -> dict:
eng = get_engine()
with eng.begin() as conn:
t = conn.execute(
text(
"""
INSERT INTO tests (title, description, created_by, is_active, is_versioned)
VALUES (:title, :desc, :uid, true, true) RETURNING id
"""
),
{'title': title, 'desc': description or None, 'uid': author_id},
).mappings().first()
test_id = t['id']
v = conn.execute(
text(
"""
INSERT INTO test_versions (test_id, version, is_active, parent_id)
VALUES (:tid, 1, true, NULL) RETURNING id
"""
),
{'tid': test_id},
).mappings().first()
return {'testId': str(test_id), 'versionId': str(v['id'])}
def _get_active_version_row(conn, test_id: str) -> dict | None:
row = conn.execute(
text(
'SELECT * FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1'
),
{'id': test_id},
).mappings().first()
return dict(row) if row else None
def _copy_question_tree(conn, from_version_id, to_version_id) -> None:
questions = conn.execute(
text(
'SELECT id, text, question_order, has_multiple_answers '
'FROM questions WHERE test_version_id = :v ORDER BY question_order'
),
{'v': from_version_id},
).mappings().all()
for q in questions:
new_q = conn.execute(
text(
"""
INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
VALUES (:v, :text, :ord, :multi) RETURNING id
"""
),
{
'v': to_version_id,
'text': q['text'],
'ord': q['question_order'],
'multi': q['has_multiple_answers'],
},
).mappings().first()
nqid = new_q['id']
opts = conn.execute(
text(
'SELECT text, is_correct, option_order FROM answer_options '
'WHERE question_id = :q ORDER BY option_order'
),
{'q': q['id']},
).mappings().all()
for o in opts:
conn.execute(
text(
"""
INSERT INTO answer_options (question_id, text, is_correct, option_order)
VALUES (:q, :text, :ic, :ord)
"""
),
{'q': nqid, 'text': o['text'], 'ic': o['is_correct'], 'ord': o['option_order']},
)
def _replace_version_content(conn, test_version_id, payload: dict) -> None:
conn.execute(
text(
"""
DELETE FROM answer_options WHERE question_id IN (
SELECT id FROM questions WHERE test_version_id = :v
)
"""
),
{'v': test_version_id},
)
conn.execute(
text('DELETE FROM questions WHERE test_version_id = :v'),
{'v': test_version_id},
)
questions = payload.get('questions') or []
for i, q in enumerate(questions):
ins_q = conn.execute(
text(
"""
INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
VALUES (:v, :text, :ord, :multi) RETURNING id
"""
),
{
'v': test_version_id,
'text': q.get('text'),
'ord': q.get('question_order') or (i + 1),
'multi': bool(q.get('hasMultipleAnswers')),
},
).mappings().first()
qid = ins_q['id']
opts = q.get('options') or []
for j, o in enumerate(opts):
conn.execute(
text(
"""
INSERT INTO answer_options (question_id, text, is_correct, option_order)
VALUES (:q, :text, :ic, :ord)
"""
),
{
'q': qid,
'text': o.get('text'),
'ic': bool(o.get('isCorrect')),
'ord': o.get('option_order') or (j + 1),
},
)
def _fork_new_version(conn, test_id: str) -> dict:
av = _get_active_version_row(conn, test_id)
if not av:
raise HttpError(500, RU['internal']) # invariant: должна быть активная версия
mx = conn.execute(
text(
'SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = :t'
),
{'t': test_id},
).mappings().first()
next_v = (mx['v'] or 0) + 1
conn.execute(
text('UPDATE test_versions SET is_active = false WHERE test_id = :t'),
{'t': test_id},
)
nv = conn.execute(
text(
"""
INSERT INTO test_versions (test_id, version, is_active, parent_id)
VALUES (:t, :ver, true, :parent) RETURNING *
"""
),
{'t': test_id, 'ver': next_v, 'parent': av['id']},
).mappings().first()
_copy_question_tree(conn, av['id'], nv['id'])
return dict(nv)
def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict:
if not isinstance(payload, dict):
payload = {}
eng = get_engine()
with eng.begin() as conn:
t = conn.execute(
text('SELECT id, created_by FROM tests WHERE id = :id'),
{'id': test_id},
).mappings().first()
if not t:
raise HttpError(404, RU['testNotFound'] if 'testNotFound' in RU else 'Тест не найден.')
if not is_test_author(t['created_by'], author_id):
raise HttpError(403, 'Доступ запрещён.')
if payload.get('title') is not None or payload.get('description') is not None:
conn.execute(
text(
"""
UPDATE tests
SET title = COALESCE(:title, title),
description = COALESCE(:desc, description),
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
"""
),
{
'title': payload.get('title'),
'desc': payload.get('description'),
'id': test_id,
},
)
if payload.get('passingThreshold') is not None:
try:
raw = float(payload['passingThreshold'])
pt = max(0, min(100, round(raw)))
conn.execute(
text(
'UPDATE tests SET passing_threshold = :pt, updated_at = CURRENT_TIMESTAMP WHERE id = :id'
),
{'pt': pt, 'id': test_id},
)
except (TypeError, ValueError):
pass
has_attempts = has_any_attempt_for_test(conn, test_id)
version_row = _get_active_version_row(conn, test_id)
if not version_row:
raise HttpError(500, 'Нет активной версии теста.')
forked = False
if has_attempts and 'questions' in payload and payload.get('questions') is not None:
version_row = _fork_new_version(conn, test_id)
forked = True
if payload.get('questions') is not None:
_replace_version_content(conn, version_row['id'], payload)
return {'testId': test_id, 'versionId': str(version_row['id']), 'forked': forked}

17
flask_app/app/static/css/app.css

@ -0,0 +1,17 @@
/* Точечные стили поверх Tailwind CDN.
В E1.0 файл почти пустой задаёт только сглаживание иконок и базовый focus-ring,
чтобы кнопки/ссылки были консистентны со стилем кабинета HR. */
.material-symbols-outlined {
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 20;
}
:focus-visible {
outline: 2px solid #6366f1; /* brand-500 */
outline-offset: 2px;
border-radius: 6px;
}

546
flask_app/app/static/js/editor.js

@ -0,0 +1,546 @@
/* Редактор теста: рабочий минимум.
* Работает с эндпоинтами /api/tests/<id>/{draft, ai/generate-test, ai/generate-question}
* и /api/tests/<id> (PATCH chainActive).
*
* Полная мобильная отполировка UX (4 аккордеона, fixed footer, drag-n-drop)
* запланирована отдельным спринтом E1.7.
*/
(() => {
'use strict';
const root = document.getElementById('editor-root');
if (!root) return;
const TEST_ID = root.dataset.testId;
const initial = JSON.parse(root.dataset.initial);
const $ = (sel, parent = document) => parent.querySelector(sel);
const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel));
const titleEl = $('#test-title');
const descEl = $('#test-description');
const thresholdEl = $('#test-threshold');
const questionsEl = $('#questions');
const qCountEl = $('#q-count');
const saveStatusEl = $('#save-status');
const aiStatusEl = $('#ai-status');
const chainActiveEl = $('#chain-active');
const tplQ = $('#tpl-question');
const tplO = $('#tpl-option');
let chainActive = true;
// ─── render ─────────────────────────────────────────────────────────
function renderQuestion(q) {
const node = tplQ.content.firstElementChild.cloneNode(true);
node._q = { id: q.id || null };
$('.q-text', node).value = q.text || '';
$('.q-multi', node).checked = !!q.hasMultipleAnswers;
const optsEl = $('.q-options', node);
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o)));
bindQuestionEvents(node);
return node;
}
function renderOption(o) {
const node = tplO.content.firstElementChild.cloneNode(true);
$('.opt-text', node).value = o.text || '';
$('.opt-correct', node).checked = !!o.isCorrect;
$('.opt-delete', node).addEventListener('click', () => {
node.remove();
});
return node;
}
function bindQuestionEvents(node) {
$('.q-delete', node).addEventListener('click', () => {
if (!confirm('Удалить вопрос?')) return;
node.remove();
renumber();
});
$('.q-up', node).addEventListener('click', () => {
if (node.previousElementSibling) {
node.parentNode.insertBefore(node, node.previousElementSibling);
renumber();
}
});
$('.q-down', node).addEventListener('click', () => {
if (node.nextElementSibling) {
node.parentNode.insertBefore(node.nextElementSibling, node);
renumber();
}
});
$('.q-add-option', node).addEventListener('click', () => {
$('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false }));
});
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
}
function renumber() {
$$('#questions .q-item').forEach((li, i) => {
$('.q-num', li).textContent = `Вопрос #${i + 1}`;
});
qCountEl.textContent = $$('#questions .q-item').length;
}
function loadInitial() {
titleEl.value = initial.test.title || '';
descEl.value = initial.test.description || '';
thresholdEl.value =
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
questionsEl.innerHTML = '';
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
}
// ─── collect ───────────────────────────────────────────────────────
function collectPayload() {
const questions = $$('#questions .q-item').map((li, i) => ({
text: $('.q-text', li).value.trim(),
question_order: i + 1,
hasMultipleAnswers: $('.q-multi', li).checked,
options: $$('.opt-item', li).map((op, j) => ({
text: $('.opt-text', op).value.trim(),
isCorrect: $('.opt-correct', op).checked,
option_order: j + 1,
})),
}));
const payload = {
title: titleEl.value.trim() || null,
description: descEl.value.trim() || null,
questions,
};
const t = thresholdEl.value;
if (t !== '' && Number.isFinite(Number(t))) payload.passingThreshold = Number(t);
return payload;
}
function collectShape() {
return $$('#questions .q-item').map((li) => ({
optionsCount: Math.max(2, $$('.opt-item', li).length || 4),
hasMultipleAnswers: $('.q-multi', li).checked,
}));
}
// ─── actions ───────────────────────────────────────────────────────
$('#add-question').addEventListener('click', () => {
questionsEl.appendChild(
renderQuestion({
text: '',
hasMultipleAnswers: false,
options: [
{ text: '', isCorrect: true },
{ text: '', isCorrect: false },
{ text: '', isCorrect: false },
{ text: '', isCorrect: false },
],
}),
);
renumber();
});
$('#save-draft').addEventListener('click', async () => {
saveStatusEl.textContent = 'Сохраняем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectPayload()),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось сохранить.');
if (chainActiveEl.checked !== chainActive) {
const r2 = await fetch(`/api/tests/${TEST_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chainActive: chainActiveEl.checked }),
});
if (r2.ok) chainActive = chainActiveEl.checked;
}
saveStatusEl.textContent = data.forked
? 'Сохранено (создана новая версия — есть попытки прохождения).'
: 'Сохранено.';
setTimeout(() => (saveStatusEl.textContent = ''), 4000);
} catch (e) {
saveStatusEl.textContent = '';
alert(e.message || 'Не удалось сохранить.');
}
});
$('#ai-generate-test').addEventListener('click', async () => {
const shape = collectShape();
if (!shape.length) {
alert('Сначала добавьте хотя бы один вопрос (его настройки определяют сетку).');
return;
}
aiStatusEl.textContent = 'Генерируем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
shape,
}),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
const draft = data.draft;
if (draft.title) titleEl.value = draft.title;
if (draft.description) descEl.value = draft.description;
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
aiStatusEl.textContent = '';
alert(e.message || 'AI: ошибка.');
}
});
// ─── импорт документа (E1.3) ───────────────────────────────────
$('#ai-import-file').addEventListener('change', async (ev) => {
const file = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (!file) return;
aiStatusEl.textContent = `Загружаем «${file.name}»…`;
try {
const fd = new FormData();
fd.append('file', file);
const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd });
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось импортировать.');
const g = data.generation || {};
if (!g.available) {
aiStatusEl.textContent = '';
const msg = g.message || 'AI недоступен.';
const preview = (g.textPreview || data.extractedText || '').slice(0, 600);
alert(msg + (preview ? '\n\nПервые 600 символов:\n' + preview : ''));
return;
}
const ok = confirm(
`${g.message}\n\nПрименить как новый черновик?\n` +
`Текущие вопросы будут заменены.`,
);
if (!ok) {
aiStatusEl.textContent = '';
return;
}
const draft = g.draft;
if (draft.title) titleEl.value = draft.title;
if (draft.description) descEl.value = draft.description;
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
aiStatusEl.textContent = '';
alert(e.message || 'Не удалось импортировать.');
}
});
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
function aiAlert(data, fallback) {
const msg = (data && data.error) || fallback || 'AI: ошибка.';
if (data && data.settingsUrl) {
if (confirm(msg + '\n\nОткрыть Настройки?')) {
window.location.href = data.settingsUrl;
}
return;
}
alert(msg);
}
function escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const modal = $('#ai-modal');
const modalTitle = $('#ai-modal-title');
const modalBody = $('#ai-modal-body');
const modalActions = $('#ai-modal-actions');
$('#ai-modal-close').addEventListener('click', () => modal.close());
function openModal(title, bodyHtml, actions) {
modalTitle.textContent = title;
modalBody.innerHTML = bodyHtml;
modalActions.innerHTML = '';
(actions || []).forEach((a) => {
const b = document.createElement('button');
b.textContent = a.label;
b.className = a.className
|| 'px-3 py-2 rounded-lg bg-ink-100 hover:bg-ink-200 text-sm';
b.addEventListener('click', () => a.onClick(b));
modalActions.appendChild(b);
});
modal.showModal();
}
$('#ai-generate-by-title').addEventListener('click', async () => {
const title = titleEl.value.trim();
if (!title) {
alert('Сначала заполните название теста.');
titleEl.focus();
return;
}
const nQRaw = prompt('Сколько вопросов сгенерировать?', '10');
if (nQRaw == null) return;
const nQ = Math.max(3, Math.min(40, parseInt(nQRaw, 10) || 10));
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4');
if (nORaw == null) return;
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4));
aiStatusEl.textContent = 'Генерируем по названию…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: title,
testDescription: descEl.value,
questionsCount: nQ,
optionsCount: nO,
}),
});
const data = await r.json();
if (!r.ok) {
aiStatusEl.textContent = '';
return aiAlert(data);
}
const draft = data.draft;
const ok = confirm(
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` +
'Применить как черновик? Текущие вопросы будут заменены.',
);
if (!ok) {
aiStatusEl.textContent = '';
return;
}
if (draft.title) titleEl.value = draft.title;
if (draft.description) descEl.value = draft.description;
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
aiStatusEl.textContent = '';
aiAlert(null, e.message);
}
});
$('#ai-check').addEventListener('click', async () => {
const payload = collectPayload();
if (!payload.questions.length) {
alert('В тесте нет вопросов — нечего проверять.');
return;
}
aiStatusEl.textContent = 'Анализируем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questions: payload.questions,
}),
});
const data = await r.json();
aiStatusEl.textContent = '';
if (!r.ok) return aiAlert(data);
const rev = data.review || {};
const verdict = rev.verdict || 'warn';
const verdictMap = {
ok: ['Годен', 'bg-green-50 text-green-800 border-green-200'],
warn: ['Есть замечания', 'bg-yellow-50 text-yellow-800 border-yellow-200'],
bad: ['Серьёзные проблемы', 'bg-red-50 text-red-800 border-red-200'],
};
const [verdictText, verdictCls] = verdictMap[verdict] || verdictMap.warn;
let html = `<div class="rounded-lg border ${verdictCls} p-3 text-sm">
<div class="font-semibold">${verdictText}</div>
<div class="mt-1">${escHtml(rev.summary || '')}</div></div>`;
if (Array.isArray(rev.sections) && rev.sections.length) {
html += rev.sections.map((s) => `
<div class="mt-4">
<div class="font-semibold">${escHtml(s.title)}</div>
<ul class="mt-1 list-disc pl-5 text-sm space-y-1">
${s.items.map((it) => `<li>${escHtml(it)}</li>`).join('')}
</ul>
</div>`).join('');
} else {
html += '<p class="mt-4 text-sm text-ink-500">Замечаний нет.</p>';
}
openModal('Проверка теста', html, [
{ label: 'Закрыть', onClick: () => modal.close(),
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' },
]);
} catch (e) {
aiStatusEl.textContent = '';
aiAlert(null, e.message);
}
});
$('#ai-improve').addEventListener('click', async () => {
const payload = collectPayload();
if (!payload.questions.length) {
alert('В тесте нет вопросов — нечего улучшать.');
return;
}
aiStatusEl.textContent = 'Улучшаем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questions: payload.questions,
}),
});
const data = await r.json();
aiStatusEl.textContent = '';
if (!r.ok) return aiAlert(data);
const items = data.items || [];
if (!items.length) {
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [
{ label: 'Закрыть', onClick: () => modal.close() },
]);
return;
}
const changed = items.filter((i) => i.changed);
if (!changed.length) {
openModal('Улучшение теста', '<p>AI не предложил изменений.</p>', [
{ label: 'Закрыть', onClick: () => modal.close() },
]);
return;
}
let html = `<p class="text-sm text-ink-500 mb-3">
Отметьте вопросы, к которым нужно применить улучшения. ${changed.length} из ${items.length}.</p>`;
html += changed.map((it) => `
<div class="rounded-xl border border-ink-300/60 p-3 mb-3" data-idx="${it.index}">
<label class="inline-flex items-center gap-2 text-sm font-medium">
<input type="checkbox" class="apply-q rounded border-ink-300 text-brand-600 focus:ring-brand-500" checked />
<span>Вопрос #${it.index + 1}</span>
</label>
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<div class="text-xs uppercase text-ink-500">Было</div>
<div class="mt-1 ${it.textChanged ? 'line-through text-ink-500' : ''}">
${escHtml(it.original.text)}
</div>
<ul class="mt-1 list-disc pl-5">
${it.original.options.map((o) =>
`<li class="${it.optionsChanged ? 'text-ink-500' : ''}">
${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')}
</ul>
</div>
<div>
<div class="text-xs uppercase text-brand-700">Стало</div>
<div class="mt-1 ${it.textChanged ? 'font-medium' : ''}">
${escHtml(it.suggested.text)}
</div>
<ul class="mt-1 list-disc pl-5">
${it.suggested.options.map((o) =>
`<li>${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')}
</ul>
</div>
</div>
</div>`).join('');
openModal('Улучшение теста', html, [
{ label: 'Отмена', onClick: () => modal.close() },
{
label: 'Применить выбранное',
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm',
onClick: () => {
const qs = $$('#questions .q-item');
modalBody.querySelectorAll('[data-idx]').forEach((row) => {
if (!$('.apply-q', row).checked) return;
const idx = parseInt(row.dataset.idx, 10);
const it = items.find((x) => x.index === idx);
if (!it || !qs[idx]) return;
const node = qs[idx];
$('.q-text', node).value = it.suggested.text;
$('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers;
const optsEl = $('.q-options', node);
optsEl.innerHTML = '';
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
});
modal.close();
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
},
},
]);
} catch (e) {
aiStatusEl.textContent = '';
aiAlert(null, e.message);
}
});
async function aiGenerateQuestion(node) {
const qText = $('.q-text', node).value.trim();
const optsCount = Math.max(2, $$('.opt-item', node).length || 4);
const multi = $('.q-multi', node).checked;
aiStatusEl.textContent = 'AI: один вопрос…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questionText: qText,
optionsCount: optsCount,
hasMultipleAnswers: multi,
}),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
$('.q-text', node).value = data.text || '';
if (data.mode === 'full' && Array.isArray(data.options)) {
const optsEl = $('.q-options', node);
optsEl.innerHTML = '';
data.options.forEach((o) => optsEl.appendChild(renderOption(o)));
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
}
aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.';
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
aiStatusEl.textContent = '';
alert(e.message || 'AI: ошибка.');
}
}
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
fetch(`/api/tests/${TEST_ID}/summary`)
.then((r) => r.json())
.then((data) => {
if (data && data.test && typeof data.test.chainActive === 'boolean') {
chainActive = data.test.chainActive;
chainActiveEl.checked = chainActive;
} else {
chainActiveEl.checked = true;
chainActive = true;
}
})
.catch(() => {
chainActiveEl.checked = true;
});
loadInitial();
})();

9
flask_app/app/templates/404.html

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}404 — страница не найдена{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center">
<span class="material-symbols-outlined text-5xl text-brand-600">search_off</span>
<h1 class="mt-2 text-xl font-semibold">Страница не найдена</h1>
<p class="mt-1 text-ink-500">Проверьте адрес или вернитесь на <a class="text-brand-600 hover:underline" href="{{ url_for('main.index') }}">главную</a>.</p>
</section>
{% endblock %}

9
flask_app/app/templates/500.html

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}500 — внутренняя ошибка{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center">
<span class="material-symbols-outlined text-5xl text-red-600">error</span>
<h1 class="mt-2 text-xl font-semibold">Что-то пошло не так</h1>
<p class="mt-1 text-ink-500">Попробуйте обновить страницу. Если ошибка повторяется — посмотрите логи сервера.</p>
</section>
{% endblock %}

58
flask_app/app/templates/auth/login.html

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Вход — Тестирование{% endblock %}
{% block content %}
<section class="mx-auto max-w-md mt-8">
<div class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-600">login</span>
<h1 class="text-xl font-semibold">Вход в систему</h1>
</div>
<p class="mt-1 text-sm text-ink-500">
Используйте логин и пароль.
{% if hr_auth_enabled %}
Учётка кадровой системы (HR).
{% endif %}
</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mt-4 space-y-2">
{% for category, msg in messages %}
<div class="px-3 py-2 rounded-lg text-sm
{% if category == 'error' %}bg-red-50 text-red-700 border border-red-200
{% else %}bg-brand-50 text-brand-700 border border-brand-100{% endif %}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('auth.login_submit') }}" class="mt-5 space-y-4" novalidate>
<input type="hidden" name="next" value="{{ next or '/' }}">
<label class="block">
<span class="text-sm font-medium text-ink-700">Логин</span>
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<label class="block">
<span class="text-sm font-medium text-ink-700">Пароль</span>
<input type="password" name="password" required autocomplete="current-password"
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<button type="submit"
class="w-full inline-flex items-center justify-center gap-2 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white font-medium px-4 py-2 transition">
<span class="material-symbols-outlined text-base">login</span>
Войти
</button>
</form>
</div>
</section>
{% endblock %}

115
flask_app/app/templates/base.html

@ -0,0 +1,115 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{% block title %}Тестирование персонала{% endblock %}</title>
{# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #}
<script src="https://cdn.tailwindcss.com"></script>
<script>
// Минимальная палитра в стиле кабинета HR. Без зависимостей от HR-репо.
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Manrope', 'Inter', 'system-ui', 'sans-serif'],
},
colors: {
brand: {
50: '#eef2ff',
100: '#e0e7ff',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
},
ink: {
900: '#0f172a',
700: '#334155',
500: '#64748b',
300: '#cbd5e1',
100: '#f1f5f9',
},
},
},
},
};
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined"
/>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
{% block head %}{% endblock %}
</head>
<body class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased">
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/60">
<div class="mx-auto max-w-6xl px-4 h-14 flex items-center justify-between">
<a href="{{ url_for('main.index') }}" class="flex items-center gap-2 font-semibold text-ink-900">
<span class="material-symbols-outlined text-brand-600">quiz</span>
<span>Тестирование</span>
</a>
<nav class="flex items-center gap-1 sm:gap-2 text-sm">
{% if current_user %}
<a href="{{ url_for('tests.tests_list_page') }}"
class="inline-flex items-center justify-center gap-1
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
text-ink-700 hover:bg-ink-100"
title="Каталог тестов" aria-label="Каталог тестов">
<span class="material-symbols-outlined text-base">list_alt</span>
<span class="hidden sm:inline">Тесты</span>
</a>
<a href="{{ url_for('settings.settings_page') }}"
class="inline-flex items-center justify-center gap-1
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
text-ink-700 hover:bg-ink-100"
title="Настройки" aria-label="Настройки">
<span class="material-symbols-outlined text-base">settings</span>
<span class="hidden sm:inline">Настройки</span>
</a>
<span class="hidden md:inline text-ink-500">
{{ current_user.full_name or current_user.login }}
<span class="text-ink-300">·</span>
<span class="text-brand-700">{{ current_user.role }}</span>
</span>
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
<button type="submit"
class="inline-flex items-center justify-center gap-1
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
text-ink-700 hover:bg-ink-100 transition"
title="Выйти" aria-label="Выйти">
<span class="material-symbols-outlined text-base">logout</span>
<span class="hidden sm:inline">Выйти</span>
</button>
</form>
{% else %}
<a href="{{ url_for('auth.login_page') }}"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
text-brand-700 hover:bg-brand-50 transition min-h-10">
<span class="material-symbols-outlined text-base">login</span>
Войти
</a>
{% endif %}
</nav>
</div>
</header>
<main class="mx-auto max-w-6xl px-4 py-6">
{% block content %}{% endblock %}
</main>
<footer class="mx-auto max-w-6xl px-4 py-8 text-xs text-ink-500">
{% block footer %}testing-flask-app · Этап 1{% endblock %}
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

54
flask_app/app/templates/index.html

@ -1,9 +1,45 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="ru"> {% block title %}Тестирование — главная{% endblock %}
<head>
<meta charset="UTF-8" /> {% block content %}
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
<title>Тестирование</title> <h1 class="text-2xl font-semibold text-ink-900">Сервис тестирования персонала</h1>
</head> <p class="mt-2 text-ink-500">
<body></body> Этап 1 миграции: переход на единый стек (Flask + Jinja). Бизнес-функции
</html> переносятся последовательно — авторизация, каталог тестов, редактор,
назначения, прохождение, импорт/AI.
</p>
<div class="mt-5 flex flex-wrap gap-2 text-sm">
<a href="{{ url_for('tests.tests_list_page') }}"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white transition">
<span class="material-symbols-outlined text-base">list_alt</span>
Каталог тестов
</a>
<a href="{{ url_for('main.health') }}"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-50 text-brand-700 hover:bg-brand-100 transition">
<span class="material-symbols-outlined text-base">monitoring</span>
Health-check
</a>
</div>
</section>
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mt-4">
{% for title, descr, icon in [
('Авторизация', 'E1.1 — логин по HR/локальному пользователю.', 'login'),
('Тесты', 'E1.2 — каталог, фильтры, карточки.', 'list_alt'),
('Редактор', 'E1.3 — создание/правка теста, AI-помощник.', 'edit_note'),
('Назначения', 'E1.4 — назначить сотрудникам, отслеживать.', 'assignment'),
('Прохождение', 'E1.5 — UI прохождения теста сотрудником.', 'fact_check'),
('Импорт/AI', 'E1.6 — генерация черновиков из документов.', 'auto_awesome'),
] %}
<article class="rounded-xl bg-white border border-ink-300/60 p-4">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-600">{{ icon }}</span>
<h3 class="font-semibold">{{ title }}</h3>
</div>
<p class="mt-1 text-sm text-ink-500">{{ descr }}</p>
</article>
{% endfor %}
</section>
{% endblock %}

101
flask_app/app/templates/settings.html

@ -0,0 +1,101 @@
{% extends "base.html" %}
{% block title %}Настройки — LLM{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6 max-w-2xl">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-600">settings</span>
<h1 class="text-2xl font-semibold">Настройки</h1>
</div>
<h2 class="mt-5 font-semibold">Подключение к LLM</h2>
<p class="mt-1 text-sm text-ink-500">
Ключ задаётся в <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> сервера
(общий, не на пользователя). Поддерживаются DeepSeek и OpenAI-совместимые API.
После изменения <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> нужен рестарт процесса.
</p>
<dl class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<dt class="text-ink-500">Статус ключа</dt>
<dd>
{% if configured %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-green-50 text-green-700 border border-green-200">
<span class="material-symbols-outlined text-base">check_circle</span> Задан
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-50 text-red-700 border border-red-200">
<span class="material-symbols-outlined text-base">error</span> Не задан
</span>
{% endif %}
</dd>
<dt class="text-ink-500">Провайдер</dt>
<dd>{{ provider or '—' }}</dd>
<dt class="text-ink-500">Модель</dt>
<dd>{{ model or '—' }}</dd>
<dt class="text-ink-500">Base URL</dt>
<dd class="break-all">{{ base_url or '—' }}</dd>
</dl>
{% if not configured %}
<div class="mt-5 rounded-lg bg-ink-100/60 border border-ink-300/60 p-4 text-sm">
<p class="font-medium">Как задать ключ</p>
<pre class="mt-2 text-xs whitespace-pre-wrap font-mono">DEEPSEEK_API_KEY=sk-...
# либо
OPENAI_API_KEY=sk-...
# опционально:
# LLM_BASE_URL=https://api.deepseek.com/v1
# LLM_MODEL=deepseek-chat</pre>
<p class="mt-2 text-ink-500">
Файл: <code>flask_app/.env</code>. После сохранения — рестарт процесса.
</p>
</div>
{% endif %}
<div class="mt-5 flex items-center gap-3">
<button id="btn-ping"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white text-sm">
<span class="material-symbols-outlined text-base">cable</span>
Проверить подключение
</button>
<span id="ping-status" class="text-sm text-ink-500"></span>
</div>
<div id="ping-result" class="mt-4 hidden text-sm rounded-lg p-3 border"></div>
</section>
{% endblock %}
{% block scripts %}
<script>
(() => {
const btn = document.getElementById('btn-ping');
const status = document.getElementById('ping-status');
const result = document.getElementById('ping-result');
btn.addEventListener('click', async () => {
status.textContent = 'Запрос…';
btn.disabled = true;
try {
const r = await fetch('/api/llm/ping', { method: 'POST' });
const d = await r.json();
result.classList.remove('hidden', 'bg-green-50', 'border-green-200', 'text-green-800',
'bg-red-50', 'border-red-200', 'text-red-800');
if (d.ok) {
result.classList.add('bg-green-50', 'border-green-200', 'text-green-800');
result.innerHTML = `<b>OK</b> · ${d.provider} / ${d.model} · ${d.latencyMs} мс`
+ (d.sample ? `<br><span class="text-xs opacity-80">Ответ: ${d.sample.replace(/</g,'&lt;')}</span>` : '');
} else {
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800');
result.innerHTML = `<b>Ошибка</b> · ${d.code || ''}<br>${(d.error || '').replace(/</g,'&lt;')}`;
}
} catch (e) {
result.classList.remove('hidden');
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800');
result.textContent = e.message || 'Сбой запроса.';
} finally {
btn.disabled = false;
status.textContent = '';
}
});
})();
</script>
{% endblock %}

240
flask_app/app/templates/tests/editor.html

@ -0,0 +1,240 @@
{% extends "base.html" %}
{% block title %}{{ content.test.title }} — редактор{% endblock %}
{% block content %}
<div id="editor-root"
class="space-y-4 sm:space-y-5 pb-24"
data-test-id="{{ test_id }}"
data-initial='{{ content | tojson | safe }}'>
{# ── 1. Шапка теста ─────────────────────────────────────────── #}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-5">
<h2 class="text-xs font-medium text-ink-500 uppercase tracking-wide">Тест</h2>
<label class="mt-2 block">
<span class="sr-only">Название</span>
<input id="test-title" type="text" maxlength="200" placeholder="Название теста"
class="w-full rounded-lg border border-ink-300 px-3 py-3 text-lg font-semibold
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<label class="mt-3 block">
<span class="text-xs font-medium text-ink-500">Описание</span>
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea>
</label>
<label class="mt-3 flex items-center justify-between gap-3">
<span class="text-xs font-medium text-ink-500">Проходной балл, %</span>
<input id="test-threshold" type="number" min="0" max="100" step="1"
inputmode="numeric"
class="w-24 text-right rounded-lg border border-ink-300 px-3 py-2
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
</section>
{# ── 2. AI-помощник ─────────────────────────────────────────── #}
<section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-600">auto_awesome</span>
<h2 class="font-semibold text-brand-700">AI-помощник</h2>
</div>
{# Группа A — генерация. Главные действия. На sm+ — в одну строку. #}
<div class="mt-3">
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Создать вопросы</p>
<div class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
<button id="ai-generate-by-title"
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium min-h-11">
<span class="material-symbols-outlined text-base">edit_note</span>
По названию
</button>
<button id="ai-generate-test"
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
bg-white border border-brand-300/60 text-brand-700 hover:bg-brand-50
text-sm font-medium min-h-11">
<span class="material-symbols-outlined text-base">stars</span>
По текущей сетке
</button>
</div>
</div>
{# Группа B — анализ существующего. #}
<div class="mt-4">
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Улучшить существующее</p>
<div class="mt-2 grid grid-cols-2 gap-2">
<button id="ai-check"
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300
text-sm min-h-11">
<span class="material-symbols-outlined text-base">fact_check</span>
Проверить
</button>
<button id="ai-improve"
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300
text-sm min-h-11">
<span class="material-symbols-outlined text-base">tune</span>
Улучшить
</button>
</div>
</div>
{# Группа C — импорт. #}
<div class="mt-4">
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Импортировать</p>
<label class="mt-2 inline-flex w-full items-center justify-center gap-2 px-3 py-3
rounded-lg bg-white border border-ink-300/60 hover:border-brand-300
text-sm cursor-pointer min-h-11">
<span class="material-symbols-outlined text-base text-brand-600">upload_file</span>
<span>Загрузить документ (PDF, DOCX, TXT, MD)</span>
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" />
</label>
<p class="mt-1.5 text-xs text-ink-500">
До 16 МБ. AI извлечёт текст и предложит черновик теста.
</p>
</div>
<p id="ai-status" class="mt-3 text-sm text-ink-500 min-h-[1.25rem]"></p>
</section>
{# ── 3. Вопросы ─────────────────────────────────────────────── #}
<section>
<div class="flex items-center justify-between gap-2 px-1">
<h2 class="font-semibold">Вопросы (<span id="q-count">0</span>)</h2>
<button id="add-question"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10">
<span class="material-symbols-outlined text-base">add</span>
<span class="hidden sm:inline">Добавить вопрос</span>
<span class="sm:hidden">Добавить</span>
</button>
</div>
<ol id="questions" class="mt-3 space-y-3"></ol>
</section>
</div>
{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #}
<div class="fixed bottom-0 inset-x-0 z-30 bg-white/95 backdrop-blur border-t border-ink-300/60
pb-[env(safe-area-inset-bottom)]">
<div class="mx-auto max-w-6xl px-4 py-3
flex items-center justify-between gap-3">
<label class="inline-flex items-center gap-2 text-sm min-w-0">
<input id="chain-active" type="checkbox"
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
<span class="truncate">Цепочка активна</span>
</label>
<div class="flex items-center gap-2 shrink-0">
<a href="{{ url_for('tests.tests_list_page') }}"
class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm">
К каталогу
</a>
<button id="save-draft"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11">
<span class="material-symbols-outlined text-base">save</span>
Сохранить
</button>
</div>
</div>
<p id="save-status" class="mx-auto max-w-6xl px-4 pb-2 text-xs text-ink-500"></p>
</div>
{# ── Шаблон вопроса ─────────────────────────────────────────────── #}
<template id="tpl-question">
<li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item">
{# Шапка карточки вопроса: номер слева, кнопки справа. #}
<div class="flex items-center justify-between gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded-md
bg-brand-50 text-brand-700 text-xs font-medium q-num">Вопрос #</span>
<div class="flex items-center gap-0.5">
<button class="q-up p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
title="Выше" aria-label="Поднять выше">
<span class="material-symbols-outlined text-base">arrow_upward</span>
</button>
<button class="q-down p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
title="Ниже" aria-label="Опустить ниже">
<span class="material-symbols-outlined text-base">arrow_downward</span>
</button>
<button class="q-delete p-2 rounded hover:bg-red-50 text-red-600 min-w-10 min-h-10"
title="Удалить" aria-label="Удалить вопрос">
<span class="material-symbols-outlined text-base">delete</span>
</button>
</div>
</div>
<textarea class="q-text mt-3 w-full rounded-lg border border-ink-300 px-3 py-2
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
rows="2" placeholder="Формулировка вопроса"></textarea>
{# Тип ответа + AI — две полные строки на мобиле, в строку на sm+. #}
<div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm">
<label class="inline-flex items-center gap-2 min-h-9">
<input type="checkbox"
class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
<span>Несколько правильных ответов</span>
</label>
<button class="q-ai inline-flex items-center justify-center gap-1 px-2.5 py-2 rounded-lg
bg-brand-50 hover:bg-brand-100 text-brand-700 text-sm min-h-10">
<span class="material-symbols-outlined text-base">auto_awesome</span>
AI: вопрос/переформулировать
</button>
</div>
<ul class="q-options mt-3 space-y-2"></ul>
<button class="q-add-option mt-2 inline-flex items-center gap-1 px-2 py-2 rounded
text-sm text-brand-700 hover:bg-brand-50 min-h-10">
<span class="material-symbols-outlined text-base">add</span>
Добавить вариант
</button>
</li>
</template>
{# ── Шаблон варианта ────────────────────────────────────────────── #}
<template id="tpl-option">
<li class="flex items-center gap-2 opt-item">
{# Чекбокс «Правильный» — обёрнут в большой tap-target. #}
<label class="inline-flex items-center justify-center w-10 h-10 shrink-0 cursor-pointer
rounded hover:bg-ink-100" title="Правильный ответ">
<input type="checkbox"
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
</label>
<input type="text"
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
placeholder="Вариант ответа" />
<button class="opt-delete shrink-0 w-10 h-10 inline-flex items-center justify-center
rounded hover:bg-red-50 text-red-600"
title="Удалить" aria-label="Удалить вариант">
<span class="material-symbols-outlined text-base">close</span>
</button>
</li>
</template>
{# ── Модалка результата AI-проверки/улучшения (fullscreen на мобиле) ── #}
<dialog id="ai-modal"
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-3xl sm:w-full sm:max-h-[90vh]
sm:rounded-2xl sm:m-auto bg-white backdrop:bg-black/50">
<div class="flex flex-col h-full sm:max-h-[90vh]">
<div class="flex items-center justify-between gap-3 px-4 sm:px-5 py-3 border-b border-ink-300/60">
<h3 id="ai-modal-title" class="text-lg font-semibold truncate">AI</h3>
<button id="ai-modal-close"
class="p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
aria-label="Закрыть">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div id="ai-modal-body" class="flex-1 overflow-y-auto px-4 sm:px-5 py-4"></div>
<div id="ai-modal-actions"
class="px-4 sm:px-5 py-3 border-t border-ink-300/60
flex items-center justify-end gap-2 flex-wrap
pb-[max(env(safe-area-inset-bottom),0.75rem)]"></div>
</div>
</dialog>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/editor.js') }}"></script>
{% endblock %}

141
flask_app/app/templates/tests/list.html

@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block title %}Тесты — каталог{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1>
<p class="mt-1 text-sm text-ink-500">Активные тесты, к которым у вас есть доступ.</p>
</div>
<button id="btn-create-test"
class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white font-medium transition
min-h-11 w-full sm:w-auto">
<span class="material-symbols-outlined text-base">add</span>
Создать тест
</button>
</div>
{% if visible %}
<ul class="mt-5 grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{% for t in visible %}
<li class="rounded-xl border border-ink-300/60 hover:border-brand-300 hover:shadow-sm transition bg-white">
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
class="block p-4 active:bg-ink-100/40">
<div class="flex items-start justify-between gap-2">
<h3 class="font-semibold text-ink-900 line-clamp-2 min-w-0">{{ t.title }}</h3>
<span class="text-xs text-ink-500 shrink-0 mt-0.5">v{{ t.version }}</span>
</div>
{% if t.description %}
<p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p>
{% endif %}
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500">
<span class="truncate">Автор: {{ t.author_full_name or '—' }}</span>
<span class="inline-flex items-center gap-1 text-brand-700">
<span class="material-symbols-outlined text-sm">edit_note</span>
Открыть
</span>
</div>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-5 text-ink-500 text-sm">Доступных тестов пока нет.</p>
{% endif %}
{% if hidden %}
<details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4">
<summary class="cursor-pointer font-medium text-ink-700">
Скрытые вами цепочки ({{ hidden|length }})
</summary>
<ul class="mt-3 space-y-2">
{% for t in hidden %}
<li class="flex items-center justify-between gap-2 text-sm">
<span>{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span></span>
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
class="text-brand-700 hover:underline">Открыть</a>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</section>
<dialog id="dlg-create"
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-md sm:w-full sm:m-auto
sm:rounded-2xl bg-white backdrop:bg-ink-900/50">
<form method="dialog" class="flex flex-col h-full sm:h-auto bg-white sm:rounded-2xl">
<div class="px-4 sm:px-5 py-3 border-b border-ink-300/60 flex items-center justify-between">
<h2 class="text-lg font-semibold">Новый тест</h2>
<button type="button" id="dlg-cancel-x"
class="p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
aria-label="Закрыть">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="flex-1 overflow-y-auto px-4 sm:px-5 py-4 space-y-3">
<label class="block">
<span class="text-sm font-medium text-ink-700">Название</span>
<input id="new-test-title" type="text" required maxlength="200"
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-3
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<label class="block">
<span class="text-sm font-medium text-ink-700">Описание (опц.)</span>
<textarea id="new-test-desc" rows="3"
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea>
</label>
</div>
<div class="px-4 sm:px-5 py-3 border-t border-ink-300/60 flex justify-end gap-2
bg-ink-100/40 sm:rounded-b-2xl
pb-[max(env(safe-area-inset-bottom),0.75rem)]">
<button type="button" id="dlg-cancel"
class="px-4 py-2.5 rounded-lg text-ink-700 hover:bg-ink-100 min-h-11">Отмена</button>
<button type="button" id="dlg-submit"
class="px-4 py-2.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white min-h-11">
Создать
</button>
</div>
</form>
</dialog>
{% endblock %}
{% block scripts %}
<script>
(() => {
const dlg = document.getElementById('dlg-create');
const titleEl = document.getElementById('new-test-title');
const descEl = document.getElementById('new-test-desc');
document.getElementById('btn-create-test').addEventListener('click', () => {
titleEl.value = '';
descEl.value = '';
if (typeof dlg.showModal === 'function') dlg.showModal();
else dlg.setAttribute('open', 'open');
setTimeout(() => titleEl.focus(), 50);
});
document.getElementById('dlg-cancel').addEventListener('click', () => dlg.close());
document.getElementById('dlg-cancel-x').addEventListener('click', () => dlg.close());
document.getElementById('dlg-submit').addEventListener('click', async () => {
const title = titleEl.value.trim();
if (!title) { titleEl.focus(); return; }
try {
const r = await fetch('/api/tests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: descEl.value.trim() || null }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось создать тест.');
window.location.href = `/tests/${data.testId}/edit`;
} catch (e) {
alert(e.message || 'Не удалось создать тест.');
}
});
})();
</script>
{% endblock %}

2
flask_app/app/tests/__init__.py

@ -0,0 +1,2 @@
"""Blueprint `tests`: JSON API (`/api/tests/*`) и UI (`/tests`, `/tests/<id>/edit`)."""
from .routes import tests_bp # noqa: F401

465
flask_app/app/tests/routes.py

@ -0,0 +1,465 @@
"""Маршруты тестов (E1.2).
Покрытие Express Flask:
- GET /api/tests/ каталог + hidden by you
- POST /api/tests/ создать тест (цепочку с версией 1)
- GET /api/tests/<id>/summary краткая карточка
- GET /api/tests/<id>/versions список версий + hasAttempts
- GET /api/tests/<id>/editor контент редактора
- POST /api/tests/<id>/draft saveTestDraft (fork если нужно)
- POST /api/tests/<id>/versions/<vid>/activate
- PATCH /api/tests/<id> chainActive
- POST /api/tests/<id>/ai/generate-test
- POST /api/tests/<id>/ai/generate-question
UI-страницы:
- GET /tests каталог
- GET /tests/<id>/edit редактор (вызывает /api/tests/...)
"""
from __future__ import annotations
import logging
from flask import Blueprint, jsonify, render_template, request
from sqlalchemy import text
from ..auth.decorators import current_user, login_required
from ..db import get_engine
from ..messages import RU
from ..services.ai_editor import (
HttpError as AiHttpError,
check_test_quality,
generate_full_test_by_shape,
generate_or_rephrase_question,
generate_test_by_title,
improve_test_full,
parse_and_validate_shape,
)
from ..services.document_extract import (
HttpError as DocExtractHttpError,
extract_text_from_file,
)
from ..services.document_gen import generation_for_import_document
from ..services.draft_validator import LlmError
from ..services.editor_content import HttpError as EditorHttpError, get_editor_content
from ..services.test_access import is_test_author, list_hidden_by_author, list_visible_tests
from ..services.test_chain import has_any_attempt_for_test
from ..services.test_draft import (
HttpError as DraftHttpError,
create_test_with_version,
save_test_draft,
)
log = logging.getLogger(__name__)
tests_bp = Blueprint('tests', __name__)
# ─── helpers ─────────────────────────────────────────────────────────
def _stringify_uuids(d: dict) -> dict:
"""Преобразует UUID-поля в строки для безопасной JSON-сериализации."""
out = {}
for k, v in d.items():
if hasattr(v, 'hex') and not isinstance(v, (str, bytes)):
out[k] = str(v)
else:
out[k] = v
return out
def _check_test_author_or_404(test_id: str, user_id: str) -> dict:
"""Загружает {id, created_by}; 404 если нет, 403 если не автор."""
eng = get_engine()
with eng.connect() as conn:
row = conn.execute(
text('SELECT id, created_by FROM tests WHERE id = :id'),
{'id': test_id},
).mappings().first()
if not row:
from werkzeug.exceptions import NotFound
raise NotFound(RU['notFound'])
if not is_test_author(row['created_by'], user_id):
from werkzeug.exceptions import Forbidden
raise Forbidden('Доступ запрещён.')
return dict(row)
# ─── JSON API ────────────────────────────────────────────────────────
@tests_bp.route('/api/tests/', methods=['GET'])
@tests_bp.route('/api/tests', methods=['GET'])
@login_required
def api_list_tests():
user = current_user()
visible = list_visible_tests(user.id)
hidden = list_hidden_by_author(user.id)
return jsonify(
tests=[_stringify_uuids(r) for r in visible],
hiddenByYou=[_stringify_uuids(r) for r in hidden],
)
@tests_bp.route('/api/tests/', methods=['POST'])
@tests_bp.route('/api/tests', methods=['POST'])
@login_required
def api_create_test():
user = current_user()
body = request.get_json(silent=True) or {}
title = body.get('title')
if not isinstance(title, str) or not title.strip():
return jsonify(error='Укажите название.'), 400
out = create_test_with_version(user.id, title=title.strip(), description=body.get('description'))
return jsonify(out), 201
@tests_bp.route('/api/tests/<test_id>/summary', methods=['GET'])
@login_required
def api_test_summary(test_id):
user = current_user()
eng = get_engine()
with eng.connect() as conn:
row = conn.execute(
text(
"""
SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active,
t.created_by, t.created_at, t.updated_at,
tv.id AS active_version_id, tv.version,
u.full_name AS author_full_name
FROM tests t
LEFT JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
LEFT JOIN users u ON u.id = t.created_by
WHERE t.id = :id
"""
),
{'id': test_id},
).mappings().first()
if not row:
return jsonify(error=RU['notFound']), 404
is_author = is_test_author(row['created_by'], user.id)
if not row['chain_active'] and not is_author:
return jsonify(error=RU['notFound']), 404
if not is_author:
from ..services.test_access import user_has_test_access
acc = user_has_test_access(user.id, test_id)
if not acc.ok:
return jsonify(error=RU['notFound']), 404
return jsonify(
test={
'id': str(row['id']),
'title': row['title'],
'description': row['description'],
'passingThreshold': row['passing_threshold'],
'chainActive': row['chain_active'],
'activeVersionId': str(row['active_version_id']) if row['active_version_id'] else None,
'version': row['version'],
'createdAt': row['created_at'].isoformat() if row['created_at'] else None,
'updatedAt': row['updated_at'].isoformat() if row['updated_at'] else None,
'createdBy': str(row['created_by']) if row['created_by'] else None,
'authorFullName': row['author_full_name'],
},
isAuthor=is_author,
hasActiveVersion=row['active_version_id'] is not None,
)
@tests_bp.route('/api/tests/<test_id>/versions', methods=['GET'])
@login_required
def api_test_versions(test_id):
user = current_user()
eng = get_engine()
with eng.connect() as conn:
t = conn.execute(
text(
"""
SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at,
t.description, u.full_name AS author_full_name
FROM tests t
INNER JOIN users u ON u.id = t.created_by
WHERE t.id = :id
"""
),
{'id': test_id},
).mappings().first()
if not t:
return jsonify(error=RU['notFound']), 404
if not is_test_author(t['created_by'], user.id):
return jsonify(error='Доступ запрещён.'), 403
rows = conn.execute(
text(
'SELECT id, version, is_active, parent_id, created_at '
'FROM test_versions WHERE test_id = :id ORDER BY version'
),
{'id': test_id},
).mappings().all()
has_attempts = has_any_attempt_for_test(conn, test_id)
return jsonify(
test={
'id': str(t['id']),
'title': t['title'],
'description': t['description'],
'chainActive': t['is_active'],
'createdAt': t['created_at'].isoformat() if t['created_at'] else None,
'updatedAt': t['updated_at'].isoformat() if t['updated_at'] else None,
'createdBy': str(t['created_by']) if t['created_by'] else None,
'authorFullName': t['author_full_name'],
},
versions=[
{
'id': str(r['id']),
'version': r['version'],
'is_active': r['is_active'],
'parent_id': str(r['parent_id']) if r['parent_id'] else None,
'created_at': r['created_at'].isoformat() if r['created_at'] else None,
}
for r in rows
],
hasAttempts=has_attempts,
)
@tests_bp.route('/api/tests/<test_id>/editor', methods=['GET'])
@login_required
def api_test_editor(test_id):
user = current_user()
try:
out = get_editor_content(user.id, test_id)
except EditorHttpError as e:
return jsonify(error=e.message), e.status
return jsonify(out)
@tests_bp.route('/api/tests/<test_id>/draft', methods=['POST'])
@login_required
def api_save_draft(test_id):
user = current_user()
payload = request.get_json(silent=True) or {}
try:
out = save_test_draft(user.id, test_id, payload)
except DraftHttpError as e:
return jsonify(error=e.message), e.status
return jsonify(out)
@tests_bp.route('/api/tests/<test_id>/versions/<version_id>/activate', methods=['POST'])
@login_required
def api_activate_version(test_id, version_id):
user = current_user()
_check_test_author_or_404(test_id, user.id)
eng = get_engine()
with eng.begin() as conn:
v = conn.execute(
text('SELECT id FROM test_versions WHERE test_id = :t AND id = :v'),
{'t': test_id, 'v': version_id},
).first()
if not v:
return jsonify(error='Версия не найдена.'), 404
conn.execute(
text('UPDATE test_versions SET is_active = false WHERE test_id = :t'),
{'t': test_id},
)
conn.execute(
text('UPDATE test_versions SET is_active = true WHERE id = :v'),
{'v': version_id},
)
return jsonify(ok=True, activeVersionId=str(version_id))
@tests_bp.route('/api/tests/<test_id>', methods=['PATCH'])
@login_required
def api_patch_test(test_id):
user = current_user()
body = request.get_json(silent=True) or {}
chain = body.get('chainActive', body.get('isActive'))
if not isinstance(chain, bool):
return jsonify(error='Передайте chainActive: true/false в теле запроса.'), 400
_check_test_author_or_404(test_id, user.id)
eng = get_engine()
with eng.begin() as conn:
conn.execute(
text(
'UPDATE tests SET is_active = :v, updated_at = CURRENT_TIMESTAMP WHERE id = :id'
),
{'v': chain, 'id': test_id},
)
return jsonify(id=test_id, chainActive=chain)
# ─── AI ──────────────────────────────────────────────────────────────
@tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST'])
@login_required
def api_ai_generate_test(test_id):
user = current_user()
_check_test_author_or_404(test_id, user.id)
body = request.get_json(silent=True) or {}
try:
shape = parse_and_validate_shape(body.get('shape'))
draft = generate_full_test_by_shape(
body.get('testTitle') or '',
body.get('testDescription') or '',
shape,
)
except (AiHttpError, LlmError) as e:
return _ai_error_response(e)
return jsonify(ok=True, draft=draft)
@tests_bp.route('/api/tests/<test_id>/ai/generate-question', methods=['POST'])
@login_required
def api_ai_generate_question(test_id):
user = current_user()
_check_test_author_or_404(test_id, user.id)
body = request.get_json(silent=True) or {}
try:
out = generate_or_rephrase_question(
body.get('testTitle') or '',
body.get('testDescription') or '',
body.get('questionText') or '',
body.get('optionsCount'),
bool(body.get('hasMultipleAnswers')),
)
except (AiHttpError, LlmError) as e:
return _ai_error_response(e)
return jsonify(ok=True, **out)
# ─── AI v2 (E1.8) ────────────────────────────────────────────────────
def _ai_error_response(e):
"""Единый JSON-формат ошибки для AI-эндпоинтов."""
if isinstance(e, AiHttpError):
return jsonify(error=e.message), e.status
if isinstance(e, LlmError):
log.warning('LLM error: %s (%s)', e, e.code)
return (
jsonify(error=str(e), code=e.code, settingsUrl='/settings'),
e.status or 502,
)
raise e
@tests_bp.route('/api/tests/<test_id>/ai/generate-by-title', methods=['POST'])
@login_required
def api_ai_generate_by_title(test_id):
user = current_user()
_check_test_author_or_404(test_id, user.id)
body = request.get_json(silent=True) or {}
title = (body.get('testTitle') or '').strip()
if not title:
return jsonify(error='Заполните название теста.'), 400
try:
draft = generate_test_by_title(
title,
body.get('testDescription') or '',
int(body.get('questionsCount') or 10),
int(body.get('optionsCount') or 4),
bool(body.get('hasMultipleAnswers')),
)
except (AiHttpError, LlmError) as e:
return _ai_error_response(e)
return jsonify(ok=True, draft=draft)
@tests_bp.route('/api/tests/<test_id>/ai/check', methods=['POST'])
@login_required
def api_ai_check_test(test_id):
user = current_user()
_check_test_author_or_404(test_id, user.id)
body = request.get_json(silent=True) or {}
try:
review = check_test_quality(
body.get('testTitle') or '',
body.get('testDescription') or '',
body.get('questions') or [],
)
except (AiHttpError, LlmError) as e:
return _ai_error_response(e)
return jsonify(ok=True, review=review)
@tests_bp.route('/api/tests/<test_id>/ai/improve', methods=['POST'])
@login_required
def api_ai_improve_test(test_id):
user = current_user()
_check_test_author_or_404(test_id, user.id)
body = request.get_json(silent=True) or {}
try:
out = improve_test_full(
body.get('testTitle') or '',
body.get('testDescription') or '',
body.get('questions') or [],
)
except (AiHttpError, LlmError) as e:
return _ai_error_response(e)
return jsonify(ok=True, **out)
# ─── Импорт документа (E1.3) ────────────────────────────────────────
@tests_bp.route('/api/tests/import/document', methods=['POST'])
@login_required
def api_import_document():
"""PDF/DOCX/TXT/MD → извлечённый текст + AI-черновик (если задан LLM-ключ).
Ограничения: размер файла `MAX_CONTENT_LENGTH = 16 МБ` (см. фабрику).
"""
f = request.files.get('file')
if f is None or not f.filename:
return jsonify(error='Прикрепите файл к полю file.'), 400
try:
extracted = extract_text_from_file(f.mimetype, f, f.filename)
except DocExtractHttpError as e:
return jsonify(error=e.message), e.status
except Exception:
log.exception('extract_text_from_file failed')
return jsonify(error='Не удалось разобрать файл.'), 500
generation = generation_for_import_document(extracted)
return jsonify(
received=True,
originalName=f.filename,
mime=f.mimetype,
size=len(extracted.encode('utf-8')),
extractedText=extracted,
textLength=len(extracted),
generation=generation,
)
# ─── UI (Jinja) ──────────────────────────────────────────────────────
@tests_bp.route('/tests', methods=['GET'])
@login_required
def tests_list_page():
user = current_user()
visible = list_visible_tests(user.id)
hidden = list_hidden_by_author(user.id)
return render_template(
'tests/list.html',
visible=[_stringify_uuids(r) for r in visible],
hidden=[_stringify_uuids(r) for r in hidden],
)
@tests_bp.route('/tests/<test_id>/edit', methods=['GET'])
@login_required
def tests_editor_page(test_id):
user = current_user()
try:
content = get_editor_content(user.id, test_id)
except EditorHttpError as e:
if e.status == 404:
return render_template('404.html'), 404
if e.status == 403:
return ('Доступ запрещён.', 403)
return render_template('500.html'), 500
return render_template('tests/editor.html', content=content, test_id=test_id)

14
flask_app/requirements.txt

@ -1,3 +1,17 @@
Flask>=3.0.0,<4 Flask>=3.0.0,<4
python-dotenv>=1.0.0 python-dotenv>=1.0.0
waitress>=3.0.0 waitress>=3.0.0
# Этап 1 (E1.0): тот же стек, что в HR_TG_Bot/tgFlaskForm.
# SQLAlchemy + psycopg2 драйвер для PostgreSQL.
SQLAlchemy>=2.0.0,<3
psycopg2-binary>=2.9.0,<3
# Этап 1 (E1.1): авторизация. bcrypt — для локальных хешей в clinic_tests.users.
# Werkzeug-хеши (scrypt/pbkdf2) проверяет встроенный werkzeug.security.
bcrypt>=4.0.0,<5
# Этап 1 (E1.3): импорт документов (PDF/DOCX) → AI-черновик.
pypdf>=4.0.0,<6
python-docx>=1.1.0,<2

933
frontend/src/pages/TestDetail.jsx

File diff suppressed because it is too large Load Diff

12
frontend/src/pages/TestsList.jsx

@ -106,11 +106,7 @@ export default function TestsList() {
<span className="list-row__title">{t.title}</span> <span className="list-row__title">{t.title}</span>
<span className="list-row__meta"> <span className="list-row__meta">
{formatTestAuthorLabel(user, t.created_by, t.author_full_name)} {formatTestAuthorLabel(user, t.created_by, t.author_full_name)}
<span className="list-row__meta-sep" aria-hidden> <span className="list-row__meta-tail">{' · '}v{t.version}</span>
{' '}
·{' '}
</span>
v{t.version}
</span> </span>
</Link> </Link>
</div> </div>
@ -151,11 +147,9 @@ export default function TestsList() {
<span className="list-row__title">{t.title}</span> <span className="list-row__title">{t.title}</span>
<span className="list-row__meta"> <span className="list-row__meta">
{formatTestAuthorLabel(user, t.created_by, t.author_full_name)} {formatTestAuthorLabel(user, t.created_by, t.author_full_name)}
<span className="list-row__meta-sep" aria-hidden> <span className="list-row__meta-tail">
{' '} {' · '}v{t.version} · скрыт
·{' '}
</span> </span>
v{t.version} · скрыт
</span> </span>
</Link> </Link>
</div> </div>

599
frontend/src/styles/cabinet-theme.css

@ -248,7 +248,7 @@ code,
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
color: var(--primary); color: var(--primary);
border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); border-color: color-mix(in srgb, var(--outline-variant) 70%, transparent);
} }
.btn-ghost:hover { .btn-ghost:hover {
@ -264,8 +264,11 @@ code,
.btn--sm { .btn--sm {
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.35rem 0.6rem; padding: 0.5rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
min-height: 2.75rem;
min-width: 2.75rem;
box-sizing: border-box;
} }
/* --- App shell (cabinet/base) --- */ /* --- App shell (cabinet/base) --- */
@ -297,7 +300,10 @@ code,
.cabinet-header__inner { .cabinet-header__inner {
max-width: var(--max-content); max-width: var(--max-content);
margin: 0 auto; margin: 0 auto;
padding: 0.75rem 1.25rem; padding-top: max(0.75rem, env(safe-area-inset-top, 0px));
padding-bottom: 0.75rem;
padding-left: max(1.25rem, env(safe-area-inset-left, 0px) + 0.5rem);
padding-right: max(1.25rem, env(safe-area-inset-right, 0px) + 0.5rem);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -378,7 +384,7 @@ code,
max-width: var(--max-content); max-width: var(--max-content);
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 1.25rem 1.25rem 2.5rem; padding: 1.25rem 1.25rem calc(2.5rem + env(safe-area-inset-bottom, 0px));
} }
/* Cards & lists */ /* Cards & lists */
@ -408,9 +414,17 @@ code,
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 0.15s, box-shadow 0.15s;
} }
.list-row:hover { /* Hover — только у устройств с нормальным hover (не залипает на тач после тапа) */
border-color: color-mix(in srgb, var(--primary) 35%, var(--outline-variant)); @media (hover: hover) and (pointer: fine) {
box-shadow: 0 2px 12px rgba(0, 113, 104, 0.08); .list-row:hover {
border-color: color-mix(in srgb, var(--primary) 35%, var(--outline-variant));
box-shadow: 0 2px 12px rgba(0, 113, 104, 0.08);
}
.list-row a:hover {
color: var(--primary);
text-decoration: none;
}
} }
.list-row a { .list-row a {
@ -419,11 +433,6 @@ code,
font-weight: 600; font-weight: 600;
} }
.list-row a:hover {
color: var(--primary);
text-decoration: none;
}
.list-row__meta { .list-row__meta {
color: var(--secondary); color: var(--secondary);
font-size: 0.8rem; font-size: 0.8rem;
@ -431,6 +440,11 @@ code,
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* «· v1» и хвост мета — не рвём посередине (мобайл) */
.list-row__meta-tail {
white-space: nowrap;
}
/* Вся плитка — одна ссылка */ /* Вся плитка — одна ссылка */
.list-row--action { .list-row--action {
padding: 0; padding: 0;
@ -447,9 +461,11 @@ code,
transition: background 0.12s ease; transition: background 0.12s ease;
} }
.list-row--action .list-row__link:hover { @media (hover: hover) and (pointer: fine) {
background: color-mix(in srgb, var(--primary) 6%, transparent); .list-row--action .list-row__link:hover {
text-decoration: none; background: color-mix(in srgb, var(--primary) 6%, transparent);
text-decoration: none;
}
} }
.list-row--action .list-row__title { .list-row--action .list-row__title {
@ -468,24 +484,40 @@ code,
gap: 0; gap: 0;
} }
/* Колонка тянется по высоте кнопки «Пройти»; ссылка на всю эту высоту — цель тача на моб. */
.list-row--split .list-row__main { .list-row--split .list-row__main {
display: flex;
flex-direction: column;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
min-height: 0;
align-self: stretch;
} }
.list-row--split .list-row__link { .list-row--split .list-row__link {
display: block; display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
justify-content: flex-start;
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
outline-offset: 2px; outline-offset: 2px;
border-radius: 0.85rem 0 0 0.85rem; border-radius: 0.85rem 0 0 0.85rem;
transition: background 0.12s ease; transition: background 0.12s ease;
-webkit-tap-highlight-color: color-mix(in srgb, var(--primary) 18%, transparent);
} }
.list-row--split .list-row__link:hover { .list-row--split .list-row__link:active {
background: color-mix(in srgb, var(--primary) 6%, transparent); background: color-mix(in srgb, var(--primary) 10%, transparent);
text-decoration: none; }
@media (hover: hover) and (pointer: fine) {
.list-row--split .list-row__link:hover {
background: color-mix(in srgb, var(--primary) 6%, transparent);
text-decoration: none;
}
} }
.list-row--split .list-row__side { .list-row--split .list-row__side {
@ -502,9 +534,17 @@ code,
font-weight: 600; font-weight: 600;
} }
@media (max-width: 520px) { /* Моб/узкий экран: заголовок на полную ширину, кнопка снизу на полную ширину — без пустой полосы */
@media (max-width: 640px) {
.list-row--split { .list-row--split {
flex-wrap: wrap; flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
}
.list-row--split .list-row__main {
flex: 0 0 auto;
width: 100%;
} }
.list-row--split .list-row__link { .list-row--split .list-row__link {
@ -512,12 +552,18 @@ code,
} }
.list-row--split .list-row__side { .list-row--split .list-row__side {
box-sizing: border-box;
width: 100%; width: 100%;
justify-content: flex-end; justify-content: stretch;
border-left: none; border-left: none;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
padding: 0.5rem 0.9rem 0.75rem; padding: 0.5rem 0.9rem 0.75rem;
} }
.list-row--split .list-row__side .btn {
width: 100%;
box-sizing: border-box;
}
} }
/* Карточка теста: визуальные блоки + сворачивание (удобно на узком экране) */ /* Карточка теста: визуальные блоки + сворачивание (удобно на узком экране) */
@ -531,6 +577,11 @@ code,
margin-bottom: 1.1rem; margin-bottom: 1.1rem;
} }
/* Чуть больше воздуха между аккордеонами на карточке теста */
.test-detail-page > .cabinet-brick {
margin-bottom: 1.35rem;
}
.cabinet-brick--hero { .cabinet-brick--hero {
padding: 0.1rem 0 0.2rem; padding: 0.1rem 0 0.2rem;
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 45%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 45%, transparent);
@ -554,6 +605,29 @@ code,
min-height: 2.75rem; min-height: 2.75rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem;
}
.cabinet-disclosure__summary-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
min-width: 0;
text-align: left;
}
.cabinet-disclosure__summary-title {
font-size: 1.05rem;
line-height: 1.25;
}
.cabinet-disclosure__summary-sub {
display: block;
font-size: 0.8rem;
font-weight: 400;
line-height: 1.3;
color: var(--secondary);
} }
.cabinet-disclosure__summary::-webkit-details-marker { .cabinet-disclosure__summary::-webkit-details-marker {
@ -575,7 +649,7 @@ code,
} }
.cabinet-disclosure__body { .cabinet-disclosure__body {
padding: 0 1rem 1.05rem; padding: 0.7rem 1rem 1.05rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
} }
@ -606,10 +680,165 @@ code,
.assign-toolbar__search { .assign-toolbar__search {
flex: 1 1 200px; flex: 1 1 200px;
min-height: 2.5rem;
max-height: 2.75rem;
padding-top: 6px;
padding-bottom: 6px;
line-height: 1.25;
box-sizing: border-box;
}
/* Назначение: селекты — как раньше; поиск — одна «строка», не «плитка» */
@media (max-width: 640px) {
.assign-toolbar {
gap: 0.4rem;
}
.assign-toolbar .form-input {
font-size: 16px;
line-height: 1.3;
}
.assign-toolbar__search {
font-size: 16px;
line-height: 1.3;
}
.assign-toolbar .form-input:not(.assign-toolbar__search) {
padding: 8px 12px;
}
}
/* Подсекции и подсказки (карточка теста) */
.test-detail-subsection {
margin-top: 1.25rem;
padding-top: 1.15rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
}
.test-detail-subsection:first-of-type,
.test-detail-subsection--tight {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.test-detail-subsection--import {
margin-top: 1.35rem;
padding-top: 1.2rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
}
.test-detail-subsection__title {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 600;
font-family: inherit;
color: var(--on-surface);
letter-spacing: -0.01em;
}
.test-detail-hint {
margin: 0 0 0.6rem;
font-size: 0.8rem;
line-height: 1.4;
color: var(--secondary);
}
/* Панель «ИИ» — в тон остальным disclosure, без лишнего «модальности» */
.test-detail-ai-panel {
padding: 0.9rem 1rem;
margin-bottom: 1.15rem;
background: var(--surface-container-low);
border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
border-radius: 0.85rem;
box-shadow: none;
}
/* Кнопка публикации — не тянем на 100% ширины, если одна */
.publication-visibility__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.publication-visibility__actions .btn {
width: auto;
min-width: min(100%, 11rem);
max-width: 100%;
box-sizing: border-box;
}
/* Убрать вариант: иконка */
.question-option-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
min-width: 2.25rem;
height: 2.25rem;
margin: 0;
padding: 0;
border: none;
border-radius: 0.65rem;
background: transparent;
color: var(--secondary);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.question-option-remove:hover {
background: color-mix(in srgb, var(--error) 8%, var(--surface));
color: var(--error, #b91c1c);
}
.question-option-remove .material-symbols-outlined {
font-size: 1.2rem;
line-height: 1;
}
/* Футер вопроса: + вариант, удалить вопрос */
.question-editor__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.65rem;
margin-top: 0.4rem;
}
.question-editor__footer .btn--sm,
.question-editor__add-question {
font-size: 0.8rem;
}
/* Добавить вопрос — в конец блока «Вопросы» */
.test-detail-add-question {
display: flex;
justify-content: flex-start;
margin-top: 1.1rem;
padding-top: 0.85rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 28%, transparent);
}
/* Импорт: кнопка на всю ширину в колонку; ритм отступов */
@media (max-width: 640px) {
.import-file-row--block {
flex-direction: column;
align-items: stretch;
}
.import-file-label {
display: block;
width: 100%;
text-align: center;
}
} }
.assign-list { .assign-list {
max-height: min(50vh, 22rem); max-height: min(40vh, 18rem);
min-height: 0;
overflow: auto; overflow: auto;
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
border-radius: 0.75rem; border-radius: 0.75rem;
@ -617,13 +846,28 @@ code,
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
/* Чуть выше область прокрутки — под панель инструмента теста и fixed-футер */
@media (max-width: 640px) {
.assign-list {
max-height: min(52dvh, 24rem);
}
}
.assign-row { .assign-row {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 40%, transparent);
cursor: pointer; cursor: pointer;
align-items: flex-start; align-items: center;
}
.assign-row input[type='checkbox'] {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
margin: 0;
accent-color: var(--primary);
} }
.assign-row:last-child { .assign-row:last-child {
@ -660,6 +904,12 @@ code,
font-size: 0.8rem; font-size: 0.8rem;
color: var(--secondary); color: var(--secondary);
line-height: 1.35; line-height: 1.35;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
word-break: break-word;
overflow-wrap: anywhere;
} }
.create-row { .create-row {
@ -755,6 +1005,63 @@ code,
color: var(--on-surface-variant); color: var(--on-surface-variant);
} }
/* История версий: карточки вместо таблицы (мобайл) */
.version-card-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.version-card-list__item {
margin: 0;
min-height: 0;
}
.version-card-list__row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.version-card-list__main {
min-width: 0;
flex: 1 1 12rem;
}
.version-card-list__title-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.version-card-list__action {
flex: 0 0 auto;
align-self: center;
}
@media (max-width: 520px) {
.version-card-list__row {
flex-direction: column;
align-items: stretch;
}
/* В column flex `flex-grow: 1` на .main тянет блок по вертикали — лишнее пустое место */
.version-card-list__main {
flex: 0 1 auto;
}
.version-card-list__action {
width: 100%;
align-self: stretch;
}
}
.draft-block { .draft-block {
margin-top: 1.25rem; margin-top: 1.25rem;
padding: 1rem; padding: 1rem;
@ -783,3 +1090,243 @@ code,
.inline-actions .btn { .inline-actions .btn {
width: auto; width: auto;
} }
/* Нижняя панель: полноширинные primary + secondary (без перебития .inline-actions .btn) */
.actions-bar {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
width: 100%;
box-sizing: border-box;
}
.actions-bar .btn-primary {
width: 100%;
margin-top: 0;
box-sizing: border-box;
}
.actions-bar a.btn,
.actions-bar .btn.btn-ghost {
display: block;
width: 100%;
text-align: center;
box-sizing: border-box;
}
@media (min-width: 480px) {
.actions-bar {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.actions-bar .btn-primary {
width: auto;
min-width: 12rem;
flex: 1 1 auto;
}
.actions-bar a.btn,
.actions-bar .btn.btn-ghost {
display: inline-block;
width: auto;
flex: 0 0 auto;
}
}
@media (max-width: 520px) {
.inline-actions--block-mobile {
flex-direction: column;
align-items: stretch;
}
.inline-actions--block-mobile .btn {
width: 100%;
box-sizing: border-box;
}
}
/* --- Спринт 2: редактор вопроса, прогоны, импорт, фикс. футер --- */
.question-editor-block {
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
margin-top: 1.05rem;
padding-top: 1.05rem;
}
.question-editor-block--first {
border-top: none;
margin-top: 0;
padding-top: 0;
}
.question-option-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.question-option-row__text {
flex: 1 1 10rem;
min-width: 0;
}
.question-editor-block__header {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.question-editor-block__title {
margin-bottom: 0;
font-size: 0.95rem;
}
.question-editor-block__ai-btn {
width: 100%;
text-align: center;
box-sizing: border-box;
align-self: stretch;
}
@media (min-width: 520px) {
.question-editor-block__header {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.question-editor-block__ai-btn {
width: auto;
align-self: center;
}
}
/* Спринт 2.4: radio = один верный, checkbox = несколько (нативная метафора) */
.question-option-row__mark {
width: 1.15rem;
height: 1.15rem;
flex-shrink: 0;
margin-top: 0.1rem;
cursor: pointer;
accent-color: var(--primary);
}
/* Список прогонов: карточки */
.attempts-card-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.attempts-card-list__item {
margin: 0;
}
.attempts-card-list__row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.attempts-card-list__main {
flex: 1 1 12rem;
min-width: 0;
}
.attempts-card-list__action {
flex: 0 0 auto;
}
@media (max-width: 520px) {
.attempts-card-list__row {
flex-direction: column;
}
.attempts-card-list__action {
width: 100%;
text-align: center;
}
}
/* Импорт: скрытый input + кнопка-стиль */
.import-file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.import-file-row {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.import-file-label {
margin: 0;
cursor: pointer;
width: auto;
display: inline-block;
box-sizing: border-box;
}
/* Мобилка: закреплённый футер с «Сохранить» (дубль панели скрыт) */
.test-detail-fixed-actions {
display: none;
}
@media (max-width: 640px) {
.test-detail-page--with-fixed-actions {
padding-bottom: calc(7.5rem + env(safe-area-inset-bottom, 0px));
}
.test-detail-page--with-fixed-actions .editor-actions-flow {
display: none;
}
.test-detail-fixed-actions {
display: block;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
padding: 0.65rem 1.25rem calc(0.65rem + env(safe-area-inset-bottom, 0px));
background: color-mix(in srgb, var(--surface) 96%, #fff);
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 45%, transparent);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08);
}
.test-detail-fixed-actions__inner {
max-width: var(--max-content);
margin: 0 auto;
}
.test-detail-fixed-actions__status {
margin: 0 0 0.45rem;
font-size: 0.8rem;
line-height: 1.3;
max-height: 3.2rem;
overflow: auto;
}
}

Loading…
Cancel
Save