Compare commits

...

35 Commits

Author SHA1 Message Date
Константин Лебединский 1c4dacbc85 Merge branch 'dev-redisign' into dev
Этап 1 миграции TestingWebApp на Python/Flask/Jinja:
- E1.0 База Flask-приложения (БД-пул, sessions, base.html)
- E1.1 Auth (Flask sessions, bcrypt+Werkzeug, опц. HR_AUTH)
- E1.2 Тесты: список и редактор + AI-генерация (минимум)
- E1.3 Импорт документов (PDF/DOCX/TXT/MD)
- E1.8 AI v2: /settings + ping, generate-by-title, check, improve

Полировка мобильного UI (расположение, без изменения дизайна).
docker-compose.dev.yml: проброс DATABASE_URL/HR_*/LLM_* и сети postgres.
Документация: migration-final.md (двух-этапный план),
QA-versioning-and-ai.md (инструкция для тестировщика на edullm.pirogov.ai).
2026-04-28 00:13:11 +05:00
Константин Лебединский c3bdb406d6 docs(qa): уточнить — для тестирования подойдёт любая учётка
Made-with: Cursor
2026-04-28 00:11:10 +05:00
Константин Лебединский 47d673496b docs(qa): переписать инструкцию для тестировщика — только UI, без консолей и SQL
Made-with: Cursor
2026-04-28 00:09:11 +05:00
Константин Лебединский 2d6d75fb3c ui(mobile): полировка расположения редактора и каталога
Только layout/spacing/touch-targets, без изменения цветовой палитры
и типографики.

editor.html
- Шапка теста: «Название» — отдельной строкой, large input; «Описание»
  ниже; «Проходной балл» — компактная пара label+input справа,
  inputmode="numeric".
- AI-панель разбита на 3 группы с подзаголовками: «Создать вопросы»
  (По названию / По текущей сетке), «Улучшить существующее»
  (Проверить / Улучшить), «Импортировать» (загрузка файла).
- Все основные кнопки — min-h-11, на мобиле во всю ширину/в гриде по 2.
- Карточка вопроса: бейдж-номер, кнопки up/down/delete по 40×40,
  textarea и опции — на всю ширину с min-w-0 чтобы не было overflow.
- Опции: чекбокс «Правильный» в 40×40 tap-target, input занимает flex,
  кнопка удаления 40×40.
- Footer переведён на fixed bottom с safe-area-inset-bottom; контент
  получает pb-24, чтобы не уезжал под футер.
- Модалка AI-результата теперь fullscreen на мобиле, sm:rounded-2xl
  на десктопе; шапка/тело/кнопки — отдельными зонами.

list.html
- Заголовок и кнопка «Создать тест» вертикально на мобиле,
  кнопка во всю ширину min-h-11.
- Карточка теста — целиком кликабельная (`<a>` обёртка), grid-cols-1
  по умолчанию, sm:2, lg:3.
- Модалка создания — fullscreen на мобиле с крестиком в шапке,
  safe-area-inset-bottom в футере.

base.html
- Ссылки «Тесты» и «Настройки» теперь видны и на мобиле как иконки
  (40×40 tap-target), подписи появляются с sm: брейкпоинта.
- Имя/роль пользователя — только с md+ (узкий мобильный экран).

Made-with: Cursor
2026-04-27 23:55:39 +05:00
Константин Лебединский 547840d671 docs(qa): tester guide for versioning and AI features
Made-with: Cursor
2026-04-27 23:50:38 +05:00
Константин Лебединский 4b0d56ff0e feat(flask): E1.0–E1.3, E1.8 — миграция на Python/Flask + AI v2
Этап 1 миграции TestingWebApp на целевой стек (Python/Flask/Jinja),
БД остаётся clinic_tests.

E1.0 — База Flask-приложения: SQLAlchemy/psycopg2 пул, Flask sessions,
фабрика create_app, blueprint main с / и /health, base.html в стиле
кабинета HR (Tailwind CDN + Manrope + Material Symbols), 404/500.

E1.1 — Auth + /api/me: Flask sessions (signed cookie) вместо JWT,
bcrypt + Werkzeug, опц. HR_AUTH=1 с UPSERT в clinic_tests.users по
staff_id. UI /login, JSON /api/auth/{login,logout,me}, декораторы
@login_required / @require_role.

E1.2 — Тесты: список + редактор. 10 эндпоинтов, сервисы test_draft,
test_access, test_chain, ai_editor, llm_client, draft_validator,
editor_content. UI /tests (каталог + создание) и /tests/<id>/edit
(редактор с AI). Полный мобильный UX (аккордеоны/drag-n-drop) — в E1.7.

E1.3 — Импорт документов: pypdf + python-docx, эндпоинт
POST /api/tests/import/document, кнопка «Импорт документа» в
AI-панели редактора, лимит 16 МБ.

E1.8 — AI v2: страница /settings (статус ENV-ключа + ping),
ai/generate-by-title (без сетки), ai/check (рецензия), ai/improve
(массовое было→стало с чекбоксами). Унифицированный ответ AI-ошибок:
{ error, code, settingsUrl }.

Docker:
- docker-compose.dev.yml: добавлены DATABASE_URL, HR_AUTH/HR_DATABASE_URL,
  DEEPSEEK_API_KEY/OPENAI_API_KEY/LLM_BASE_URL/LLM_MODEL и сеть postgres
  для testing-flask.

Документация:
- docs/migration-final.md — двух-этапный план (Этап 1: унификация
  стека внутри TestingWebApp; Этап 2: слияние с tgFlaskForm).
- docs/migration-final-inventory.md — карта 22 эндпоинтов Express.

Made-with: Cursor
2026-04-27 23:29:26 +05:00
Константин Лебединский 31b51b7768 docs: move user guide out of sprints; sync design docs with UI
- СПРИНТЫ: remove inline instruction and sprint 4; link to РУКОВОДСТВО + images
- Add docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md (user-facing, with SVG refs)
- ПРЕДЛОЖЕНИЕ_ДИЗАЙН: актуализация TestDetail/cabinet-theme vs legacy TestForm
- PROJECT_STATUS, DEV_CONTOUR, шаги 05+README, migration, README: current block names and links

Made-with: Cursor
2026-04-27 20:48:52 +05:00
Константин Лебединский 5cd94c05ad docs: user guide for cabinet, sprint 4, SVG placeholders; drop token sprint
- Replace old sprint 4 (design tokens) with sprint 4: plain-language guide + images
- Add 'Как пользоваться' section: list, about test, questions, save, history, show/assign
- Add docs/images/cabinet-ui SVG placeholders and README for swapping in PNGs

Made-with: Cursor
2026-04-27 20:37:46 +05:00
Константин Лебединский f1f5223076 feat(test detail): regroup sections, copy, and editor affordances
- AccSection: optional subtitle; disclosure body top padding
- Renames: О тесте, Вопросы, История (Версии + Прохождения), Показ в каталоге
- Move import into Вопросы as «Документ в вопросы»; remove duplicate import section
- Merge publication + assignment with subheads Видимость / Кому выдать; non-full-width actions
- Assign: «Выбрать всех (N)»; search row height tweaks
- Editor: крестик удаления варианта; единый футер + вариант/удалить вопрос; + вопрос в отдельной полосе
- AI block: test-detail-ai-panel (lighter, aligned with rest)
- History: empty state for runs; attempts error only in section (remove hero duplicate)

Made-with: Cursor
2026-04-27 20:36:09 +05:00
Константин Лебединский 72a5863871 fix(mobile): version card height, assign list, header safe-area
- version cards: flex-grow 0 for main in column layout (no empty vertical gap)
- test detail: slightly larger gaps between accordion bricks; more bottom padding for fixed footer
- header: respect safe-area insets for top and sides (logout away from screen edge)
- assign: compact toolbar inputs on narrow screens; taller scroll area; row borders a bit stronger
- assign rows: center checkbox with text; clamp meta to 3 lines with ellipsis; checkbox size + accent-color

Made-with: Cursor
2026-04-27 20:19:51 +05:00
Константин Лебединский f7d9cbb1c4 fix(tests list): stack card on mobile, full-width title and Pass button
- max-width 640px: column layout, no split title/button in one row
- Pass button full width in side row; remove flex-end empty strip
- Scope list-row :hover to hover+fine pointer; add :active on link for touch
- list-row--action link hover also scoped to fine pointer

Made-with: Cursor
2026-04-27 20:08:33 +05:00
Константин Лебединский 9a85a13e08 fix(tests list): full-height link in split row for mobile tap
- list-row__main: flex column + stretch; list-row__link: flex:1 to fill
  height next to «Пройти» so taps below title hit the <a>
- Subtle -webkit-tap-highlight-color on list link

Made-with: Cursor
2026-04-27 19:45:13 +05:00
Константин Лебединский 3e70f4322d Sprint 2: attempt cards, file import button, question layout, radio/check, sticky save
- Replace attempts table with attempts-card-list cards
- Custom import: sr-only file input + btn-styled label
- question-editor-block: stack header on mobile, row from 520px
- option marks: radio for single correct, checkbox for multiple
- Fixed bottom actions on max-width 640px with draft status; hide in-flow panel
- Update СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md and DOC/ШАГИ/ШАГ_2026-04-27_003.md

Made-with: Cursor
2026-04-27 19:38:42 +05:00
Константин Лебединский 1db3653e66 docs: sprint checklist use [x]/[ ] task list format
Made-with: Cursor
2026-04-27 19:35:57 +05:00
Константин Лебединский 5db12c2348 Mobile UI sprint 1: actions-bar, version cards, meta line, safe area
- Add docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md and DOC/ШАГИ/ШАГ_2026-04-27_002.md
- cabinet-theme: .actions-bar, .version-card-list, .list-row__meta-tail,
  .inline-actions--block-mobile, btn--sm/ghost tweaks, safe-area main padding
- TestDetail: replace version table with cards; command panel uses actions-bar;
  assign list only when there are people; publication full-width on narrow
- TestsList: version suffix in non-breaking tail span

Made-with: Cursor
2026-04-27 19:35:08 +05:00
Константин Лебединский 2a05f41b65 Redesign test editor: meta, content, AI shape, command bar
- Split draft editor into AccSection Метаинформация and Содержание
- AI generation: topic, question count (1–30), answers per question (2–8)
- Move save and back-to-list to bottom command panel; remove AI from hero
- Normalize generated options to requested count; sync ai topic on import draft
- Add DOC/ШАГИ/ШАГ_2026-04-27_001.md and track design proposal doc

Made-with: Cursor
2026-04-27 19:07:00 +05:00
Константин Лебединский b3e3757a92 docs: миграция на tgFlaskForm и производительность Flask; контур flask_app; UI без лишних описаний
Made-with: Cursor
2026-04-27 18:43:51 +05:00
Константин Лебединский 47279c72e3 chore: фронт :3107, API :3001 (Docker, Vite, CORS, доки)
- compose: testing-web 3107:80, testing-backend 3001:3001, PORT+FRONTEND_URL
- nginx → testing-backend:3001; Vite 3107, proxy /api → 3001
- server default PORT 3001; Dockerfile EXPOSE 3001; CORS dev — localhost:3107
- README, журнал, user guide

Made-with: Cursor
2026-04-24 23:14:33 +05:00
Константин Лебединский 42b5e9ad44 chore: API на хосте 3107 (согласовано с Vite proxy)
- compose: 3107:3107 вместо 3002:3107
- README: прямой URL API с хоста
- vite: комментарий про порт

Made-with: Cursor
2026-04-24 23:09:18 +05:00
Константин Лебединский 0fe04d4d99 feat: полный бэк и фронт (попытки, разбор, импорт, ИИ, назначения)
- Сервисы: testAttemptService, testAccess, document import/gen/extract, LLM, assignment, aiEditor
- Конфиг: devAuthor, featureFlags; messages/ru; интеграция V.9 (skip без БД)
- API/роуты: app, auth, server; Dockerfile и env example
- Фронт: TestAttempt, TestAttemptReview, AttemptReviewBlock, стили, правки App/api/login/vite
- compose и README; смоук-тесты расширены

Закрывает отсутствие модулей в origin после клона.

Made-with: Cursor
2026-04-24 22:55:15 +05:00
Константин Лебединский a68331c86b docs: статус проекта, инструкция dev, обновление всех .md
- PROJECT_STATUS: что сделано (черновики, версии, разбор, каталог) и планы
- DEV_CONTOUR_USER_GUIDE: сценарии для проверяющих на dev-стенде
- README, ТЗ, card1, журнал, бэклоги, шаги 01–11+README, спринты, TEST_TABLES: ссылки и примечания
- backend/PROGRESS: ссылка на PROJECT_STATUS

Made-with: Cursor
2026-04-24 22:12:06 +05:00
Константин Лебединский 4801ea9f19 UI: фамилия с инициалами в шапке, подпись автора у тестов
- Хедер: отображать Фамилия И. О., полное ФИО в title.
- Список тестов и карточка: «Автор: Вы» для своих, иначе «Автор: Фамилия И. О.».
- API: в каталоге и summary/versions — created_by, author full_name (camelCase в JSON).

Made-with: Cursor
2026-04-24 22:08:45 +05:00
Константин Лебединский 89da5b60b7 feat(docker): docker-compose.dev — backend+nginx, общий Postgres, миграции в entrypoint
Made-with: Cursor
2026-04-24 20:36:49 +05:00
Константин Лебединский 8ffd104f64 chore: общий Postgres по умолчанию; compose standalone + подсказки .env
Made-with: Cursor
2026-04-24 20:33:14 +05:00
Константин Лебединский 699277be07 fix(migrate): освобождать клиент пула, диагностика ECONNREFUSED и AggregateError
Made-with: Cursor
2026-04-24 20:31:43 +05:00
Константин Лебединский 5631d85238 feat(card1): версии тестов API, черновик, HR-login, import, UI
- V.1–V.3: saveTestDraft, fork при попытках; миграция 003 staff_id
- V.4–V.6: REST /api/tests, activate, PATCH, start attempt
- A: HR_DATABASE_URL + Werkzeug/bcrypt, JWT staffId, HR_AUTH
- D.1: multipart /api/tests/import/document
- Frontend: login, список тестов, экран версий/черновика/попытки
- ТЗ: V.10 назначения vs активная версия; журнал приёма

Made-with: Cursor
2026-04-24 20:30:09 +05:00
Константин Лебединский 7fa6f98ee1 docs: БД clinic_tests + hr_bot_test, staff_id, RBAC, telegram_id справка
Made-with: Cursor
2026-04-24 20:23:59 +05:00
Константин Лебединский 675555531f feat(db): DATABASE_URL и общий Postgres (Postgres_TG_Bots), БД clinic_tests
Made-with: Cursor
2026-04-24 20:16:22 +05:00
Константин Лебединский c381283ee4 chore: eslint — убрать 2 error (unused), журнал A1–A4 проверки
Made-with: Cursor
2026-04-24 20:12:33 +05:00
Константин Лебединский 26b5eddefa docs: S1-01 ручная проверка — card1 V.1/V.2/V.3
Made-with: Cursor
2026-04-24 20:10:58 +05:00
Константин Лебединский fcc6fae463 docs: S1-00 ручная проверка — журнал и раздел B
Made-with: Cursor
2026-04-24 20:10:19 +05:00
Константин Лебединский 997a71b974 docs: ручные проверки — один шаг из чата, ответ ОК/не ОК, заполняет ассистент
Made-with: Cursor
2026-04-24 19:32:02 +05:00
Константин Лебединский e87168d3a0 feat: журнал тестирования, бэклог идей; V.2 hasAnyAttemptForTest + unit tests; ссылки в спринтах
Made-with: Cursor
2026-04-24 19:26:02 +05:00
Константин Лебединский 4eeb3fbc62 docs: card1 (версии, документ, auth Postgres_TG_Bots); миграция 002 parent_id+unique active; спринт1+бэклог под Node
Made-with: Cursor
2026-04-24 19:17:46 +05:00
Константин Лебединский 93dcfcf4ff docs: add revision task spec (dev)
Made-with: Cursor
2026-04-23 21:14:22 +05:00
149 changed files with 22772 additions and 3 deletions
Vendored
BIN
View File
Binary file not shown.
+9
View File
@@ -1,5 +1,14 @@
# General # General
.DS_Store .DS_Store
node_modules/
dist/
.env
.env.local
backend/.env
frontend/.env
flask_app/.env
flask_app/.venv/
flask_app/**/__pycache__/
__MACOSX/ __MACOSX/
# Thumbnails and Metadata # Thumbnails and Metadata
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+73 -1
View File
@@ -6,18 +6,62 @@
**Дата:** 2026-03-21 **Дата:** 2026-03-21
**Статус:** Согласовано **Статус:** Согласовано
**Актуальное состояние кода (не ТЗ, а «что уже есть»):** [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) · [инструкция для проверяющих на dev](docs/DEV_CONTOUR_USER_GUIDE.md) · [кабинет: коротко для врачей/кураторов](docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.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`».
---
## Стек технологий
### Этот репозиторий (TestingWebApp)
| Слой | Технологии |
|------|------------|
| **Backend** | Node.js (ESM), **Express** 4, **pg**, миграции SQL; аутентификация — cookie + **JWT** (**jsonwebtoken**), пароли **bcryptjs**; опционально вход через HR (`HR_AUTH`, отдельное подключение к БД HR). |
| **Frontend** | **React** 18, **React Router** 6, сборка **Vite** 5; статика в проде через Nginx (см. `docker-compose.dev.yml`). |
| **Данные** | **PostgreSQL**, отдельная БД **`clinic_tests`**: UUID-ключи, таблицы `tests`, `test_versions`, `questions`, `answer_options`, назначения, попытки (см. `backend/src/db/migrations/`). |
| **Прочее** | Извлечение текста из PDF/DOCX (**pdf-parse**, **mammoth**), опционально LLM для черновиков тестов; **dotenv**, **cors**, **multer**. |
### Целевой стек (Flask, как в кабинете / мини-приложении)
Тот же класс технологий, что в **`HR_TG_Bot/tgFlaskForm`**: Python, Flask, шаблоны, Postgres. Сейчас допускается **отдельный деплой** нового контура из каталога [`flask_app/`](flask_app/README.md); позже — слияние с полным кабинетом при необходимости.
Эталон реализации модуля в монорепозитории HR — общий веб-кабинет **`HR_TG_Bot/tgFlaskForm`**:
| Слой | Технологии |
|------|------------|
| **Приложение** | **Python 3**, **Flask** 3, шаблоны **Jinja2** + **PyPug**, blueprint `/cabinet/testing`; прод-сервер типично **waitress**. |
| **Данные** | **SQLAlchemy** 2, **psycopg2**, БД **`hr_bot_test`**: таблицы `testing_*`, связи с **`staff_members`**. |
| **Клиент** | HTML-шаблоны кабинета, JS в `webApp/templates/static/js/cabinet/` (без отдельного SPA в этом репозитории). |
| **Инфра** | Тот же кластер Postgres, что и у Postgres_TG_Bots / HR (см. раздел установки ниже). |
Подробности переноса и миграции данных: [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md).
Скрипт ETL в монорепозитории HR: [`../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (`--dry-run` / `--apply`, переменные `CLINIC_TESTS_URL` и `HR_BOT_URL`).
--- ---
## Содержание ## Содержание
- [Стек технологий](#стек-технологий) · [flask_app/ — новый контур](flask_app/README.md)
- [Состояние реализации (сводка)](#состояние-реализации-сводка)
- [Функциональные возможности](#функциональные-возможности) - [Функциональные возможности](#функциональные-возможности)
- [Роли и права доступа](#роли-и-права-доступа) - [Роли и права доступа](#роли-и-права-доступа)
- [Установка и запуск](#установка-и-запуск) - [Установка и запуск](#установка-и-запуск)
- [Данные, сотрудники, интеграция с HR](#данные-сотрудники-интеграция-с-hr)
- [Нефункциональные требования](#нефункциональные-требования) - [Нефункциональные требования](#нефункциональные-требования)
- [Вне scope](#вне-scope-не-реализуется-в-данной-версии) - [Вне scope](#вне-scope-не-реализуется-в-данной-версии)
--- ---
## Состояние реализации (сводка)
Коротко и по-человечески: [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) (черновики и версии, разбор попыток, список тестов, dev-стенд).
Как пользоваться локальным **dev** без чтения кода: [docs/DEV_CONTOUR_USER_GUIDE.md](docs/DEV_CONTOUR_USER_GUIDE.md).
---
## Функциональные возможности ## Функциональные возможности
### Управление пользователями и подразделениями ### Управление пользователями и подразделениями
@@ -105,7 +149,35 @@
## Установка и запуск ## Установка и запуск
Инструкции по установке и запуску приложения будут добавлены после выбора технологического стека. ### База данных (как в HR_TG_Bot / Postgres_TG_Bots)
Используется **тот же** экземпляр PostgreSQL, что и в [Postgres_TG_Bots](../Postgres_TG_Bots) (`docker-compose.dev.yml`, контейнер `hr_postgres_dev`, учётка `hr_bot_user` / сеть `hr_postgres_dev_net` — см. [HR_TG_Bot docker-compose](../HR_TG_Bot/docker-compose.dev.yml)).
Схема приложения (таблицы `users`, `tests`, `departments`, …) **не** совмещается с БД `hr_bot_test` — для TestingWebApp заведена отдельная база **`clinic_tests`**.
1. Поднять Postgres из `Postgres_TG_Bots` (и при необходимости внешнюю сеть: `docker network create hr_postgres_dev_net` — как в compose этих репозиториев).
2. Один раз создать базу:
`psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;"`
3. Скопировать `backend/.env.example` в `backend/.env`, при необходимости поправить `DATABASE_URL` (внутри Docker кластера — хост `hr_postgres_dev`, порт `5432`).
4. Миграции: из каталога `backend/`: `npm run migrate`, затем `npm start` (и фронт из `frontend/``npm run dev`).
**Docker (UI + API + общий Postgres):** поднять `Postgres_TG_Bots` (сеть `hr_postgres_dev_net`), создать БД `clinic_tests`, затем из корня `TestingWebApp`:
`docker compose -f docker-compose.dev.yml up --build` — интерфейс **http://localhost:3107** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3001** (см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). **Новый Flask-контур** (тот же стек, что кабинет HR): **http://localhost:3108** — сервис `testing-flask`, см. [flask_app/README.md](flask_app/README.md). Локальный `npm run dev` фронта (Vite) — тоже **:3107**, прокси `/api` на **:3001**. В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`). В `backend/.env` задайте `PORT=3001`, если поднимаете API отдельно от compose.
`docker compose -f docker-compose.dev.yml down` — остановка.
**Без общего кластера** (только отладка): `docker compose --profile standalone up -d` в TestingWebApp — Postgres на **5433**, тогда в `.env` укажите `DATABASE_URL=...localhost:5433/clinic_tests` или `DB_PORT=5433` с `DB_NAME`/`DB_USER` из compose.
**Если `npm run migrate` пишет `ECONNREFUSED ...:5433`:** в `backend/.env` нет (или кривой) `DATABASE_URL` на **5432**, и сработал старый `DB_PORT=5433`. Задайте `DATABASE_URL` как в `backend/.env.example` для общего Postgres.
### Данные, сотрудники, интеграция с HR
- **Две роли кластера Postgres:** в **`clinic_tests`** — только сущности модуля тестирования (тесты, версии, назначения, попытки, локальные технические учётки при необходимости). В **`hr_bot_test`** (Postgres_TG_Bots / hr_web_viewer) — штат, справочники, существующий **RBAC** и веб-логины. Так мы не смешиваем схемы и не дублируем «источник правды» по людям.
- **Сотрудник в процессах** (назначения, дашборды, доступ к результатам) — везде по **`staff_members.id`**. Ссылки в `clinic_tests` храним как **тот же идентификатор** (логическая связь с `staff_members` в `hr_bot_test`); **ФИО, отдел, роли** подтягиваем из HR при отображении или кэшируем по согласованной политике, а не ведём второй кадровый учёт.
- **`telegram_id`** в данных сотрудника **не участвует** в бизнес-логике модуля: ни вход, ни проверка прав, ни выбор сотрудника в сценариях, ни фильтрация — только **справочная** информация при необходимости (отображение, история).
- **RBAC в перспективе:** единая система разрешений — та, что уже в HR (роли, `staff_role_assignments`, permissions). Модуль тестирования **не** развивает отдельную полную копию матрицы; проверка действий в целевом виде — через **HR** (внутренний API / токен / согласованные запросы к БД). Пока договор и API не готовы — допустимы временные флаги в `clinic_tests`, явно помечаемые как MVP.
Детализация задач и варианты A.x: [docs/revision_task/card1.md](docs/revision_task/card1.md).
--- ---
+5
View File
@@ -0,0 +1,5 @@
node_modules
.env
.env.*
*.log
.git
+46
View File
@@ -0,0 +1,46 @@
# --- Рекомендуемый вариант: ОБЩИЙ кластер (Postgres_TG_Bots) ---
# Скопируйте в backend/.env и задайте минимум DATABASE_URL + JWT_SECRET.
# Не оставляйте в .env устаревший DB_PORT=5433, если пользуетесь 5432 — иначе,
# при отсутствии/ошибке в DATABASE_URL пул уйдёт на DB_* и снова «не туда».
#
# Как в HR_TG_Bot: тот же Postgres (Postgres_TG_Bots/docker-compose.dev.yml),
# отдельная база clinic_tests (не путать с hr_bot_test).
# Локально (порт 5432, как в Postgres_TG_Bots на хосте):
#
# Backend в Docker рядом с HR: хост — container_name Postgres, порт 5432 внутри сети:
# DATABASE_URL=postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests
#
# Базу clinic_tests создают один раз (от суперпользователя контейнера):
# psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;"
#
# Если DATABASE_URL НЕ задан, берутся DB_* (fallback). Для общего кластера задавайте DATABASE_URL.
# DB_HOST=localhost
# DB_PORT=5432
# DB_NAME=clinic_tests
# DB_USER=developer
# DB_PASSWORD=dev_password
DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests
JWT_SECRET=change_me_in_production
# Порт HTTP API (как в docker-compose: 3001)
# PORT=3001
# A.1: HR login (Werkzeug password, staff by web_login = username в public.users)
# В Docker (docker-compose.dev.yml) по умолчанию HR_AUTH=1 и HR_DATABASE_URL на hr_bot_test.
# HR_AUTH=1
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test
# V.8: API/UI назначения (POST /api/tests/:id/assign, каталог в карточке). В NODE_ENV=development
# включено без этого флага. В production: CLINIC_ASSIGNMENT_ENABLED=1
# CLINIC_ASSIGNMENT_ENABLED=1
# D.3 — генерация черновика из импорта (POST /api/tests/import/document), OpenAI-совместимый API
# DEEPSEEK_API_KEY= → по умолчанию https://api.deepseek.com/v1, модель deepseek-chat
# OPENAI_API_KEY= → https://api.openai.com/v1, модель gpt-4o-mini (если нет ключа DeepSeek)
# LLM_BASE_URL= → переопределить (без /chat/completions)
# LLM_MODEL=
# LLM_NO_JSON=1 → убрать response_format, если API не принимает json_object
# DEEPSEEK_API_KEY=
# OPENAI_API_KEY=
+15
View File
@@ -0,0 +1,15 @@
{
"env": {
"es2022": true,
"node": true
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"extends": ["eslint:recommended"],
"rules": {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"no-console": "warn"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}
+8
View File
@@ -0,0 +1,8 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
EXPOSE 3001
RUN chmod +x docker-entrypoint.sh
ENTRYPOINT ["./docker-entrypoint.sh"]
+54
View File
@@ -0,0 +1,54 @@
# Progress — миграция `001_initial` (историческая заметка)
*Актуальное описание продукта и сценариев: [../docs/PROJECT_STATUS.md](../docs/PROJECT_STATUS.md).*
# Progress - Шаг 2: Проектирование базы данных
## Статус: ✅ ЗАВЕРШЕНО
### Выполненные задачи:
1.**Создание SQL-миграции** (`backend/src/db/migrations/001_initial.sql`)
- Созданы все таблицы:
- `departments` (Подразделения)
- `users` (Пользователи)
- `tests` (Тесты)
- `test_versions` (Версии тестов)
- `questions` (Вопросы)
- `answer_options` (Варианты ответов)
- `test_assignments` (Назначения тестов)
- `test_assignment_targets` (Получатели назначений)
- `test_attempts` (Попытки прохождения)
- `user_answers` (Ответы пользователя)
- `settings` (Настройки)
- Созданы ENUM типы: `user_role`, `target_type`, `attempt_status`
- Созданы индексы для оптимизации запросов
- Добавлены начальные данные в таблицу `settings`
2.**Создание скрипта миграции** (`backend/src/db/migrate.js`)
- Поддержка выполнения SQL-миграций
- Отслеживание выполненных миграций в таблице `migrations`
- Транзакционное выполнение миграций
- Логирование процесса выполнения
3.**Создание db.js** (`backend/src/db/db.js`)
- Подключение к PostgreSQL с использованием пула соединений
- Функции: `query()`, `transaction()`, `getClient()`
- Обработка ошибок пула
- Логирование запросов в режиме разработки
4.**Применение миграций к БД**
- Миграция `001_initial.sql` успешно выполнена
- Все таблицы созданы в базе данных `clinic_tests`
### Созданные файлы:
```
backend/src/db/
├── migrations/
│ └── 001_initial.sql # SQL-миграция с созданием всех таблиц
├── migrate.js # Скрипт для выполнения миграций
└── db.js # Модуль подключения к PostgreSQL
```
### Дата выполнения: 2026-03-21
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
echo "Running database migrations…"
node src/db/migrate.js
echo "Starting API…"
exec node src/server.js
+3212
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "clinic-tests-backend",
"version": "1.0.0",
"description": "Backend for Clinic Tests application",
"main": "src/server.js",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "NODE_ENV=development node --watch src/server.js",
"test": "node --test 'src/**/*.test.js'",
"test:integration": "CLINIC_TESTS_INTEGRATION=1 node --test 'src/**/*.test.js'",
"migrate": "node src/db/migrate.js",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write src/"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.12.0",
"multer": "^1.4.5-lts.1",
"pdf-parse": "^2.4.5",
"pg": "^8.12.0"
},
"devDependencies": {
"eslint": "^8.57.0",
"prettier": "^3.3.3",
"supertest": "^7.2.2"
}
}
+26
View File
@@ -0,0 +1,26 @@
/**
* V.9 — минимальные проверки HTTP без БД: health и 401 на защищённых маршрутах.
* Интеграции с Postgres — см. отдельные сценарии / ручной журнал.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from './app.js';
import { RU } from './messages/ru.js';
const app = createApp();
test('GET /api/health — 200 и status ok', async () => {
const res = await request(app).get('/api/health').expect(200);
assert.equal(res.body.status, 'ok');
});
test('GET /api/tests без cookie — 401', async () => {
const res = await request(app).get('/api/tests').expect(401);
assert.equal(res.body.error, RU.authRequired);
});
test('GET /api/__no_route__ — 404 на русском', async () => {
const res = await request(app).get('/api/__no_route__').expect(404);
assert.equal(res.body.error, RU.notFound);
});
+49
View File
@@ -0,0 +1,49 @@
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import dotenv from 'dotenv';
import authRoutes from './routes/auth.js';
import testsRoutes from './routes/tests.js';
import { RU } from './messages/ru.js';
dotenv.config();
export function createApp() {
const app = express();
const corsOrigins =
process.env.NODE_ENV === 'production'
? process.env.FRONTEND_URL
? [process.env.FRONTEND_URL]
: []
: [
'http://localhost:3107',
'http://localhost:3000',
];
app.use(
cors({
origin: corsOrigins.length ? corsOrigins : true,
credentials: true,
})
);
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRoutes);
app.use('/api/tests', testsRoutes);
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
message: 'Server is running',
});
});
app.use((err, req, res, _next) => {
console.error('Error:', err);
res.status(err.status || 500).json({
error: err.message || RU.internal,
});
});
app.use((req, res) => {
res.status(404).json({ error: RU.notFound });
});
return app;
}
+7
View File
@@ -0,0 +1,7 @@
/** Пароль-заглушка: вход только через HR, локальный compare не пройдёт. */
export const HR_MANAGED_PASSWORD_PLACEHOLDER = '$HR$MANAGED$NO_LOCAL$';
/** HR login enabled (1 = Werkzeug + upsert user по staff_id) */
export function isHrAuthEnabled() {
return process.env.HR_AUTH === '1' || process.env.HR_AUTH === 'true';
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Правка цепочки теста (черновик, версии, публикация, редактор) — только создатель (`tests.created_by`).
*/
export function isTestAuthor(createdBy, userId) {
return createdBy === userId;
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Флаги продуктовых фич (env). В development ряд вещей включён по умолчанию.
*/
/** API и UI: назначение тестов сотрудникам (каталог HR + POST /tests/:id/assign). */
export function isAssignmentFeatureEnabled() {
if (process.env.NODE_ENV === 'development') {
return true;
}
const v = (process.env.CLINIC_ASSIGNMENT_ENABLED || '').toLowerCase();
if (v === '1' || v === 'true' || v === 'yes') {
return true;
}
if (v === '0' || v === 'false' || v === 'no') {
return false;
}
return false;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Database Connection Module
* PostgreSQL connection pool and utility functions
*/
import pg from 'pg';
import { getPoolConfig } from './poolConfig.js';
const { Pool } = pg;
const pool = new Pool(getPoolConfig());
// Handle pool errors
pool.on('error', (err) => {
console.error('Unexpected pool error:', err.message);
});
/**
* Execute a query with the connection pool
* @param {string} text - SQL query text
* @param {Array} params - Query parameters
* @returns {Promise<pg.QueryResult>} Query result
*/
export async function query(text, params) {
const start = Date.now();
const result = await pool.query(text, params);
const duration = Date.now() - start;
if (process.env.NODE_ENV === 'development') {
console.log('Executed query:', { text: text.substring(0, 50), duration, rows: result.rowCount });
}
return result;
}
/**
* Execute a query with automatic client release
* @param {string} text - SQL query text
* @param {Array} params - Query parameters
* @returns {Promise<pg.QueryResult>} Query result
*/
export async function queryWithClient(text, params) {
const client = await pool.connect();
try {
return await client.query(text, params);
} finally {
client.release();
}
}
/**
* Execute a transaction
* @param {Function} callback - Async function receiving client as parameter
* @returns {Promise<any>} Transaction result
*/
export async function transaction(callback) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Get a client from the pool
* @returns {Promise<pg.PoolClient>} Pool client
*/
export async function getClient() {
return pool.connect();
}
/**
* Get pool status information
* @returns {Object} Pool statistics
*/
export function getPoolStatus() {
return {
totalCount: pool.totalCount,
idleCount: pool.idleCount,
waitingCount: pool.waitingCount,
};
}
/**
* Close the connection pool
* @returns {Promise<void>}
*/
export async function closePool() {
await pool.end();
}
// Default export the pool for direct access if needed
export default pool;
+27
View File
@@ -0,0 +1,27 @@
/**
* Read-only (по соглашению) пул к hr_bot_test для логина и справки по сотруднику.
*/
import pg from 'pg';
import { getHrPoolConfig } from './poolConfig.js';
const { Pool } = pg;
const cfg = getHrPoolConfig();
const pool = cfg ? new Pool(cfg) : null;
export function getHrPool() {
return pool;
}
/**
* @param {string} text
* @param {unknown[]} [params]
*/
export async function queryHr(text, params) {
if (!pool) {
throw new Error('HR database not configured (set HR_DATABASE_URL)');
}
return pool.query(text, params);
}
export default { getHrPool, queryHr };
+147
View File
@@ -0,0 +1,147 @@
/**
* Database Migration Script
* Executes SQL migration files in order
*/
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import pg from 'pg';
import { getPoolConfig } from './poolConfig.js';
const { Pool } = pg;
const MIGRATIONS_DIR = join(process.cwd(), 'src', 'db', 'migrations');
/**
* Get list of migration files sorted by name
*/
function getMigrationFiles() {
const files = readdirSync(MIGRATIONS_DIR)
.filter((file) => file.endsWith('.sql'))
.sort();
return files;
}
/**
* Create migrations tracking table if not exists
*/
async function ensureMigrationsTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT NOW()
)
`);
}
/**
* Get list of already executed migrations
*/
async function getExecutedMigrations(pool) {
const result = await pool.query('SELECT name FROM migrations ORDER BY name');
return result.rows.map((row) => row.name);
}
/**
* Execute a single migration file
*/
async function executeMigration(pool, filename) {
const filePath = join(MIGRATIONS_DIR, filename);
const sql = readFileSync(filePath, 'utf-8');
console.log(`Executing migration: ${filename}`);
await pool.query('BEGIN');
try {
await pool.query(sql);
await pool.query(
'INSERT INTO migrations (name) VALUES ($1)',
[filename]
);
await pool.query('COMMIT');
console.log(`✓ Migration ${filename} completed successfully`);
} catch (error) {
await pool.query('ROLLBACK');
console.error(`✗ Migration ${filename} failed:`, error.message);
throw error;
}
}
/**
* Main migration function
*/
function logMigrationError(error) {
const msg = error?.message || String(error);
console.error('\n✗ Migration failed:', msg || '(no message)');
if (error?.code) {
console.error(' PG code:', error.code);
}
if (error?.address && error?.port) {
console.error(' connect:', `${error.address}:${error.port}`);
}
if (error?.name === 'AggregateError' && Array.isArray(error.errors)) {
for (const e of error.errors) {
console.error(' —', e?.message || e);
}
}
if (error?.code === 'ECONNREFUSED') {
console.error(
' hint: проверьте, что Postgres запущен и DATABASE_URL / DB_* в backend/.env совпадают с портом (часто 5432 для Postgres_TG_Bots или 5433 для локального compose).'
);
}
if (process.env.DEBUG_MIGRATE === '1' || !msg) {
console.error(error);
}
}
async function migrate() {
const pool = new Pool(getPoolConfig());
try {
console.log('Connecting to database...');
const client = await pool.connect();
try {
await client.query('SELECT 1');
} finally {
client.release();
}
console.log('Connected to database\n');
// Ensure migrations table exists
await ensureMigrationsTable(pool);
// Get migration files and already executed migrations
const migrationFiles = getMigrationFiles();
const executedMigrations = await getExecutedMigrations(pool);
console.log(`Found ${migrationFiles.length} migration file(s)`);
console.log(`Already executed: ${executedMigrations.length} migration(s)\n`);
// Execute pending migrations
const pendingMigrations = migrationFiles.filter(
(file) => !executedMigrations.includes(file)
);
if (pendingMigrations.length === 0) {
console.log('All migrations already executed.');
} else {
console.log(`Pending migrations: ${pendingMigrations.length}\n`);
for (const filename of pendingMigrations) {
await executeMigration(pool, filename);
}
console.log(`\n✓ Successfully executed ${pendingMigrations.length} migration(s)`);
}
} catch (error) {
logMigrationError(error);
process.exit(1);
} finally {
await pool.end();
}
}
// Run migrations if this script is executed directly
migrate();
+130
View File
@@ -0,0 +1,130 @@
-- Initial database schema for clinic tests application
-- Version: 1.0
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Departments table
CREATE TABLE IF NOT EXISTS departments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- User roles enum
CREATE TYPE user_role AS ENUM ('hr', 'manager', 'employee');
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
login VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'employee',
department_id UUID REFERENCES departments(id),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create index for login lookup
CREATE INDEX IF NOT EXISTS idx_users_login ON users(login);
CREATE INDEX IF NOT EXISTS idx_users_department ON users(department_id);
-- Tests table
CREATE TABLE IF NOT EXISTS tests (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
description TEXT,
passing_threshold INTEGER DEFAULT 70,
time_limit INTEGER,
allow_back BOOLEAN DEFAULT true,
is_active BOOLEAN DEFAULT true,
is_versioned BOOLEAN DEFAULT false,
created_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Test versions table
CREATE TABLE IF NOT EXISTS test_versions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_id UUID REFERENCES tests(id) ON DELETE CASCADE,
version INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(test_id, version)
);
-- Questions table
CREATE TABLE IF NOT EXISTS questions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
question_order INTEGER NOT NULL,
has_multiple_answers BOOLEAN DEFAULT false
);
-- Answer options table
CREATE TABLE IF NOT EXISTS answer_options (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
question_id UUID REFERENCES questions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
is_correct BOOLEAN DEFAULT false,
option_order INTEGER NOT NULL
);
-- Test assignments table
CREATE TABLE IF NOT EXISTS test_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
assigned_by UUID REFERENCES users(id),
deadline DATE,
max_attempts INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Assignment targets table
CREATE TYPE target_type AS ENUM ('department', 'user');
CREATE TABLE IF NOT EXISTS test_assignment_targets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
assignment_id UUID REFERENCES test_assignments(id) ON DELETE CASCADE,
target_type target_type NOT NULL,
target_id UUID NOT NULL
);
-- Test attempts table
CREATE TYPE attempt_status AS ENUM ('in_progress', 'completed', 'expired');
CREATE TABLE IF NOT EXISTS test_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
attempt_number INTEGER NOT NULL DEFAULT 1,
status attempt_status DEFAULT 'in_progress',
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
correct_count INTEGER DEFAULT 0,
total_questions INTEGER DEFAULT 0,
passed BOOLEAN DEFAULT false,
UNIQUE(test_version_id, user_id, attempt_number)
);
-- User answers table
CREATE TABLE IF NOT EXISTS user_answers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
attempt_id UUID REFERENCES test_attempts(id) ON DELETE CASCADE,
question_id UUID REFERENCES questions(id) ON DELETE CASCADE,
selected_options UUID[] DEFAULT '{}'
);
-- Settings table
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT
);
-- Insert default admin user (password: admin123)
-- This will be done via application code to properly hash the password
@@ -0,0 +1,14 @@
-- Version chain: parent link + at most one active version per test chain
-- Aligns with docs/revision_task/card1.md (V.1)
ALTER TABLE test_versions
ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES test_versions(id) ON DELETE RESTRICT;
COMMENT ON COLUMN test_versions.parent_id IS 'Previous version in chain; NULL for first version';
-- Only one active version per tests.id (chain)
CREATE UNIQUE INDEX IF NOT EXISTS uq_test_versions_one_active_per_test
ON test_versions (test_id)
WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_test_versions_parent_id ON test_versions (parent_id);
@@ -0,0 +1,7 @@
-- Связь пользователя клиник-теста с сотрудником HR (staff_members.id) — card1 A.x
ALTER TABLE users
ADD COLUMN IF NOT EXISTS staff_id INTEGER UNIQUE;
COMMENT ON COLUMN users.staff_id IS 'id из hr_bot_test.staff_members; без дублирования кадров в clinic_tests';
CREATE INDEX IF NOT EXISTS idx_users_staff_id ON users(staff_id) WHERE staff_id IS NOT NULL;
+65
View File
@@ -0,0 +1,65 @@
/**
* Параметры пула node-postgres, единообразно с HR_TG_Bot / Postgres_TG_Bots:
* приоритет у `DATABASE_URL` (postgresql://…), иначе DB_HOST, DB_PORT, DB_NAME, …
*/
import dotenv from 'dotenv';
dotenv.config();
/**
* @param {import('pg').PoolConfig} [overrides]
* @returns {import('pg').PoolConfig}
*/
export function getPoolConfig(overrides = {}) {
const url = process.env.DATABASE_URL?.trim();
if (url) {
return {
connectionString: url,
max: parseInt(process.env.DB_POOL_MAX || '20', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMillis: parseInt(
process.env.DB_CONNECTION_TIMEOUT || '2000',
10
),
...overrides,
};
}
return {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'clinic_tests',
user: process.env.DB_USER || 'developer',
password: process.env.DB_PASSWORD || 'dev_password',
max: parseInt(process.env.DB_POOL_MAX || '20', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMillis: parseInt(
process.env.DB_CONNECTION_TIMEOUT || '2000',
10
),
...overrides,
};
}
/**
* Пул к БД `hr_bot_test` (сотрудники, users, RBAC) — отдельно от `clinic_tests`.
* Только `HR_DATABASE_URL` (без каскадного fallback на `DATABASE_URL` — путаница опасна).
* @param {import('pg').PoolConfig} [overrides]
* @returns {import('pg').PoolConfig | null}
*/
export function getHrPoolConfig(overrides = {}) {
const url = process.env.HR_DATABASE_URL?.trim();
if (!url) {
return null;
}
return {
connectionString: url,
max: parseInt(process.env.HR_DB_POOL_MAX || '5', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMillis: parseInt(
process.env.DB_CONNECTION_TIMEOUT || '2000',
10
),
...overrides,
};
}
+234
View File
@@ -0,0 +1,234 @@
/**
* Card1 V.9: интеграция с реальной `clinic_tests` — старая попытка остаётся
* на снимке версии и старых `question_id` после форка (новая версия).
*
* Запуск: `CLINIC_TESTS_INTEGRATION=1` и применённые миграции (`npm run migrate`),
* `DATABASE_URL` (или DB_*) к той же базе. Без флага тесты помечаются skip.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import bcrypt from 'bcryptjs';
import { getPoolConfig } from '../db/poolConfig.js';
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js';
const { Pool } = pg;
/** `CLINIC_TESTS_INTEGRATION=1` и успешный `SELECT 1` (без БД — skip, не fail). */
let runDb = false;
if (process.env.CLINIC_TESTS_INTEGRATION === '1') {
const probe = new Pool({
...getPoolConfig(),
connectionTimeoutMillis: 2000,
});
try {
await probe.query('SELECT 1');
runDb = true;
} catch {
runDb = false;
} finally {
await probe.end();
}
}
const qPayload = (label) => ({
title: 'V9 ' + label,
questions: [
{
text: `Q ${label}`,
question_order: 1,
hasMultipleAnswers: false,
options: [
{ text: 'yes', isCorrect: true, option_order: 1 },
{ text: 'no', isCorrect: false, option_order: 2 },
],
},
],
});
/**
* @param {import('pg').Pool} pool
* @param {string} testId
* @param {string} [exceptUserId]
*/
async function purgeTestChain(pool, testId, exceptUserId) {
await pool.query(
`DELETE FROM user_answers WHERE attempt_id IN (
SELECT id FROM test_attempts WHERE test_version_id IN (
SELECT id FROM test_versions WHERE test_id = $1
)
)`,
[testId]
);
await pool.query(
`DELETE FROM test_attempts WHERE test_version_id IN (
SELECT id FROM test_versions WHERE test_id = $1
)`,
[testId]
);
await pool.query(
`DELETE FROM answer_options WHERE question_id IN (
SELECT id FROM questions WHERE test_version_id IN (
SELECT id FROM test_versions WHERE test_id = $1
)
)`,
[testId]
);
await pool.query(
`DELETE FROM questions WHERE test_version_id IN (
SELECT id FROM test_versions WHERE test_id = $1
)`,
[testId]
);
await pool.query(`DELETE FROM test_versions WHERE test_id = $1`, [testId]);
await pool.query(`DELETE FROM tests WHERE id = $1`, [testId]);
if (exceptUserId) {
await pool.query(`DELETE FROM users WHERE id = $1`, [exceptUserId]);
}
}
test(
'V.9: без попыток два saveTestDraft — одна строка test_versions (редактирование на месте)',
{ skip: !runDb },
async () => {
const pool = new Pool(getPoolConfig());
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
let userId;
let testId;
try {
const { rows: u } = await pool.query(
`INSERT INTO users (login, password_hash, full_name, role, is_active)
VALUES ($1, $2, 'V9 in-place', 'hr', true) RETURNING id`,
[`v9p-${suffix}`, bcrypt.hashSync('x', 4)]
);
userId = u[0].id;
const c = await createTestWithVersion(pool, userId, { title: 'V9P' });
testId = c.testId;
const { rows: v0 } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
[testId]
);
const vid0 = v0[0].id;
await saveTestDraft(pool, userId, testId, qPayload('A'));
const { rows: c1 } = await pool.query(
`SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`,
[testId]
);
assert.equal(c1[0].n, 1, 'должна остаться одна версия');
const { rows: v1 } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
[testId]
);
assert.equal(
v1[0].id,
vid0,
'id активной версии не меняется при нуле попыток'
);
await saveTestDraft(pool, userId, testId, qPayload('B'));
const { rows: c2 } = await pool.query(
`SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`,
[testId]
);
assert.equal(c2[0].n, 1);
const { rows: v2 } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
[testId]
);
assert.equal(v2[0].id, vid0);
} finally {
if (userId && testId) {
await purgeTestChain(pool, testId, userId);
}
await pool.end();
}
}
);
test(
'V.9: после попытки форк — попытка и user_answers остаются на старых version_id / question_id',
{ skip: !runDb },
async () => {
const pool = new Pool(getPoolConfig());
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
let userId;
let testId;
let v1Id;
let q1Id;
let opt1Id;
let attemptId;
try {
const { rows: u } = await pool.query(
`INSERT INTO users (login, password_hash, full_name, role, is_active)
VALUES ($1, $2, 'V9 fork', 'hr', true) RETURNING id`,
[`v9f-${suffix}`, bcrypt.hashSync('x', 4)]
);
userId = u[0].id;
const c = await createTestWithVersion(pool, userId, { title: 'V9F' });
testId = c.testId;
await saveTestDraft(pool, userId, testId, qPayload('pre'));
const { rows: tv0 } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
[testId]
);
v1Id = tv0[0].id;
const { rows: qu } = await pool.query(
`SELECT id FROM questions WHERE test_version_id = $1 LIMIT 1`,
[v1Id]
);
q1Id = qu[0].id;
const { rows: op } = await pool.query(
`SELECT id FROM answer_options WHERE question_id = $1 AND is_correct = true LIMIT 1`,
[q1Id]
);
opt1Id = op[0].id;
const { rows: at } = await pool.query(
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status, correct_count, total_questions, passed)
VALUES ($1, $2, 1, 'completed', 1, 1, true) RETURNING id`,
[v1Id, userId]
);
attemptId = at[0].id;
await pool.query(
`INSERT INTO user_answers (attempt_id, question_id, selected_options) VALUES ($1, $2, $3::uuid[])`,
[attemptId, q1Id, [opt1Id]]
);
const out = await saveTestDraft(pool, userId, testId, qPayload('post-fork'));
assert.equal(out.forked, true, 'должна создаться новая версия после попытки');
const { rows: att } = await pool.query(
`SELECT test_version_id FROM test_attempts WHERE id = $1`,
[attemptId]
);
assert.equal(
att[0].test_version_id,
v1Id,
'попытка остаётся на версии, с которой проходили'
);
const { rows: ua } = await pool.query(
`SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`,
[attemptId]
);
assert.equal(ua[0].question_id, q1Id);
assert.equal(ua[0].selected_options[0], opt1Id);
const { rows: qExists } = await pool.query(
`SELECT 1 FROM questions WHERE id = $1 AND test_version_id = $2`,
[q1Id, v1Id]
);
assert.equal(qExists.length, 1, 'старый вопрос остаётся в старой версии');
const { rows: active } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
[testId]
);
assert.notEqual(active[0].id, v1Id, 'новая версия — активна');
} finally {
if (userId && testId) {
await purgeTestChain(pool, testId, userId);
}
await pool.end();
}
}
);
+41
View File
@@ -0,0 +1,41 @@
/** Тексты ответов API для пользователей (русский). */
export const RU = {
loginAndPasswordRequired: 'Укажите логин и пароль.',
invalidCredentials: 'Неверный логин или пароль.',
useHrLogin: 'Войдите через учётную запись кадровой системы (тот же логин, что в HR).',
hrDatabaseUrlMissing:
'База кадровой системы не настроена: задайте HR_DATABASE_URL на backend.',
hrDatabaseNotConfigured: 'База кадровой системы не настроена.',
noStaffForLogin:
'К учётной записи не привязан сотрудник: в HR в карточке сотрудника должно совпадать поле веб-логина (web_login) с логином входа, как в кабинете сотрудника.',
loggedOut: 'Вы вышли из системы.',
logoutFailed: 'Не удалось выйти. Повторите попытку.',
userDataFailed: 'Не удалось загрузить данные пользователя.',
loginFailed: 'Ошибка входа. Повторите попытку.',
authRequired: 'Требуется вход в систему.',
tokenInvalid: 'Сессия истекла или недействительна. Войдите снова.',
userNotFound: 'Пользователь не найден.',
authError: 'Ошибка проверки доступа.',
insufficientPermissions: 'Недостаточно прав.',
departmentAccessDenied: 'Нет доступа к этому подразделению.',
notFound: 'Не найдено.',
fileFieldRequired: 'Прикрепите файл к полю file.',
uploadFailed: 'Не удалось принять файл.',
titleRequired: 'Укажите название.',
assignmentUserRequired: 'Передайте userId (UUID) или staffId (число, сотрудник из HR).',
assignmentUserOrStaff: 'Укажите только userId, или только staffId — не оба сразу.',
testNotFound: 'Тест не найден.',
forbidden: 'Доступ запрещён.',
versionNotFound: 'Версия не найдена.',
chainActiveRequired: 'Передайте chainActive: true/false в теле запроса.',
noActiveVersion: 'Нет активной версии теста.',
internal: 'Внутренняя ошибка сервера.',
fileTooLarge: 'Файл слишком большой (максимум 10 МБ).',
unsupportedFileType:
'Неподдерживаемый формат. Допустимы: PDF, DOCX, TXT, MD.',
attemptNotFound: 'Попытка не найдена.',
attemptNotInProgress: 'Попытка уже завершена или просрочена.',
attemptNotCompleted: 'Попытка ещё не завершена — подробный разбор доступен после отправки ответов.',
testHasNoQuestions: 'В активной версии нет вопросов. Добавьте вопросы и сохраните черновик.',
invalidOptionForQuestion: 'Выбран вариант ответа, не относящийся к вопросу.',
};
+171
View File
@@ -0,0 +1,171 @@
/**
* Authorization Middleware
* JWT authentication and role-based access control
*/
import { verifyToken } from '../utils/auth.js';
import { query } from '../db/db.js';
import { RU } from '../messages/ru.js';
/**
* Extract token from cookie
* @param {Object} req - Express request object
* @returns {string|null} Token from cookie
*/
function getTokenFromCookie(req) {
return req.cookies?.token || null;
}
/**
* Middleware to authenticate JWT token
* Adds user data to req.user
*/
export async function authenticate(req, res, next) {
try {
const token = getTokenFromCookie(req);
if (!token) {
return res.status(401).json({ error: RU.authRequired });
}
const decoded = verifyToken(token);
if (!decoded) {
return res.status(401).json({ error: RU.tokenInvalid });
}
const result = await query(
'SELECT id, login, full_name, role, department_id, staff_id FROM users WHERE id = $1',
[decoded.userId]
);
if (result.rows.length === 0) {
return res.status(401).json({ error: RU.userNotFound });
}
const user = result.rows[0];
const staffId = user.staff_id ?? decoded.staffId;
req.user = {
id: user.id,
login: user.login,
fullName: user.full_name,
role: user.role,
departmentId: user.department_id,
};
if (staffId != null) {
req.user.staffId = staffId;
}
next();
} catch (error) {
console.error('Auth middleware error:', error);
return res.status(500).json({ error: RU.authError });
}
}
/**
* Middleware factory to require specific roles
* @param {string|string[]} roles - Required role(s)
* @returns {Function} Express middleware
*/
export function requireRole(roles) {
const allowedRoles = Array.isArray(roles) ? roles : [roles];
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: RU.authRequired });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: RU.insufficientPermissions });
}
next();
};
}
/**
* Middleware to require specific department access
* For managers to access their department's data
* @param {number} departmentId - Required department ID
* @returns {Function} Express middleware
*/
export function requireDepartment(departmentId) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: RU.authRequired });
}
// Admins can access all departments
if (req.user.role === 'admin') {
return next();
}
// Managers can only access their department
if (req.user.role === 'manager' && req.user.departmentId !== departmentId) {
return res.status(403).json({ error: RU.departmentAccessDenied });
}
next();
};
}
/**
* Optional authentication middleware
* Attaches user to request if token is valid, but doesn't require it
*/
export async function optionalAuth(req, res, next) {
try {
const token = getTokenFromCookie(req);
if (!token) {
req.user = null;
return next();
}
const decoded = verifyToken(token);
if (!decoded) {
req.user = null;
return next();
}
const result = await query(
'SELECT id, login, full_name, role, department_id, staff_id FROM users WHERE id = $1',
[decoded.userId]
);
if (result.rows.length === 0) {
req.user = null;
return next();
}
const user = result.rows[0];
const staffId = user.staff_id ?? decoded.staffId;
req.user = {
id: user.id,
login: user.login,
fullName: user.full_name,
role: user.role,
departmentId: user.department_id,
};
if (staffId != null) {
req.user.staffId = staffId;
}
next();
} catch (error) {
// Don't block request on auth errors
req.user = null;
next();
}
}
export default {
authenticate,
requireRole,
requireDepartment,
optionalAuth,
};
+188
View File
@@ -0,0 +1,188 @@
/**
* A.1A.4: локальный bcrypt (dev) и HR (HR_AUTH=1 + Werkzeug + staff_id)
*/
import express from 'express';
import { query } from '../db/db.js';
import { comparePassword, generateToken } from '../utils/auth.js';
import { authenticate } from '../middleware/auth.js';
import { queryHr, getHrPool } from '../db/hrPool.js';
import { mapHrRoleToApp } from '../utils/hrRoleMap.js';
import {
isHrAuthEnabled,
HR_MANAGED_PASSWORD_PLACEHOLDER,
} from '../config/authConstants.js';
import { RU } from '../messages/ru.js';
import {
getAssignmentDirectory,
getHrDepartmentNames,
} from '../services/assignmentDirectoryService.js';
import { isAssignmentFeatureEnabled } from '../config/featureFlags.js';
const router = express.Router();
router.post('/login', async (req, res) => {
try {
const { login, password } = req.body;
if (!login || !password) {
return res.status(400).json({ error: RU.loginAndPasswordRequired });
}
if (isHrAuthEnabled()) {
if (!getHrPool()) {
return res.status(500).json({ error: RU.hrDatabaseUrlMissing });
}
const u = await queryHr(
`SELECT id, username, password_hash, role
FROM users
WHERE LOWER(TRIM(username)) = LOWER(TRIM($1))`,
[login]
);
if (u.rows.length === 0 || !u.rows[0].password_hash) {
return res.status(401).json({ error: RU.invalidCredentials });
}
const row = u.rows[0];
const ok = await comparePassword(password, row.password_hash);
if (!ok) {
return res.status(401).json({ error: RU.invalidCredentials });
}
const s = await queryHr(
`SELECT id, fio FROM staff_members
WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM($1))`,
[login]
);
if (s.rows.length === 0) {
return res.status(403).json({ error: RU.noStaffForLogin });
}
const staffId = s.rows[0].id;
const fio = s.rows[0].fio || login;
const appRole = mapHrRoleToApp(row.role);
const up = await query(
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
VALUES ($1, $2, $3, $4, null, true, $5)
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, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, appRole, staffId]
);
const uu = up.rows[0];
const token = generateToken(
uu.id,
uu.role,
uu.department_id,
{ staffId: uu.staff_id }
);
res.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return res.json({
user: {
id: uu.id,
login: uu.login,
fullName: uu.full_name,
role: uu.role,
departmentId: uu.department_id,
staffId: uu.staff_id,
},
});
}
const result = await query(
'SELECT id, login, password_hash, full_name, role, department_id FROM users WHERE login = $1 AND is_active = true',
[login]
);
if (result.rows.length === 0) {
return res.status(401).json({ error: RU.invalidCredentials });
}
const user = result.rows[0];
if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) {
return res.status(401).json({ error: RU.useHrLogin });
}
const isValidPassword = await comparePassword(password, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({ error: RU.invalidCredentials });
}
const token = generateToken(user.id, user.role, user.department_id);
res.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return res.json({
user: {
id: user.id,
login: user.login,
fullName: user.full_name,
role: user.role,
departmentId: user.department_id,
staffId: null,
},
});
} catch (error) {
if (error.message?.includes('HR database not configured')) {
return res.status(500).json({ error: RU.hrDatabaseNotConfigured });
}
console.error('Login error:', error);
return res.status(500).json({ error: RU.loginFailed });
}
});
router.post('/logout', (req, res) => {
try {
res.clearCookie('token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
});
res.json({ message: RU.loggedOut });
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ error: RU.logoutFailed });
}
});
router.get('/me', authenticate, async (req, res) => {
try {
const devUi = process.env.NODE_ENV === 'development';
const assignmentUi = isAssignmentFeatureEnabled();
res.json({ user: req.user, devUi, assignmentUi });
} catch (error) {
console.error('Get current user error:', error);
res.status(500).json({ error: RU.userDataFailed });
}
});
/**
* Каталог сотрудников для назначения: HR (все) + отделы + поиск. Как `POST .../assign`: см. `isAssignmentFeatureEnabled()`.
* Query: q, department (имя отдела или __all__), clinic=all|with|without
*/
router.get('/dev/assignment-directory', authenticate, async (req, res) => {
if (!isAssignmentFeatureEnabled()) {
return res.status(404).json({ error: RU.notFound });
}
try {
const q = typeof req.query.q === 'string' ? req.query.q : '';
const department = typeof req.query.department === 'string' ? req.query.department : '';
const c = req.query.clinic;
const clinicFilter =
c === 'with' || c === 'without' ? c : 'all';
const { people, source } = await getAssignmentDirectory({
q,
department,
clinicFilter,
});
const departments = await getHrDepartmentNames();
res.json({ people, source, departments });
} catch (error) {
console.error('dev assignment directory:', error);
res.status(500).json({ error: RU.userDataFailed });
}
});
export default router;
+634
View File
@@ -0,0 +1,634 @@
/**
* V.4V.6, D.1 — API тестов, версий, импорт файла
*/
import express from 'express';
import fs from 'fs/promises';
import os from 'os';
import multer from 'multer';
import pool, { query } from '../db/db.js';
import { authenticate } from '../middleware/auth.js';
import { hasAnyAttemptForTest } from '../services/testChainService.js';
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js';
import {
getEditorContent,
getPlayContent,
submitAttempt,
getAttemptReviewForUser,
listTestAttemptsForAuthor,
} from '../services/testAttemptService.js';
import { extractTextFromFile } from '../services/documentExtractService.js';
import { generationForImportDocument } from '../services/documentGenService.js';
import { RU } from '../messages/ru.js';
import { isTestAuthor } from '../config/devAuthor.js';
import { ensureClinicUserIdForStaff } from '../services/assignmentUserService.js';
import {
queryTestsVisibleToUser,
userHasTestAccess,
} from '../services/testAccessService.js';
import { isAssignmentFeatureEnabled } from '../config/featureFlags.js';
import {
generateFullTestByShape,
generateOrRephraseQuestion,
parseAndValidateShape,
} from '../services/aiEditorService.js';
const router = express.Router();
const upload = multer({
dest: os.tmpdir(),
limits: { fileSize: 10 * 1024 * 1024 },
});
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch((err) => {
if (err.status) {
res.status(err.status).json({ error: err.message });
} else {
next(err);
}
});
};
}
/** D.1 + D.2 + D.3 (заглушка) — `POST` до маршрутов `/:id` */
router.post(
'/import/document',
authenticate,
(req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: RU.fileTooLarge });
}
return next(err);
}
next();
});
},
asyncHandler(async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: RU.fileFieldRequired });
}
const p = req.file.path;
const { mimetype, originalname } = req.file;
let size;
let extractedText;
try {
const st = await fs.stat(p);
size = st.size;
extractedText = await extractTextFromFile(mimetype, p, originalname);
} catch (e) {
try {
await fs.unlink(p);
} catch {
// ignore
}
if (e.status) {
return res.status(e.status).json({ error: e.message });
}
console.error('import document:', e);
return res.status(500).json({ error: RU.uploadFailed });
}
try {
await fs.unlink(p);
} catch {
// D.5: временный файл удалён; при ошибке extract уже удалили выше
}
const generation = await generationForImportDocument(extractedText);
res.json({
received: true,
originalName: originalname,
mime: mimetype,
size,
extractedText,
textLength: extractedText.length,
generation,
});
})
);
router.get(
'/',
authenticate,
asyncHandler(async (req, res) => {
const { rows } = await queryTestsVisibleToUser(req.user.id);
const { rows: hidden } = await query(
`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 = $1
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`,
[req.user.id]
);
res.json({ tests: rows, hiddenByYou: hidden });
})
);
router.post(
'/',
authenticate,
asyncHandler(async (req, res) => {
const { title, description } = req.body;
if (!title || typeof title !== 'string') {
return res.status(400).json({ error: RU.titleRequired });
}
const out = await createTestWithVersion(pool, req.user.id, {
title,
description,
});
res.status(201).json(out);
})
);
/**
* V.8: краткая карточка цепочки — одна строка (активная версия), без дублей.
* Не-автор не видит скрытую с общего списка цепочку (кроме прямой ссылки автора).
*/
router.get(
'/:id/summary',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { rows } = await query(
`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 = $1`,
[testId]
);
if (!rows.length) {
return res.status(404).json({ error: RU.testNotFound });
}
const row = rows[0];
const isAuthor = isTestAuthor(row.created_by, req.user.id);
if (row.chain_active === false && !isAuthor) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isAuthor) {
const acc = await userHasTestAccess(req.user.id, testId);
if (!acc.ok) {
return res.status(404).json({ error: RU.testNotFound });
}
}
res.json({
test: {
id: row.id,
title: row.title,
description: row.description,
passingThreshold: row.passing_threshold,
chainActive: row.chain_active,
activeVersionId: row.active_version_id,
version: row.version,
createdAt: row.created_at,
updatedAt: row.updated_at,
createdBy: row.created_by,
authorFullName: row.author_full_name,
},
isAuthor,
hasActiveVersion: row.active_version_id != null,
});
})
);
router.get(
'/:id/versions',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { rows: t } = await query(
`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 = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isTestAuthor(t[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
const testRow = t[0];
const { rows } = await query(
`SELECT id, version, is_active, parent_id, created_at
FROM test_versions WHERE test_id = $1 ORDER BY version`,
[testId]
);
const hasAttempts = await hasAnyAttemptForTest(pool, testId);
res.json({
test: {
id: testRow.id,
title: testRow.title,
description: testRow.description,
chainActive: testRow.is_active,
createdAt: testRow.created_at,
updatedAt: testRow.updated_at,
createdBy: testRow.created_by,
authorFullName: testRow.author_full_name,
},
versions: rows,
hasAttempts,
});
})
);
router.get(
'/:id/editor',
authenticate,
asyncHandler(async (req, res) => {
const out = await getEditorContent(pool, req.user.id, req.params.id);
res.json(out);
})
);
/**
* ИИ: заполнить тест по текущей сетке (число вопросов = len(shape), варианты = optionsCount).
* Только автор. Тело: { testTitle?, testDescription?, shape: [{ optionsCount, hasMultipleAnswers }] }
*/
router.post(
'/:id/ai/generate-test',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { rows: t } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isTestAuthor(t[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
const b = req.body && typeof req.body === 'object' ? req.body : {};
const shape = parseAndValidateShape(b.shape);
const testTitle = typeof b.testTitle === 'string' ? b.testTitle : '';
const testDescription = typeof b.testDescription === 'string' ? b.testDescription : '';
const draft = await generateFullTestByShape(testTitle, testDescription, shape);
res.json({ ok: true, draft });
})
);
/**
* ИИ: один вопрос — пустой текст → сгенерировать вопрос и варианты; иначе переформулировать только текст.
* Только автор. Тело: { testTitle?, testDescription?, questionText, optionsCount, hasMultipleAnswers }
*/
router.post(
'/:id/ai/generate-question',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { rows: tr } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!tr.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isTestAuthor(tr[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
const b = req.body && typeof req.body === 'object' ? req.body : {};
const testTitle = typeof b.testTitle === 'string' ? b.testTitle : '';
const testDescription = typeof b.testDescription === 'string' ? b.testDescription : '';
const questionText = typeof b.questionText === 'string' ? b.questionText : '';
const optionsCount = b.optionsCount;
const hasMultipleAnswers = Boolean(b.hasMultipleAnswers);
const out = await generateOrRephraseQuestion(
testTitle,
testDescription,
questionText,
optionsCount,
hasMultipleAnswers
);
res.json({ ok: true, ...out });
})
);
router.post(
'/:id/versions/:vid/activate',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const versionId = req.params.vid;
const { rows: t } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isTestAuthor(t[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
const { rows: v } = await query(
`SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`,
[testId, versionId]
);
if (!v.length) {
return res.status(404).json({ error: RU.versionNotFound });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`UPDATE test_versions SET is_active = false WHERE test_id = $1`,
[testId]
);
await client.query(
`UPDATE test_versions SET is_active = true WHERE id = $1`,
[versionId]
);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
res.json({ ok: true, activeVersionId: versionId });
})
);
router.patch(
'/:id',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { isActive, chainActive } = req.body;
const chain = chainActive ?? isActive;
if (typeof chain !== 'boolean') {
return res.status(400).json({ error: RU.chainActiveRequired });
}
const { rows: t } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isTestAuthor(t[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
await query(
`UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
[testId, chain]
);
res.json({ id: testId, chainActive: chain });
})
);
/**
* Пакетное назначение — `test_assignments` + `test_assignment_targets` (user).
* Включение: `development` или `CLINIC_ASSIGNMENT_ENABLED=1`. Только автор. Тело: `userIds`, `staffIds` и/или одиночные `userId` / `staffId`.
*/
router.post(
'/:id/assign',
authenticate,
asyncHandler(async (req, res) => {
if (!isAssignmentFeatureEnabled()) {
return res.status(404).json({ error: RU.notFound });
}
const testId = req.params.id;
const b = req.body && typeof req.body === 'object' ? req.body : {};
const userIds = new Set();
if (Array.isArray(b.userIds)) {
for (const u of b.userIds) {
if (typeof u === 'string' && u.trim()) {
userIds.add(u.trim());
}
}
}
if (Array.isArray(b.staffIds)) {
for (const s of b.staffIds) {
const n = Number(s);
if (Number.isFinite(n) && n >= 1) {
userIds.add(await ensureClinicUserIdForStaff(pool, n));
}
}
}
if (userIds.size === 0) {
if (b.staffId != null && b.userId) {
return res.status(400).json({ error: RU.assignmentUserOrStaff });
}
if (b.staffId != null) {
const sid = Number(b.staffId);
if (Number.isNaN(sid)) {
return res.status(400).json({ error: RU.assignmentUserRequired });
}
userIds.add(await ensureClinicUserIdForStaff(pool, sid));
} else if (typeof b.userId === 'string' && b.userId.trim()) {
userIds.add(b.userId.trim());
}
}
if (userIds.size === 0) {
return res.status(400).json({ error: RU.assignmentUserRequired });
}
const { rows: t } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!isTestAuthor(t[0].created_by, req.user.id)) {
return res.status(403).json({ error: RU.forbidden });
}
const { rows: tv } = await query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`,
[testId]
);
if (!tv.length) {
return res.status(400).json({ error: RU.noActiveVersion });
}
for (const uid of userIds) {
const { rows: u } = await query(
`SELECT id FROM users WHERE id = $1 AND is_active = true`,
[uid]
);
if (!u.length) {
return res.status(400).json({ error: RU.userNotFound });
}
}
const versionId = tv[0].id;
const client = await pool.connect();
let assignmentId;
try {
await client.query('BEGIN');
const { rows: ins } = await client.query(
`INSERT INTO test_assignments (test_version_id, assigned_by, max_attempts)
VALUES ($1, $2, 5) RETURNING id`,
[versionId, req.user.id]
);
assignmentId = ins[0].id;
for (const uid of userIds) {
await client.query(
`INSERT INTO test_assignment_targets (assignment_id, target_type, target_id)
VALUES ($1, 'user', $2)`,
[assignmentId, uid]
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
res
.status(201)
.json({ ok: true, assignmentId, count: userIds.size });
})
);
router.post(
'/:id/draft',
authenticate,
asyncHandler(async (req, res) => {
const out = await saveTestDraft(pool, req.user.id, req.params.id, req.body);
res.json(out);
})
);
router.post(
'/:id/attempts/start',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const acc = await userHasTestAccess(req.user.id, testId);
if (!acc.ok) {
return res.status(404).json({ error: RU.testNotFound });
}
const { rows: tv } = await query(
`SELECT tv.id AS test_version_id
FROM test_versions tv
WHERE tv.test_id = $1 AND tv.is_active = true LIMIT 1`,
[testId]
);
if (!tv.length) {
return res.status(404).json({ error: RU.noActiveVersion });
}
const testVersionId = tv[0].test_version_id;
const { rows: mx } = await query(
`SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts
WHERE test_version_id = $1 AND user_id = $2`,
[testVersionId, req.user.id]
);
const nextN = (mx[0].n || 0) + 1;
const { rows: a } = await query(
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status)
VALUES ($1, $2, $3, 'in_progress')
RETURNING id, test_version_id, user_id, attempt_number, status, started_at`,
[testVersionId, req.user.id, nextN]
);
res.status(201).json({ attempt: a[0] });
})
);
/** Только автор: список попыток по цепочке (все версии) */
router.get(
'/:id/attempts',
authenticate,
asyncHandler(async (req, res) => {
const rows = await listTestAttemptsForAuthor(pool, req.user.id, req.params.id);
res.json({
attempts: rows.map((r) => ({
id: r.id,
userId: r.user_id,
status: r.status,
attemptNumber: r.attempt_number,
startedAt: r.started_at,
completedAt: r.completed_at,
correctCount: r.correct_count,
totalQuestions: r.total_questions,
passed: r.passed,
testVersion: r.test_version,
attempterName: r.attempter_name,
attempterLogin: r.attempter_login,
})),
});
})
);
/** Разбор завершённой попытки: владелец или автор */
router.get(
'/:id/attempts/:aid/review',
authenticate,
asyncHandler(async (req, res) => {
const review = await getAttemptReviewForUser(
pool,
req.user.id,
req.params.id,
req.params.aid
);
res.json(review);
})
);
router.get(
'/:id/attempts/:aid/play',
authenticate,
asyncHandler(async (req, res) => {
const out = await getPlayContent(pool, req.user.id, req.params.id, req.params.aid);
res.json(out);
})
);
router.post(
'/:id/attempts/:aid/submit',
authenticate,
asyncHandler(async (req, res) => {
const out = await submitAttempt(
pool,
req.user.id,
req.params.id,
req.params.aid,
req.body?.answers
);
res.json(out);
})
);
router.get(
'/:id/chain-info',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const acc = await userHasTestAccess(req.user.id, testId);
if (acc.notFound) {
return res.status(404).json({ error: RU.testNotFound });
}
if (!acc.ok) {
return res.status(404).json({ error: RU.testNotFound });
}
const { rows: tr } = await query(
`SELECT t.is_active AS chain_active FROM tests t WHERE t.id = $1`,
[testId]
);
if (!tr.length) {
return res.status(404).json({ error: RU.testNotFound });
}
if (tr[0].chain_active === false) {
const { rows: auth } = await query(
`SELECT created_by FROM tests WHERE id = $1`,
[testId]
);
if (!isTestAuthor(auth[0].created_by, req.user.id)) {
return res.status(404).json({ error: RU.testNotFound });
}
}
const has = await hasAnyAttemptForTest(pool, testId);
res.json({ testId, hasAnyAttempt: has });
})
);
export default router;
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from './app.js';
const app = createApp();
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});
+197
View File
@@ -0,0 +1,197 @@
/**
* Генерация теста/вопроса в редакторе: строгая сетка (число вопросов и вариантов) из UI.
*/
import { getLlmConfig, chatCompletionTextContent } from './llmClient.js';
import {
parseJsonFromLlmText,
validateAndNormalizeDraft,
} from './documentGenService.js';
/**
* @param {unknown} s
* @returns {{ optionsCount: number, hasMultipleAnswers: boolean }[]}
*/
export function parseAndValidateShape(s) {
if (!Array.isArray(s) || s.length === 0) {
const e = new Error('Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].');
e.status = 400;
throw e;
}
if (s.length > 40) {
const e = new Error('Не более 40 вопросов за раз.');
e.status = 400;
throw e;
}
return s.map((row, i) => {
if (!row || typeof row !== 'object') {
const e = new Error(`shape[${i}]: ожидается объект.`);
e.status = 400;
throw e;
}
const n = Math.floor(Number((/** @type {any} */ (row)).optionsCount));
const hasMultipleAnswers = Boolean((/** @type {any} */ (row)).hasMultipleAnswers);
if (!Number.isFinite(n) || n < 2 || n > 12) {
const e = new Error(`shape[${i}]: optionsCount от 2 до 12.`);
e.status = 400;
throw e;
}
return { optionsCount: n, hasMultipleAnswers };
});
}
/**
* @param {any} o parsed draft
* @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape
*/
export function assertDraftMatchesShape(o, shape) {
if (!o?.questions || !Array.isArray(o.questions)) {
const e = new Error('В ответе нет questions.');
e.code = 'llm_shape';
throw e;
}
if (o.questions.length !== shape.length) {
const e = new Error(
`Ожидалось вопросов: ${shape.length}, в ответе: ${o.questions.length}.`
);
e.code = 'llm_shape';
throw e;
}
for (let i = 0; i < shape.length; i++) {
const q = o.questions[i];
const sh = shape[i];
if (!q?.options || !Array.isArray(q.options)) {
const e = new Error(`Вопрос ${i + 1}: нет options.`);
e.code = 'llm_shape';
throw e;
}
if (q.options.length !== sh.optionsCount) {
const e = new Error(
`Вопрос ${i + 1}: ожидалось вариантов ${sh.optionsCount}, в ответе: ${q.options.length}.`
);
e.code = 'llm_shape';
throw e;
}
if (Boolean(q.hasMultipleAnswers) !== sh.hasMultipleAnswers) {
const e = new Error(
`Вопрос ${i + 1}: hasMultipleAnswers должен быть ${sh.hasMultipleAnswers}.`
);
e.code = 'llm_shape';
throw e;
}
}
}
/**
* @param {string} testTitle
* @param {string} testDescription
* @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape
*/
export async function generateFullTestByShape(testTitle, testDescription, shape) {
const cfg = getLlmConfig();
if (!cfg) {
const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.');
/** @type {any} */ (e).status = 503;
throw e;
}
const title = (testTitle || '').trim() || 'Тест';
const desc = (testDescription || '').trim();
const lines = shape.map(
(sh, i) =>
`Вопрос ${i + 1}: ровно ${sh.optionsCount} вариантов ответа; ${
sh.hasMultipleAnswers
? 'несколько вариантов помечены как верные (hasMultipleAnswers: true).'
: 'ровно один верный вариант (hasMultipleAnswers: false).'
}`
);
const system =
'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. Схема: {"title": string, "description": string (может быть пустой строкой), "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}.';
const user = `Составь тест по теме.
Название (можно уточнить, но смысл сохранить): ${title}
Краткое описание / контекст темы: ${desc || 'не указано; придумай согласованную тему с названием.'}
Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше):
${lines.join('\n')}
Правила: варианты — осмысленные, по теме; отметь isCorrect согласно hasMultipleAnswers; для одного правильного — ровна одна true.`;
const raw = await chatCompletionTextContent(cfg, system, user, 0.35);
const parsed = parseJsonFromLlmText(raw);
const draft = validateAndNormalizeDraft(parsed);
assertDraftMatchesShape({ questions: draft.questions }, shape);
return {
title: draft.title,
description: draft.description,
questions: draft.questions,
};
}
/**
* Пустой вопрос → сгенерировать формулировки; непустой → переформулировать только текст вопроса.
* @param {string} testTitle
* @param {string} testDescription
* @param {string} questionText
* @param {number} optionsCount
* @param {boolean} hasMultipleAnswers
*/
export async function generateOrRephraseQuestion(
testTitle,
testDescription,
questionText,
optionsCount,
hasMultipleAnswers
) {
const cfg = getLlmConfig();
if (!cfg) {
const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.');
/** @type {any} */ (e).status = 503;
throw e;
}
const n = Math.floor(Number(optionsCount));
if (!Number.isFinite(n) || n < 2 || n > 12) {
const e = new Error('optionsCount: от 2 до 12.');
e.status = 400;
throw e;
}
const topic = `${(testTitle || '').trim() || 'Тест'}. ${(testDescription || '').trim()}`.trim();
const qt = (questionText || '').trim();
if (qt) {
const system =
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости от сложности исходного черновика, без вариантов ответа.';
const user = `Тема теста: ${topic}\n\nИсходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n${qt}`;
const raw = await chatCompletionTextContent(cfg, system, user, 0.3);
const parsed = parseJsonFromLlmText(raw);
const text = String((/** @type {any} */ (parsed)).text ?? '').trim();
if (!text) {
const e = new Error('Пустой text в ответе модели.');
e.code = 'llm_shape';
throw e;
}
return { mode: 'rephrase', text };
}
const system =
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}. Все на русском.';
const user = `Тема теста: ${topic}
Сформулируй ОДИН вопрос по этой теме с ровно ${n} вариантами ответа. hasMultipleAnswers = ${
hasMultipleAnswers
? 'true (несколько верных, минимум 2 isCorrect: true, остальные false).'
: 'false (ровно один isCorrect: true).'
}`;
const raw = await chatCompletionTextContent(cfg, system, user, 0.35);
const parsed = parseJsonFromLlmText(raw);
const shape = [{ optionsCount: n, hasMultipleAnswers: Boolean(hasMultipleAnswers) }];
assertDraftMatchesShape({ questions: [parsed] }, shape);
const draft = validateAndNormalizeDraft({
title: 'временно',
questions: [parsed],
});
return {
mode: 'full',
text: draft.questions[0].text,
hasMultipleAnswers: draft.questions[0].hasMultipleAnswers,
options: draft.questions[0].options,
};
}
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseAndValidateShape } from './aiEditorService.js';
test('parseAndValidateShape: валидный ввод', () => {
const s = parseAndValidateShape([
{ optionsCount: 3, hasMultipleAnswers: false },
{ optionsCount: 2, hasMultipleAnswers: true },
]);
assert.equal(s.length, 2);
assert.equal(s[0].optionsCount, 3);
assert.equal(s[1].hasMultipleAnswers, true);
});
test('parseAndValidateShape: пусто — ошибка', () => {
assert.throws(
() => parseAndValidateShape([]),
/Передайте/
);
});
@@ -0,0 +1,125 @@
/**
* Каталог для назначения: HR (staff_members + отделы) + учётки clinic_tests по staff_id.
* Две БД — данные сливаем в Node.
*/
import { getHrPool, queryHr } from '../db/hrPool.js';
import pool from '../db/db.js';
/**
* @param {{ q?: string, department?: string, clinicFilter?: 'all' | 'with' | 'without' }} p
*/
export async function getAssignmentDirectory(p) {
const { rows: clinicByStaff } = await pool.query(
`SELECT id, staff_id, login, full_name
FROM users
WHERE is_active = true AND staff_id IS NOT NULL`
);
const byStaff = new Map();
for (const r of clinicByStaff) {
byStaff.set(r.staff_id, { clinicUserId: r.id, login: r.login, fullName: r.full_name });
}
if (!getHrPool()) {
const { rows } = await pool.query(
`SELECT u.id, u.staff_id, u.full_name AS fio, u.login AS "webLogin"
FROM users u WHERE u.is_active = true ORDER BY u.full_name NULLS LAST, u.login`
);
let people = rows.map((r) => ({
staffId: r.staff_id,
fio: r.fio || r.webLogin,
webLogin: r.webLogin,
departments: '',
clinicUserId: r.id,
}));
const qx = (p.q || '').trim().toLowerCase();
if (qx) {
people = people.filter(
(x) =>
(x.fio && x.fio.toLowerCase().includes(qx)) ||
(x.webLogin && x.webLogin.toLowerCase().includes(qx)) ||
(x.clinicUserId && x.clinicUserId.toLowerCase().includes(qx))
);
}
return { people, source: 'clinic' };
}
const q = (p.q || '').trim();
const dept = (p.department || '').trim();
const clinicFilter = p.clinicFilter || 'all';
const { rows: staffRows } = await queryHr(
`SELECT sm.id AS staff_id, sm.fio, sm.web_login
FROM staff_members sm`,
[]
);
if (!staffRows.length) {
return { people: [], source: 'hr' };
}
const { rows: edRows } = await queryHr(
`SELECT staff_id, department FROM employees_departments
WHERE department IS NOT NULL AND trim(department) <> ''`,
[]
);
const deptsByStaff = new Map();
for (const r of edRows) {
if (!deptsByStaff.has(r.staff_id)) {
deptsByStaff.set(r.staff_id, new Set());
}
deptsByStaff.get(r.staff_id).add(r.department);
}
let people = staffRows.map((r) => {
const dset = deptsByStaff.get(r.staff_id);
const departments = dset
? [...dset].sort((a, b) => a.localeCompare(b, 'ru')).join(', ')
: '';
const cu = byStaff.get(r.staff_id) || null;
return {
staffId: r.staff_id,
fio: r.fio || '—',
webLogin: r.web_login,
departments,
clinicUserId: cu ? cu.clinicUserId : null,
};
});
if (q) {
const low = q.toLowerCase();
people = people.filter(
(x) =>
(x.fio && x.fio.toLowerCase().includes(low)) ||
(x.webLogin && x.webLogin.toLowerCase().includes(low))
);
}
if (dept && dept !== '__all__') {
people = people.filter((x) => {
const s = deptsByStaff.get(x.staffId);
return s && s.has(dept);
});
}
if (clinicFilter === 'with') {
people = people.filter((x) => x.clinicUserId != null);
} else if (clinicFilter === 'without') {
people = people.filter((x) => x.clinicUserId == null);
}
people.sort((a, b) => (a.fio || '').localeCompare(b.fio || '', 'ru'));
return { people, source: 'hr' };
}
/**
* @returns {Promise<string[]>}
*/
export async function getHrDepartmentNames() {
if (!getHrPool()) {
return [];
}
const { rows } = await queryHr(
`SELECT DISTINCT TRIM(department) AS d
FROM employees_departments
WHERE department IS NOT NULL AND TRIM(department) <> ''
ORDER BY 1`
);
return rows.map((r) => r.d).filter(Boolean);
}
@@ -0,0 +1,64 @@
/**
* Создать/найти запись `clinic_tests.users` по staff_id (HR), чтобы назначить target_id = uuid.
*/
import { queryHr, getHrPool } from '../db/hrPool.js';
import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js';
import { RU } from '../messages/ru.js';
/**
* @param {import('pg').Pool} pool
* @param {number} staffId
* @returns {Promise<string>} uuid в clinic_tests.users
*/
export async function ensureClinicUserIdForStaff(pool, staffId) {
const n = Math.floor(Number(staffId));
if (!Number.isFinite(n) || n < 1) {
const e = new Error(RU.assignmentUserRequired);
e.status = 400;
throw e;
}
const { rows: ex } = await pool.query(
`SELECT id FROM users WHERE staff_id = $1 AND is_active = true LIMIT 1`,
[n]
);
if (ex.length) {
return ex[0].id;
}
if (!getHrPool()) {
const e = new Error('Нет HR БД: нельзя завести учётку по staff_id.');
e.status = 400;
throw e;
}
const { rows: st } = await queryHr(
`SELECT id, fio, web_login FROM staff_members WHERE id = $1`,
[n]
);
if (!st.length) {
const e = new Error('Сотрудник не найден в HR.');
e.status = 400;
throw e;
}
const fio = st[0].fio || `staff #${n}`;
const rawLogin = (st[0].web_login && String(st[0].web_login).trim()) || null;
let login = rawLogin;
if (!login) {
login = `staff_${n}@clinic.local`;
}
const { rows: taken } = await pool.query(
`SELECT 1 FROM users WHERE LOWER(TRIM(login)) = LOWER(TRIM($1)) AND (staff_id IS NULL OR staff_id <> $2) LIMIT 1`,
[login, n]
);
if (taken.length) {
login = `staff_${n}@clinic.local`;
}
const ins = await pool.query(
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
VALUES ($1, $2, $3, 'employee', null, true, $4)
ON CONFLICT (staff_id) DO UPDATE SET
full_name = EXCLUDED.full_name,
is_active = true
RETURNING id`,
[login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, n]
);
return ins.rows[0].id;
}
@@ -0,0 +1,66 @@
/**
* D.2 — извлечение текста из PDF, DOCX, TXT (см. card1.md).
*/
import { readFile } from 'fs/promises';
import { createRequire } from 'node:module';
import mammoth from 'mammoth';
import { RU } from '../messages/ru.js';
const require = createRequire(import.meta.url);
const pdfParse = require('pdf-parse');
/** @param {string} mime @param {string} [originalName] */
export function resolveDocumentKind(mime, originalName = '') {
const m = (mime || '').toLowerCase();
const n = originalName.toLowerCase();
if (m === 'application/pdf' || n.endsWith('.pdf')) {
return 'pdf';
}
if (
m ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
n.endsWith('.docx')
) {
return 'docx';
}
if (m === 'text/plain' || m === 'text/markdown' || n.endsWith('.txt') || n.endsWith('.md')) {
return 'text';
}
return null;
}
/**
* @param {string} mimetype
* @param {string} filePath
* @param {string} [originalName]
* @returns {Promise<string>} извлечённый плоский текст
*/
export async function extractTextFromFile(mimetype, filePath, originalName) {
const kind = resolveDocumentKind(mimetype, originalName);
if (!kind) {
const e = new Error(RU.unsupportedFileType);
e.status = 400;
throw e;
}
const buf = await readFile(filePath);
return extractTextFromBuffer(kind, buf);
}
/**
* @param {'pdf'|'docx'|'text'} kind
* @param {Buffer} buffer
*/
export async function extractTextFromBuffer(kind, buffer) {
if (kind === 'text') {
return buffer.toString('utf8');
}
if (kind === 'docx') {
const { value } = await mammoth.extractRawText({ buffer });
return (value || '').replace(/\r\n/g, '\n').trim();
}
if (kind === 'pdf') {
const data = await pdfParse(buffer);
return ((data && data.text) || '').replace(/\r\n/g, '\n').trim();
}
return '';
}
@@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
extractTextFromBuffer,
resolveDocumentKind,
} from './documentExtractService.js';
test('resolveDocumentKind: PDF по MIME и по имени', () => {
assert.equal(resolveDocumentKind('application/pdf'), 'pdf');
assert.equal(resolveDocumentKind('', 'X.PDF'), 'pdf');
assert.equal(resolveDocumentKind('application/octet-stream', 'a.pdf'), 'pdf');
});
test('resolveDocumentKind: docx, txt, неизвестно', () => {
assert.equal(
resolveDocumentKind(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
),
'docx'
);
assert.equal(resolveDocumentKind('text/plain', 'x.txt'), 'text');
assert.equal(resolveDocumentKind('', 'readme.md'), 'text');
assert.equal(resolveDocumentKind('image/png'), null);
assert.equal(resolveDocumentKind('application/octet-stream', 'a.exe'), null);
});
test('extractTextFromBuffer: text UTF-8', async () => {
const t = await extractTextFromBuffer(
'text',
Buffer.from('Проверка D.2', 'utf8')
);
assert.equal(t, 'Проверка D.2');
});
+176
View File
@@ -0,0 +1,176 @@
/**
* D.3 — генерация структуры теста из извлечённого текста (OpenAI-совместимый Chat Completions).
* Ключ: DEEPSEEK_API_KEY (по умолчанию api.deepseek.com) или OPENAI_API_KEY. Опц.: LLM_BASE_URL, LLM_MODEL.
*/
import { getLlmConfig, chatCompletionTextContent } from './llmClient.js';
const MAX_EXTRACT_CHARS = 14000;
/**
* @param {string} text
* @returns {string}
*/
export function parseJsonFromLlmText(text) {
if (typeof text !== 'string' || !text.trim()) {
const e = new Error('Пустой ответ модели.');
e.code = 'llm_empty';
throw e;
}
let t = text.trim();
const fence = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(t);
if (fence) {
t = fence[1].trim();
}
let parsed;
try {
parsed = JSON.parse(t);
} catch (err) {
const e = new Error('Ответ модели не является корректным JSON.');
e.code = 'llm_json_parse';
throw e;
}
return parsed;
}
/**
* @param {unknown} o
* @returns {{ title: string, description: string | null, questions: Array<{ text: string, hasMultipleAnswers: boolean, options: Array<{ text: string, isCorrect: boolean }> }> }}
*/
export function validateAndNormalizeDraft(o) {
if (!o || typeof o !== 'object') {
const e = new Error('JSON не содержит объекта с данными.');
e.code = 'llm_shape';
throw e;
}
const title = String((/** @type {any} */ (o)).title ?? '').trim();
if (!title) {
const e = new Error('В ответе нет поля title.');
e.code = 'llm_shape';
throw e;
}
const desc = (/** @type {any} */ (o)).description;
const description =
desc != null && String(desc).trim() ? String(desc).trim() : null;
const rawQs = (/** @type {any} */ (o)).questions;
if (!Array.isArray(rawQs) || rawQs.length === 0) {
const e = new Error('В ответе нет вопросов (questions).');
e.code = 'llm_shape';
throw e;
}
if (rawQs.length > 40) {
const e = new Error('Слишком много вопросов в ответе (макс. 40).');
e.code = 'llm_shape';
throw e;
}
const questions = rawQs.map((q, i) => {
if (!q || typeof q !== 'object') {
const e = new Error(`Вопрос ${i + 1}: неверный формат.`);
e.code = 'llm_shape';
throw e;
}
const text = String((/** @type {any} */ (q)).text ?? '').trim();
if (!text) {
const e = new Error(`Вопрос ${i + 1}: пустой текст.`);
e.code = 'llm_shape';
throw e;
}
const hasMultipleAnswers = Boolean(
(/** @type {any} */ (q)).hasMultipleAnswers
);
const rawOpts = (/** @type {any} */ (q)).options;
if (!Array.isArray(rawOpts) || rawOpts.length < 2) {
const e = new Error(`Вопрос ${i + 1}: нужны минимум 2 варианта ответа.`);
e.code = 'llm_shape';
throw e;
}
if (rawOpts.length > 12) {
const e = new Error(`Вопрос ${i + 1}: слишком много вариантов (макс. 12).`);
e.code = 'llm_shape';
throw e;
}
const options = rawOpts.map((op, j) => {
if (!op || typeof op !== 'object') {
const e = new Error(
`Вопрос ${i + 1}, вариант ${j + 1}: неверный формат.`
);
e.code = 'llm_shape';
throw e;
}
return {
text: String((/** @type {any} */ (op)).text ?? '').trim() || `Вариант ${j + 1}`,
isCorrect: Boolean((/** @type {any} */ (op)).isCorrect),
};
});
const correctN = options.filter((x) => x.isCorrect).length;
if (correctN === 0) {
const e = new Error(
`Вопрос ${i + 1}: отметьте минимум один правильный вариант.`
);
e.code = 'llm_shape';
throw e;
}
if (!hasMultipleAnswers && correctN > 1) {
const e = new Error(
`Вопрос ${i + 1}: с одним правильным ответом должен быть один вариант isCorrect, либо укажите hasMultipleAnswers: true.`
);
e.code = 'llm_shape';
throw e;
}
return { text, hasMultipleAnswers, options };
});
return { title, description, questions };
}
/**
* D.1/D.2/D.3 — ответ для POST /import/document (клиент не получает сырые ключи).
* @param {string} extractedText
*/
export async function generationForImportDocument(extractedText) {
const text = (extractedText || '').trim();
if (!text) {
return {
available: false,
message: 'Нет извлечённого текста — нечего передавать в модель.',
};
}
const cfg = getLlmConfig();
if (!cfg) {
return {
available: false,
message:
'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY (см. backend/.env.example). Ниже — превью текста; можно вставить в черновик вручную.',
textPreview: text.slice(0, 4000),
};
}
const slice =
text.length > MAX_EXTRACT_CHARS
? `${text.slice(0, MAX_EXTRACT_CHARS)}\n\n[…фрагмент обрезан для API]`
: text;
try {
const system =
'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом без пояснений. Схема: {"title": string, "description"?: string, "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. Для одиночного выбора ровно один isCorrect: true. Текст и формулировки — на русском, по содержанию входного материала.';
const user =
'Составь тест с вопросами с одним или несколькими правильными ответами на основе текста:\n\n' + slice;
const raw = await chatCompletionTextContent(cfg, system, user, 0.25);
const parsed = parseJsonFromLlmText(raw);
const draft = validateAndNormalizeDraft(parsed);
return {
available: true,
message: `Сгенерировано: «${draft.title}», вопросов: ${draft.questions.length}. Нажмите «Применить сгенерированный черновик» ниже.`,
draft: {
title: draft.title,
description: draft.description,
questions: draft.questions,
},
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const code = e instanceof Error && 'code' in e ? (/** @type {any} */ (e)).code : 'llm_error';
return {
available: false,
message: `Генерация не удалась: ${msg}`,
errorCode: code,
textPreview: text.slice(0, 4000),
};
}
}
@@ -0,0 +1,63 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
parseJsonFromLlmText,
validateAndNormalizeDraft,
} from './documentGenService.js';
test('parseJsonFromLlmText: чистый JSON', () => {
const o = parseJsonFromLlmText('{"title":"T","questions":[{"text":"Q","options":[{"text":"a","isCorrect":true},{"text":"b","isCorrect":false}]}]}');
assert.equal(o.title, 'T');
assert.equal(o.questions.length, 1);
});
test('parseJsonFromLlmText: JSON в markdown-заборе', () => {
const raw = '```json\n{"title":"X","questions":[{"text":"1","options":[{"text":"+","isCorrect":true},{"text":"-","isCorrect":false}]}]}\n```';
const o = parseJsonFromLlmText(raw);
assert.equal(o.title, 'X');
});
test('parseJsonFromLlmText: невалидный JSON — ошибка', () => {
assert.throws(
() => parseJsonFromLlmText('not json'),
/JSON/i
);
});
test('validateAndNormalizeDraft: валидный черновик', () => {
const d = validateAndNormalizeDraft({
title: ' Экзамен ',
description: ' оп ',
questions: [
{
text: '2+2?',
hasMultipleAnswers: false,
options: [
{ text: '4', isCorrect: true },
{ text: '5', isCorrect: false },
],
},
],
});
assert.equal(d.title, 'Экзамен');
assert.equal(d.description, 'оп');
assert.equal(d.questions[0].options.length, 2);
});
test('validateAndNormalizeDraft: нет title', () => {
assert.throws(
() =>
validateAndNormalizeDraft({
questions: [
{
text: 'Q',
options: [
{ text: 'a', isCorrect: true },
{ text: 'b', isCorrect: false },
],
},
],
}),
/title/i
);
});
+98
View File
@@ -0,0 +1,98 @@
/**
* OpenAI-совместимый Chat Completions. Общий для импорта и редактора.
*/
/**
* @returns {null | { provider: string, apiKey: string, baseUrl: string, model: string }}
*/
export function getLlmConfig() {
if (process.env.DEEPSEEK_API_KEY) {
return {
provider: 'deepseek',
apiKey: process.env.DEEPSEEK_API_KEY,
baseUrl: (process.env.LLM_BASE_URL || 'https://api.deepseek.com/v1').replace(
/\/+$/,
''
),
model: process.env.LLM_MODEL || 'deepseek-chat',
};
}
if (process.env.OPENAI_API_KEY) {
return {
provider: 'openai',
apiKey: process.env.OPENAI_API_KEY,
baseUrl: (process.env.LLM_BASE_URL || 'https://api.openai.com/v1').replace(
/\/+$/,
''
),
model: process.env.LLM_MODEL || 'gpt-4o-mini',
};
}
return null;
}
/**
* @param {{ baseUrl: string, apiKey: string, model: string }} cfg
* @param {string} system
* @param {string} user
* @param {number} [temperature]
* @returns {Promise<string>} raw assistant message
*/
export async function chatCompletionTextContent(cfg, system, user, temperature = 0.25) {
const url = `${cfg.baseUrl}/chat/completions`;
const body = {
model: cfg.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
temperature,
};
if (process.env.LLM_NO_JSON !== '1') {
body.response_format = { type: 'json_object' };
}
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 120000);
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cfg.apiKey}`,
},
body: JSON.stringify(body),
signal: ac.signal,
});
} catch (e) {
if (e.name === 'AbortError') {
const err = new Error('Превышен таймаут ожидания ответа LLM (120 с).');
err.code = 'llm_timeout';
throw err;
}
const err = new Error(
e instanceof Error ? e.message : 'Сбой сети при обращении к LLM'
);
err.code = 'llm_network';
throw err;
} finally {
clearTimeout(t);
}
if (!res.ok) {
const errText = await res.text();
const err = new Error(
`LLM ${res.status}: ${errText.replace(/\s+/g, ' ').slice(0, 280)}`
);
err.code = 'llm_http';
err.status = res.status;
throw err;
}
const data = await res.json();
const content = data?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
const e = new Error('Пустой content в ответе API.');
e.code = 'llm_empty';
throw e;
}
return content;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Кто видит тест: автор цепочки и пользователи с назначением (target user = clinic user id).
*/
import { isTestAuthor } from '../config/devAuthor.js';
import { query } from '../db/db.js';
/**
* @param {string} userId
* @param {string} testId
* @returns {Promise<{ ok: boolean, isAuthor: boolean, notFound: boolean }>}
*/
export async function userHasTestAccess(userId, testId) {
const { rows } = await query(
`SELECT t.created_by FROM tests t WHERE t.id = $1`,
[testId]
);
if (!rows.length) {
return { ok: false, isAuthor: false, notFound: true };
}
if (isTestAuthor(rows[0].created_by, userId)) {
return { ok: true, isAuthor: true, notFound: false };
}
const { rows: ar } = await query(
`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 = $1
AND tat.target_type = 'user'
AND tat.target_id = $2
LIMIT 1`,
[testId, userId]
);
return { ok: ar.length > 0, isAuthor: false, notFound: false };
}
/**
* Список тестов в каталоге: только `is_active` цепочка + (автор OR назначен).
*/
export async function queryTestsVisibleToUser(userId) {
return query(
`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 = $1
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 = $1
)
)
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`,
[userId]
);
}
+477
View File
@@ -0,0 +1,477 @@
/**
* Прохождение теста: контент для игры, проверка ответов, завершение попытки.
*/
import { RU } from '../messages/ru.js';
import { isTestAuthor } from '../config/devAuthor.js';
/**
* @param {import('pg').Pool|import('pg').PoolClient} db
* @param {string} testVersionId
* @param {{ includeCorrect: boolean }} opts
*/
export async function loadQuestionsForVersion(db, testVersionId, opts) {
const { rows: qrows } = await db.query(
`SELECT id, text, question_order, has_multiple_answers
FROM questions
WHERE test_version_id = $1
ORDER BY question_order`,
[testVersionId]
);
const out = [];
for (const row of qrows) {
const { rows: orows } = await db.query(
`SELECT id, text, is_correct, option_order
FROM answer_options
WHERE question_id = $1
ORDER BY option_order`,
[row.id]
);
const options = orows.map((o) => {
const base = {
id: o.id,
text: o.text,
optionOrder: o.option_order,
};
if (opts.includeCorrect) {
return { ...base, isCorrect: o.is_correct };
}
return base;
});
out.push({
id: row.id,
text: row.text,
questionOrder: row.question_order,
hasMultipleAnswers: row.has_multiple_answers,
options,
});
}
return out;
}
function sortUuidStrings(arr) {
return [...new Set(arr)].map(String).sort();
}
function sameSelection(selected, correctIds) {
const a = sortUuidStrings(selected);
const b = sortUuidStrings(correctIds);
if (a.length !== b.length) {
return false;
}
return a.every((x, i) => x === b[i]);
}
/**
* @param {import('pg').Pool} pool
* @param {string} userId
* @param {string} testId
*/
export async function getEditorContent(pool, userId, testId) {
const { rows: tr } = await pool.query(
`SELECT t.id, t.title, t.description, t.passing_threshold, t.created_by
FROM tests t WHERE t.id = $1`,
[testId]
);
if (!tr.length) {
const e = new Error(RU.testNotFound);
e.status = 404;
throw e;
}
if (!isTestAuthor(tr[0].created_by, userId)) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
const { rows: tv } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`,
[testId]
);
if (!tv.length) {
const e = new Error(RU.noActiveVersion);
e.status = 400;
throw e;
}
const versionId = tv[0].id;
const questions = await loadQuestionsForVersion(pool, versionId, {
includeCorrect: true,
});
return {
test: {
id: tr[0].id,
title: tr[0].title,
description: tr[0].description,
passingThreshold: tr[0].passing_threshold,
},
activeVersionId: versionId,
questions,
};
}
/**
* @param {import('pg').Pool} pool
* @param {string} userId
* @param {string} testId
* @param {string} attemptId
*/
export async function getPlayContent(pool, userId, testId, attemptId) {
const { rows: arows } = await pool.query(
`SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, t.title, t.passing_threshold
FROM test_attempts ta
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
INNER JOIN tests t ON t.id = tv.test_id
WHERE ta.id = $1`,
[attemptId]
);
if (!arows.length) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
const a = arows[0];
if (a.test_id !== testId) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
if (a.user_id !== userId) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
if (a.status !== 'in_progress') {
const e = new Error(RU.attemptNotInProgress);
e.status = 400;
throw e;
}
const questions = await loadQuestionsForVersion(pool, a.test_version_id, {
includeCorrect: false,
});
return {
testTitle: a.title,
passingThreshold: a.passing_threshold,
attemptId: a.id,
questions,
};
}
/**
* @param {import('pg').Pool} pool
* @param {string} userId
* @param {string} testId
* @param {string} attemptId
* @param {Record<string, string | string[] | undefined> | null | undefined} rawAnswers
*/
export async function submitAttempt(pool, userId, testId, attemptId, rawAnswers) {
const answers = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {};
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: arows } = await client.query(
`SELECT id, user_id, status, test_version_id
FROM test_attempts
WHERE id = $1
FOR UPDATE`,
[attemptId]
);
if (!arows.length) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
const a0 = arows[0];
const { rows: trows } = await client.query(
`SELECT t.passing_threshold, tv.test_id
FROM test_versions tv
INNER JOIN tests t ON t.id = tv.test_id
WHERE tv.id = $1`,
[a0.test_version_id]
);
if (!trows.length) {
const e = new Error(RU.testNotFound);
e.status = 404;
throw e;
}
const link = trows[0];
const a = {
test_id: link.test_id,
user_id: a0.user_id,
status: a0.status,
test_version_id: a0.test_version_id,
passing_threshold: link.passing_threshold,
};
if (a.test_id !== testId) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
if (a.user_id !== userId) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
if (a.status !== 'in_progress') {
const e = new Error(RU.attemptNotInProgress);
e.status = 400;
throw e;
}
const versionId = a.test_version_id;
const threshold = Number(a.passing_threshold) || 0;
const { rows: qrows } = await client.query(
`SELECT id, has_multiple_answers
FROM questions
WHERE test_version_id = $1`,
[versionId]
);
if (!qrows.length) {
const e = new Error(RU.testHasNoQuestions);
e.status = 400;
throw e;
}
const { rows: allOpts } = await client.query(
`SELECT a.id, a.question_id, a.is_correct
FROM answer_options a
INNER JOIN questions q ON q.id = a.question_id
WHERE q.test_version_id = $1`,
[versionId]
);
const byQuestion = new Map();
for (const o of allOpts) {
if (!byQuestion.has(o.question_id)) {
byQuestion.set(o.question_id, { all: new Set(), correct: [] });
}
const g = byQuestion.get(o.question_id);
g.all.add(String(o.id));
if (o.is_correct) {
g.correct.push(String(o.id));
}
}
let correctCount = 0;
for (const q of qrows) {
const qid = String(q.id);
let selected = answers[qid] ?? answers[q.id];
if (selected == null) {
selected = [];
} else if (!Array.isArray(selected)) {
selected = [String(selected)];
} else {
selected = selected.map(String);
}
const g = byQuestion.get(q.id);
if (!g) {
continue;
}
for (const sid of selected) {
if (!g.all.has(sid)) {
const e = new Error(RU.invalidOptionForQuestion);
e.status = 400;
throw e;
}
}
if (sameSelection(selected, g.correct)) {
correctCount += 1;
}
}
const total = qrows.length;
const percent = (correctCount / total) * 100;
const passed = percent + 1e-9 >= threshold;
await client.query(`DELETE FROM user_answers WHERE attempt_id = $1`, [attemptId]);
for (const q of qrows) {
const qid = String(q.id);
let selected = answers[qid] ?? answers[q.id] ?? [];
if (!Array.isArray(selected)) {
selected = [String(selected)];
} else {
selected = selected.map(String);
}
await client.query(
`INSERT INTO user_answers (attempt_id, question_id, selected_options)
VALUES ($1, $2, $3::uuid[])`,
[attemptId, q.id, selected]
);
}
await client.query(
`UPDATE test_attempts
SET status = 'completed', completed_at = CURRENT_TIMESTAMP,
correct_count = $2, total_questions = $3, passed = $4
WHERE id = $1`,
[attemptId, correctCount, total, passed]
);
await client.query('COMMIT');
const base = {
attemptId,
correctCount,
totalQuestions: total,
percent: Math.round(percent * 10) / 10,
passed,
passingThreshold: threshold,
};
const review = await buildReviewFromDb(pool, attemptId);
return { ...base, review };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
/**
* Подробный разбор завершённой попытки (для API и ответа submit).
* @param {import('pg').Pool|import('pg').PoolClient} pool
* @param {string} attemptId
*/
export async function buildReviewFromDb(pool, attemptId) {
const { rows: arows } = await pool.query(
`SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions,
ta.passed, ta.started_at, ta.completed_at,
t.id AS test_id, t.title, t.passing_threshold,
u.full_name AS attempter_name, u.login AS attempter_login
FROM test_attempts ta
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
INNER JOIN tests t ON t.id = tv.test_id
INNER JOIN users u ON u.id = ta.user_id
WHERE ta.id = $1`,
[attemptId]
);
if (!arows.length) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
const a = arows[0];
if (a.status !== 'completed') {
const e = new Error(RU.attemptNotCompleted);
e.status = 400;
throw e;
}
const questions = await loadQuestionsForVersion(pool, a.test_version_id, {
includeCorrect: true,
});
const { rows: uans } = await pool.query(
`SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`,
[attemptId]
);
const selByQ = new Map();
for (const r of uans) {
selByQ.set(String(r.question_id), (r.selected_options || []).map(String));
}
const threshold = Number(a.passing_threshold) || 0;
const total = a.total_questions || questions.length;
const percent =
total > 0
? Math.round(((a.correct_count || 0) / total) * 1000) / 10
: 0;
const qOut = questions.map((q) => {
const selected = sortUuidStrings(selByQ.get(String(q.id)) || []);
const correctIdList = sortUuidStrings(
q.options.filter((o) => o.isCorrect).map((o) => String(o.id))
);
const isUserCorrect = sameSelection(selected, correctIdList);
const selectedSet = new Set(selected);
return {
id: q.id,
text: q.text,
hasMultipleAnswers: q.hasMultipleAnswers,
isUserCorrect,
options: q.options.map((o) => ({
id: o.id,
text: o.text,
isCorrect: o.isCorrect,
selected: selectedSet.has(String(o.id)),
})),
};
});
return {
attemptId: a.id,
testId: a.test_id,
testTitle: a.title,
passingThreshold: threshold,
correctCount: a.correct_count,
totalQuestions: total,
percent,
passed: a.passed,
startedAt: a.started_at,
completedAt: a.completed_at,
attempterUserId: a.user_id,
attempterName: a.attempter_name,
attempterLogin: a.attempter_login,
questions: qOut,
};
}
/**
* Разбор попытки: владелец попытки или автор теста.
* @param {import('pg').Pool} pool
* @param {string} currentUserId
* @param {string} testId
* @param {string} attemptId
*/
export async function getAttemptReviewForUser(pool, currentUserId, testId, attemptId) {
const { rows } = await pool.query(
`SELECT ta.user_id, t.created_by, tv.test_id
FROM test_attempts ta
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
INNER JOIN tests t ON t.id = tv.test_id
WHERE ta.id = $1`,
[attemptId]
);
if (!rows.length) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
const r0 = rows[0];
if (r0.test_id !== testId) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
const isOwner = r0.user_id === currentUserId;
const isAuthor = isTestAuthor(r0.created_by, currentUserId);
if (!isOwner && !isAuthor) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
return buildReviewFromDb(pool, attemptId);
}
/**
* Список всех попыток по цепочке (все версии) — только автор.
* @param {import('pg').Pool} pool
* @param {string} authorId
* @param {string} testId
*/
export async function listTestAttemptsForAuthor(pool, authorId, testId) {
const { rows: t } = await pool.query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
const e = new Error(RU.testNotFound);
e.status = 404;
throw e;
}
if (!isTestAuthor(t[0].created_by, authorId)) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
const { rows } = await pool.query(
`SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at,
ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version,
u.full_name AS attempter_name, u.login AS attempter_login
FROM test_attempts ta
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
INNER JOIN users u ON u.id = ta.user_id
WHERE tv.test_id = $1
ORDER BY ta.started_at DESC NULLS LAST
LIMIT 200`,
[testId]
);
return rows;
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Логика «цепочки» теста: попытки и версии (см. docs/revision_task/card1.md V.2).
* @param {import('pg').Pool | { query: Function }} pool пул или объект с методом query(sql, params)
* @param {string} testId UUID теста (tests.id)
* @returns {Promise<boolean>} true, если по любой версии этой цепочки есть хотя бы одна попытка
*/
export async function hasAnyAttemptForTest(pool, testId) {
const { rows } = await pool.query(
`SELECT EXISTS (
SELECT 1
FROM test_attempts ta
INNER JOIN test_versions tv ON ta.test_version_id = tv.id
WHERE tv.test_id = $1
) AS has_any`,
[testId]
);
return rows[0].has_any === true;
}
@@ -0,0 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hasAnyAttemptForTest } from './testChainService.js';
test('hasAnyAttemptForTest: false, если в базе пусто', async () => {
const pool = {
async query() {
return { rows: [{ has_any: false }] };
},
};
const result = await hasAnyAttemptForTest(pool, '00000000-0000-0000-0000-000000000001');
assert.equal(result, false);
});
test('hasAnyAttemptForTest: true, если есть попытка', async () => {
const pool = {
async query() {
return { rows: [{ has_any: true }] };
},
};
const result = await hasAnyAttemptForTest(pool, '00000000-0000-0000-0000-000000000001');
assert.equal(result, true);
});
+218
View File
@@ -0,0 +1,218 @@
/**
* V.3 saveTestDraft, fork версии, контент вопросов.
*/
import { hasAnyAttemptForTest } from './testChainService.js';
import { RU } from '../messages/ru.js';
import { isTestAuthor } from '../config/devAuthor.js';
/**
* @param {import('pg').PoolClient} client
* @param {string} testId
*/
export async function getActiveVersionRow(client, testId) {
const { rows } = await client.query(
`SELECT * FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`,
[testId]
);
return rows[0] || null;
}
/**
* @param {import('pg').PoolClient} client
* @param {string} fromVersionId
* @param {string} toVersionId
*/
export async function copyQuestionTree(client, fromVersionId, toVersionId) {
const { rows: questions } = await client.query(
`SELECT * FROM questions WHERE test_version_id = $1 ORDER BY question_order`,
[fromVersionId]
);
for (const q of questions) {
const { rows: insQ } = await client.query(
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
VALUES ($1, $2, $3, $4) RETURNING id`,
[toVersionId, q.text, q.question_order, q.has_multiple_answers]
);
const nqid = insQ[0].id;
const { rows: options } = await client.query(
`SELECT * FROM answer_options WHERE question_id = $1 ORDER BY option_order`,
[q.id]
);
for (const o of options) {
await client.query(
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
VALUES ($1, $2, $3, $4)`,
[nqid, o.text, o.is_correct, o.option_order]
);
}
}
}
/**
* @param {import('pg').PoolClient} client
* @param {string} testVersionId
* @param {{ questions?: Array<{ text: string, question_order?: number, hasMultipleAnswers?: boolean, options?: Array<{ text: string, isCorrect?: boolean, option_order?: number }> }> }} payload
*/
export async function replaceVersionContent(client, testVersionId, payload) {
await client.query(
`DELETE FROM answer_options WHERE question_id IN
(SELECT id FROM questions WHERE test_version_id = $1)`,
[testVersionId]
);
await client.query(`DELETE FROM questions WHERE test_version_id = $1`, [
testVersionId,
]);
const questions = payload.questions || [];
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const { rows: insQ } = await client.query(
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
VALUES ($1, $2, $3, $4) RETURNING id`,
[
testVersionId,
q.text,
q.question_order ?? i + 1,
q.hasMultipleAnswers || false,
]
);
const qid = insQ[0].id;
const opts = q.options || [];
for (let j = 0; j < opts.length; j++) {
const o = opts[j];
await client.query(
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
VALUES ($1, $2, $3, $4)`,
[qid, o.text, !!o.isCorrect, o.option_order ?? j + 1]
);
}
}
}
/**
* @param {import('pg').PoolClient} client
* @param {string} testId
*/
export async function forkNewVersion(client, testId) {
const av = await getActiveVersionRow(client, testId);
if (!av) {
throw new Error(RU.noActiveVersion);
}
const { rows: mx } = await client.query(
`SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`,
[testId]
);
const nextV = (mx[0].v || 0) + 1;
// Сначала снять is_active с цепочки: частичный уникальный индекс
// uq_test_versions_one_active_per_test — не более одной true на test_id.
await client.query(
`UPDATE test_versions SET is_active = false WHERE test_id = $1`,
[testId]
);
const { rows: nv } = await client.query(
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
VALUES ($1, $2, true, $3) RETURNING *`,
[testId, nextV, av.id]
);
const newRow = nv[0];
await copyQuestionTree(client, av.id, newRow.id);
return newRow;
}
/**
* @param {import('pg').Pool} pool
* @param {string} authorId
* @param {string} testId
* @param {{ title?: string, description?: string, questions?: Array<unknown> }} payload
*/
export async function saveTestDraft(pool, authorId, testId, payload) {
const { rows: tr } = await pool.query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!tr.length) {
const e = new Error(RU.testNotFound);
e.status = 404;
throw e;
}
const t = tr[0];
if (!isTestAuthor(t.created_by, authorId)) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
const client = await pool.connect();
let forked = false;
try {
await client.query('BEGIN');
if (payload.title != null || payload.description != null) {
await client.query(
`UPDATE tests SET title = COALESCE($2, title), description = COALESCE($3, description),
updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
[testId, payload.title ?? null, payload.description ?? null]
);
}
if (payload.passingThreshold !== undefined && payload.passingThreshold !== null) {
const raw = Number(payload.passingThreshold);
if (Number.isFinite(raw)) {
const pt = Math.max(0, Math.min(100, Math.round(raw)));
await client.query(
`UPDATE tests SET passing_threshold = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
[testId, pt]
);
}
}
const hasAttempts = await hasAnyAttemptForTest(client, testId);
let versionRow = await getActiveVersionRow(client, testId);
if (!versionRow) {
const e = new Error(RU.noActiveVersion);
e.status = 500;
throw e;
}
if (hasAttempts && payload.questions !== undefined) {
versionRow = await forkNewVersion(client, testId);
forked = true;
}
if (payload.questions) {
await replaceVersionContent(client, versionRow.id, payload);
}
await client.query('COMMIT');
return { testId, versionId: versionRow.id, forked };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
/**
* Создать пустой тест (цепочка) с одной версией 1.
* @param {import('pg').Pool} pool
* @param {string} authorId
* @param {{ title: string, description?: string }} meta
*/
export async function createTestWithVersion(pool, authorId, meta) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: t } = await client.query(
`INSERT INTO tests (title, description, created_by, is_active, is_versioned)
VALUES ($1, $2, $3, true, true) RETURNING id`,
[meta.title, meta.description || null, authorId]
);
const testId = t[0].id;
const { rows: v } = await client.query(
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
VALUES ($1, 1, true, NULL) RETURNING id`,
[testId]
);
await client.query('COMMIT');
return { testId, versionId: v[0].id };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Authentication Utilities
* Password hashing and JWT token management
*/
import { hash, compare } from 'bcryptjs';
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import { checkWerkzeugPassword } from './werkzeugPassword.js';
import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js';
dotenv.config();
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
// Salt rounds (bcryptjs — тот же формат $2*, без нативной сборки — проще Docker/ARM/musl)
const SALT_ROUNDS = 10;
/**
* Hash a password using bcrypt
* @param {string} password - Plain text password
* @returns {Promise<string>} Hashed password
*/
export async function hashPassword(password) {
return hash(password, SALT_ROUNDS);
}
/**
* Compare a plain text password with a hashed password
* @param {string} password - Plain text password
* @param {string} hash - Hashed password
* @returns {Promise<boolean>} True if passwords match
*/
export async function comparePassword(password, hash) {
if (!hash) {
return false;
}
if (hash === HR_MANAGED_PASSWORD_PLACEHOLDER) {
return false;
}
if (hash.startsWith('scrypt:') || hash.startsWith('pbkdf2:')) {
return checkWerkzeugPassword(hash, password);
}
if (hash.startsWith('$2')) {
return compare(password, hash);
}
return checkWerkzeugPassword(hash, password);
}
/**
* @param {string} userId - clinic_tests.users.id
* @param {string} role
* @param {string|null|undefined} departmentId
* @param {{ staffId?: number } | null} [meta]
* @returns {string}
*/
export function generateToken(userId, role, departmentId = null, meta = null) {
const payload = {
userId,
role,
};
if (departmentId !== null && departmentId !== undefined) {
payload.departmentId = departmentId;
}
if (meta?.staffId != null) {
payload.staffId = meta.staffId;
}
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
}
/**
* Verify and decode a JWT token
* @param {string} token - JWT token
* @returns {Object|null} Decoded token payload or null if invalid
*/
export function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
}
export default {
hashPassword,
comparePassword,
generateToken,
verifyToken,
};
+21
View File
@@ -0,0 +1,21 @@
/**
* Сопоставление role из hr_bot_test.users (varchar) с ролью в модуле тестов.
* A.5 MVP — уточняется при подключении staff_role_assignments.
* @param {string|null|undefined} hrRole
* @returns {'hr' | 'manager' | 'employee'}
*/
export function mapHrRoleToApp(hrRole) {
const r = String(hrRole || '')
.toLowerCase()
.trim();
if (!r) {
return 'employee';
}
if (r === 'admin' || r.includes('hr') || r.includes('дире')) {
return 'hr';
}
if (r.includes('manager') || r.includes('рук') || r.includes('завед')) {
return 'manager';
}
return 'employee';
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Проверка хеша в формате Werkzeug 3 (scrypt:… / pbkdf2:…).
* @see https://github.com/pallets/werkzeug/blob/main/src/werkzeug/security.py
*/
import crypto from 'crypto';
/**
* @param {string} pwhash
* @param {string} password
* @returns {boolean}
*/
function hashInternal(method, salt, password) {
const methodParts = method.split(':');
const kind = methodParts[0];
const saltBytes = Buffer.from(salt, 'utf8');
const passwordBytes = Buffer.from(password, 'utf8');
if (kind === 'scrypt') {
const n = methodParts[1] ? parseInt(methodParts[1], 10) : 2 ** 15;
const r = methodParts[2] ? parseInt(methodParts[2], 10) : 8;
const p = methodParts[3] ? parseInt(methodParts[3], 10) : 1;
const maxmem = 132 * n * r * p;
return crypto
.scryptSync(passwordBytes, saltBytes, 64, { N: n, r, p, maxmem })
.toString('hex');
}
if (kind === 'pbkdf2') {
const hashName = methodParts[1] || 'sha256';
const iterStr = methodParts[2];
if (!iterStr) {
throw new Error('pbkdf2: missing iterations');
}
const iterations = parseInt(iterStr, 10);
return crypto
.pbkdf2Sync(passwordBytes, saltBytes, iterations, 32, hashName)
.toString('hex');
}
throw new Error(`Invalid hash method: ${kind}`);
}
/**
* @param {string} pwhash
* @param {string} password
* @returns {boolean}
*/
export function checkWerkzeugPassword(pwhash, password) {
if (!pwhash || pwhash.length < 3) {
return false;
}
const parts = pwhash.split('$');
if (parts.length < 3) {
return false;
}
const hashval = parts.pop();
const salt = parts.pop();
const method = parts.join('$');
if (!method || !salt || !hashval) {
return false;
}
let computed;
try {
computed = hashInternal(method, salt, password);
} catch {
return false;
}
const a = Buffer.from(computed, 'hex');
const b = Buffer.from(hashval, 'hex');
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'crypto';
import { checkWerkzeugPassword } from './werkzeugPassword.js';
test('pbkdf2:sha256 self-consistency', () => {
const salt = 'AbCdEfGhIjKlMnOp';
const iterations = 1000;
const password = 'secret';
const hashval = crypto
.pbkdf2Sync(password, salt, iterations, 32, 'sha256')
.toString('hex');
const pwhash = `pbkdf2:sha256:${iterations}$${salt}$${hashval}`;
assert.equal(checkWerkzeugPassword(pwhash, 'secret'), true);
assert.equal(checkWerkzeugPassword(pwhash, 'wrong'), false);
});
test('scrypt self-consistency', () => {
const salt = 'AbCdEfGhIjKlMnOp';
const n = 32768;
const r = 8;
const p = 1;
const maxmem = 132 * n * r * p;
const method = `scrypt:${n}:${r}:${p}`;
const password = 'x';
const h = crypto
.scryptSync(password, salt, 64, { N: n, r, p, maxmem })
.toString('hex');
const pwhash = `${method}$${salt}$${h}`;
assert.equal(checkWerkzeugPassword(pwhash, 'x'), true);
});
+82
View File
@@ -0,0 +1,82 @@
# Система тестирования + общий Postgres (Postgres_TG_Bots / hr_postgres_dev).
# Требуется: сеть hr_postgres_dev_net и поднятый hr_postgres_dev.
# cd ../Postgres_TG_Bots && docker compose -f docker-compose.dev.yml up -d
# База clinic_tests: один раз
# psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;"
#
# Запуск: из каталога TestingWebApp
# docker compose -f docker-compose.dev.yml up --build
# UI (Node): http://localhost:3107 (Nginx: /api → backend:3001), API: http://localhost:3001
# UI (Flask, новый контур): http://localhost:3108
services:
testing-backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: testing_webapp_backend
# LLM и прочие секреты из хоста (не копируются в образ — см. .dockerignore)
env_file:
- ./backend/.env
environment:
DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests
JWT_SECRET: ${JWT_SECRET:-testing_webapp_jwt_dev}
# development: httpOnly-cookie без Secure (иначе на http://localhost:3107 логин не сработает)
NODE_ENV: development
FRONTEND_URL: http://localhost:3107
PORT: "3001"
# Вход теми же учётками, что в HR: проверка пароля в hr_bot_test + привязка сотрудника по web_login.
# Без HR_AUTH / HR_DATABASE_URL логин ищется только в clinic_tests.users (локальные dev-учётки).
HR_AUTH: ${HR_AUTH:-1}
HR_DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test
# Прямой доступ к API с хоста (Vite proxy в dev: см. frontend/vite.config.js)
ports:
- "3001:3001"
networks:
- app
- postgres
testing-web:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: testing_webapp_nginx
depends_on:
- testing-backend
ports:
- "3107:80"
networks:
- app
# Новый контур: Flask (тот же стек, что кабинет HR), отдельный порт
testing-flask:
build:
context: ./flask_app
dockerfile: Dockerfile
container_name: testing_webapp_flask
environment:
PORT: "3108"
WEB_USE_WAITRESS: "1"
FLASK_DEBUG: "0"
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:
- "3108:3108"
networks:
- app
- postgres
networks:
app:
postgres:
name: hr_postgres_dev_net
external: true
+22
View File
@@ -0,0 +1,22 @@
# По умолчанию этот compose ничего не поднимает — используется ОБЩИЙ Postgres из
# ../Postgres_TG_Bots/docker-compose.dev.yml (порт 5432 на хосте, сеть hr_postgres_dev_net).
# В backend/.env: DATABASE_URL=...localhost:5432/clinic_tests (см. backend/.env.example).
#
# Только если общий кластер не нужен, изолированный Postgres (порт 5433):
# docker compose --profile standalone up -d
services:
postgres:
profiles: ["standalone"]
image: postgres:15
environment:
POSTGRES_DB: clinic_tests
POSTGRES_USER: developer
POSTGRES_PASSWORD: dev_password
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
+64
View File
@@ -0,0 +1,64 @@
# Как пользоваться стендом **dev** (простыми словами)
**Для кого:** тот, кто **проверяет** интерфейс на своей машине или на общем dev-сервере, без погрузки в код.
**Адрес по умолчанию:** [http://localhost:3107](http://localhost:3107) — если вы подняли проект командой `docker compose -f docker-compose.dev.yml up` из корня репозитория. В браузере откройте **:3107** (интерфейс); запросы к `/api/…` с этой страницы идут на бэкенд через Nginx. Прямой адрес API с вашего ПК: **http://localhost:3001** (если смотрите health или тестируете curl).
Если кто-то дал **другой URL** (например, внутренний хост клиники) — откройте его; логика та же.
---
## 1. Вход
1. Откройте в браузере адрес стенда. Должна открыться **страница входа** (логин, пароль, кнопка «Войти»).
2. Введите **логин и пароль**, которые вам выдали для **этой** базы `clinic_tests` (или HR, если включён `HR_AUTH` — смотрите, что сказал разработчик).
3. После успешного входа вы попадаете в раздел **«Тесты»**. В **шапке** справа: **Фамилия с инициалами** и роль; слева — название портала. **«Выйти»** завершает сессию.
Если не пускает — не подбирайте пароль: напишите тому, кто администрирует БД или `.env` на стенде.
---
## 2. Список «Тесты»
- Каждая **строка** — один тест (одна **цепочка**; номер версии в подписи внизу строки).
- **Слева** — **название** (клик открывает **карточку** теста: настройки, вопросы, назначения — если вы автор, или краткую информацию — если вам только **назначили**).
- **Справа** — кнопка **«Пройти»**: начать попытку **сразу** по **текущей активной** версии (именно с неё, а не с «старой из назначения»).
- Под названием: **Автор: Вы** — если вы создали тест; **Автор: Фамилия И. О.** — если тест чужой, но вам **назначен**.
Пустой список: либо вам **ничего не назначили** и вы **не создавали** тесты, либо всё **скрыто** из списка (у автора внизу может быть блок «Скрытые вами»).
---
## 3. Карточка теста (автор)
Блоки **сворачиваются** (заголовок — тап). Внизу экрана на телефоне часто закреплены **«Сохранить черновик»** и **«К списку»**.
- **О тесте** — название, описание, **порог зачёта**. **Сохранить черновик** — записывает правки. Если по тесту **уже были прогоны**, при изменении **содержимого** система заведёт **новую версию** (и предупредит, что так и задумано).
- **Вопросы** — тексты вопросов и варианты, отметка верных, **ИИ** (если есть), внизу секции — **«Документ в вопросы»** (загрузка файла и вставка в черновик, если на стенде настроен LLM).
- **История** — **Версии** (все версии, **сделать активной** с подтверждением) и **Прохождения** (кто сдавал; **Разбор** по вопросам).
- **Показ в каталоге** — **Видимость** (скрыть из общего списка или снова показать); при включённом назначении — **Кому выдать** (поиск, **«Назначить выбранных»**).
Автор **не** запускает экзамен **с карточки** в том же сценарии, что сотрудник: для самопрохождения — **«Пройти»** в **списке** «Тесты».
---
## 4. Прохождение и разбор (сотрудник или самопроверка автора)
1. В списке нажмите **«Пройти»** у нужного теста.
2. Ответьте на вопросы, нажмите **«Завершить тест»**.
3. Увидите **сводку** (сколько верно, процент, зачёт/незачёт) и **разбор по вопросам** (что отмечено, что было верно). Есть ссылка на **отдельную страницу разбора** — удобно, если нужно вернуться позже.
---
## 5. Что сказать разработчику, если «что-то не так»
- **«Белый экран» / ошибка** — сделайте скриншот и опишите, **на какой странице** (например, «после Войти» или «после Пройти»).
- **«Не вижу тест»** — уточните, вы **автор** или вам должны были **назначить**; проверьте блок **скрытых** тестов.
- **«Сохранил, а версия не та»** — скажите, были ли **уже** попытки у других: после первой попытки **любая** смена содержимого **увеличивает** номер версии **специально**, чтобы старые ответы не «переписывались».
---
## 6. Где почитать подробности для разработки
- [PROJECT_STATUS.md](PROJECT_STATUS.md) — что в целом сделано и что в планах.
- [../README.md](../README.md) — Docker, БД, переменные окружения.
+78
View File
@@ -0,0 +1,78 @@
# Состояние проекта (человеческий обзор)
**Репозиторий:** [TestingWebApp](https://git.pirogov.ai/l_konstantin/TestingWebApp) · ветка разработки: **`dev`**
**Дата среза:** 2026-04-24
Этот документ — не дублирование ТЗ, а **короткое объяснение**, что уже работает в коде и что логично делать дальше. Подробные задачи: [revision_task/card1.md](revision_task/card1.md), [revision_task/BACKLOG.md](revision_task/BACKLOG.md).
---
## Что уже сделано (как это устроено)
### Вход и роли
- Сотрудник входит по **логину и паролю** (сессия через cookie + JWT).
- В шапке показываются **роль** и **Фамилия с инициалами** (например, *Иванов И. О.*), полное ФИО — во всплывающей подсказке.
- В **режиме разработки** (`NODE_ENV=development`) у удобного тестирования могут быть дополнительные кнопки (например, создание теста сотрудником — `devUi` в ответе `/api/auth/me`).
### «Цепочка» теста и черновики
- У каждого теста есть **одна логическая цепочка** в базе: все правки вопросов относятся к ней, но **версия контента** (`v1`, `v2`, …) может расти.
- **Пока никто не проходил** этот тест — автор правит **на месте**: сохраняет черновик, и меняется текущая активная версия **без** лишнего дублирования строк в истории.
- **Как только по цепочке появилась хотя бы одна завершённая попытка** — каждое **содержательное** сохранение с изменениями создаёт **новую версию** (новый номер, старая остаётся в истории). Старые результаты остаются привязаны к **той** версии, с которой человек реально отвечал.
- **Активная версия** — та, с которой сейчас стартуют новые попытки. Автор может **вручную** переключить активную версию в таблице истории (с подтверждением), если бизнесу так нужно.
- **Публикация / видимость:** в кабинете (аккордеон **«Показ в каталоге»**, подсекция **«Видимость»**) тест можно **скрыть из общего списка** (цепочка остаётся в базе) или **снова показать**; **назначения** (подсекция **«Кому выдать»**) — при включённой фиче, см. раздел «Назначения» ниже.
- **Мобильный 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).
### Список тестов и доступ
- В каталоге **«Тесты»** видны цепочки, где вы **автор**, и тесты, **назначенные вам** (через назначение на пользователя; в dev назначения обычно **включены**).
- Под названием показывается **«Автор: Вы»** для своих тестов и **«Автор: Фамилия И. О.»** для чужих (назначенных).
- **Пройти** тест — кнопка **справа** в строке; **карточка** теста — клик по названию **слева** (попытка с карточки не стартует сама).
### Прохождение и результат
- Открывается экран вопросов (один или несколько верных вариантов); после **«Завершить тест»** — итог: сколько верно, процент, **зачёт** по порогу.
- **Разбор:** после сдачи показывается, по **каждому вопросу**, что выбрал пользователь и какие варианты верны. Отдельная страница разбора доступна по ссылке; **автор** в аккордеоне **«История»** (подсекция **«Прохождения»**) видит **завершённые** попытки и кнопку **«Разбор»** (раньше секция называлась **«Прогоны и разбор»**).
### Импорт и ИИ (MVP)
- Можно загрузить **файл** (PDF, DOCX, текст): сервер **извлекает текст** и при настроенном ключе **LLM** (например, `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` в окружении) предлагает **черновик** вопросов. В UI: подсекция **«Документ в вопросы»** внутри **«Вопросы»** (раньше — отдельный блок «Импорт из файла»). Дальше тот же поток, что и при ручном редактировании: правки → **сохранить черновик** (с учётом правил версий выше).
- **Полный** набор сценариев из ТЗ (отдельная страница настроек ключа, «проверить тест целиком», модалки с чекбоксами и т.д.) — в [sprint-02](revision_task/sprint-02.md); часть уже заложена в сервисах, UI доводится.
### Назначения (MVP)
- **Автор** в **«Показ в каталоге»** → **«Кому выдать»** может **назначить** сотрудников из справочника (поиск, фильтры, **«Выбрать всех»** в текущем списке; в dev — при включённой фиче в `docker-compose` / `.env`). Назначение **не** перепривязывается автоматически к каждой новой версии контента: **старт попытки** всегда берёт **текущую активную** версию на момент нажатия **«Пройти»**.
### Интеграция с HR (в зачатке)
- Поддержан сценарий **входа через учётки HR** (`HR_AUTH` + `HR_DATABASE_URL`) для проверок на одном кластере Postgres с экосистемой `Postgres_TG_Bots` (см. [README — установка](../README.md)).
- Целевой **RBAC** из HR-таблиц — [card1, часть A](revision_task/card1.md#часть-a--авторизация-по-паролю-бд-postgres_tg_bots); сейчас — упрощённое сопоставление ролей.
---
## Что в планах (логичный следующий слой)
| Направление | Суть |
|-------------|------|
| **AI по ТЗ §4.2** | Ключ в настройках (не на клиенте), кнопки «сгенерировать/проверить/улучшить» с превью и подтверждением, регресс с версиями. |
| **Дашборды (ТЗ этап 2)** | Единая картина по отделу / клинике, фильтры, история. |
| **MAX / мини-приложение** | Встраивание в общий HR-контур клиники. |
| **Таймер, подсказки, медиа в вопросах** | Режимы прохождения и вложения — отдельные этапы ТЗ. |
| **E2E и интеграционные тесты** | Расширение `V.9`, стабильный CI. |
| **Назначения** | Сроки, лимит попыток, назначения «по отделу» (частично в бэклоге [BACKLOG_IDEAS](revision_task/BACKLOG_IDEAS.md)). |
Журнал приёмок и чек-листы: [TESTING_JOURNAL.md](revision_task/TESTING_JOURNAL.md).
---
## Связанные файлы
- [Руководство пользователя dev-контура](DEV_CONTOUR_USER_GUIDE.md)
- [Руководство кабинета (простыми словами)](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md)
- [Спринты: мобильный UI](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md)
- [Предложение по дизайну (ист. + актуализация)](ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md)
- [README с установкой](../README.md)
- [Карта задач card1](revision_task/card1.md)
+414
View File
@@ -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. **Учётная запись**, под которой воспроизвели (логин, без пароля).
Этого достаточно — лезть в консоль/код/базу не нужно и не надо.
+380
View File
@@ -0,0 +1,380 @@
# Анализ таблиц для тестирования сотрудников
*Модуль **TestingWebApp** использует отдельную БД `clinic_tests` (см. [PROJECT_STATUS.md](PROJECT_STATUS.md) и [README.md](../README.md)). Ниже — разбор **наследуемых** / смежных сущностей в другой схеме, для сравнения и миграционных дискуссий.*
## Обзор существующих таблиц
В базе данных существуют следующие таблицы, связанные с тестированием:
### 1. [`training_questions`](hr_web_viewer/models.py) - Вопросы обучения
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | integer | Первичный ключ |
| `position` | text | Должность/категория |
| `test_type` | text | Тип темы/теста |
| `question` | text | Текст вопроса |
| `answer_1` - `answer_12` | text | Варианты ответов |
| `answer_count` | smallint | Количество правильных ответов |
**Проблемы:**
- Отсутствует явное указание правильного ответа
- Нет типа вопроса (одиночный/множественный выбор, текстовый, сопоставление)
- Нет баллов за вопрос
- Нет порядка вопросов
- Поле `position` используется как категория, но не связано с должностями
### 2. [`training_results`](hr_web_viewer/models.py) - Результаты обучения
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | integer | Первичный ключ |
| `telegram_id` | bigint | ID сотрудника |
| `correct_answers` | integer | Правильные ответы |
| `total_questions` | integer | Всего вопросов |
| `score` | integer | Балл |
| `completed_at` | timestamp | Дата завершения |
| `passed` | boolean | Пройден/не пройден |
**Индексы:**
- `idx_training_results_telegram_id` - по telegram_id
**Проблемы:**
- Нет связи с конкретным тестом (test_type)
- Нет количества попыток
- Нет детализации по ответам
### 3. [`training_settings`](hr_web_viewer/models.py) - Настройки обучения
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | integer | Первичный ключ |
| `position` | varchar(100) | Должность |
| `question_count` | integer | Количество вопросов (по умолчанию 10) |
| `passing_score` | integer | Проходной балл (по умолчанию 70) |
| `time_limit` | integer | Ограничение времени в минутах (по умолчанию 30) |
| `active` | boolean | Активен/неактивен |
**Индексы:**
- `idx_training_settings_position` - по position
**Проблемы:**
- Нет связи с категорией теста
- Нет ограничения количества попыток
- Нет настройки случайного порядка вопросов
### 4. [`test_assignments`](hr_web_viewer/models.py) - Назначения тестов
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | integer | Первичный ключ |
| `la_name` | text | Название адаптации |
| `intern_fio` | text | ФИО стажера |
| `user_credentials` | text | Учетные данные |
| `test_theme` | text | Тема теста |
| `test_subtheme` | text | Подтема теста |
| `attempts_allowed` | integer | Количество попыток |
| `passing_score` | integer | Проходной балл |
| `la_id` | integer | Ссылка на адаптацию |
| `intern_id` | bigint | ID сотрудника (staff_members.id) |
| `deadline` | timestamp | Срок сдачи |
**Внешние ключи:**
- `intern_id` -> `staff_members(id)`
- `la_id` -> `learning_adaptations(id)`
**Проблемы:**
- Назначения привязаны к конкретным сотрудникам, а не к должностям
- Нет статуса прохождения
- Нет связи с результатами
### 5. [`test_table`](hr_web_viewer/models.py) - Таблица тестов
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | integer | Первичный ключ |
| `name` | varchar(100) | Название теста |
**Проблемы:**
- Минимальная структура, практически не используется
### 6. [`corp_groups_tester`](hr_web_viewer/models.py) - Тестировщики (корпоративные группы)
| Колонка | Тип | Описание |
|---------|-----|----------|
| `id` | integer | Первичный ключ |
| `fio` | text | ФИО |
| `telegram_id` | varchar(20) | Telegram ID |
| `position` | varchar(200) | Должность |
| `department` | varchar(200) | Отдел |
| `phone` | varchar(20) | Телефон |
| `email` | varchar(100) | Email |
| `hire_date` | date | Дата приема |
**Проблемы:**
- Дублирует данные staff_members
- Не используется в текущей системе
---
## Рекомендуемая расширенная схема для ClinicTestingApp
### Новые таблицы
#### 1. `tests` - Основные тесты
```sql
CREATE TABLE tests (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL, -- Название теста
description TEXT, -- Описание
category VARCHAR(100), -- Категория (тема)
position VARCHAR(100), -- Должность (nullable - для всех)
question_count INTEGER DEFAULT 10, -- Количество вопросов в тесте
time_limit_minutes INTEGER, -- Ограничение времени (null = без ограничений)
attempts_allowed INTEGER DEFAULT 3, -- Количество попыток
passing_score_percent INTEGER DEFAULT 70, -- Проходной процент
random_questions BOOLEAN DEFAULT FALSE, -- Случайный порядок вопросов
is_active BOOLEAN DEFAULT TRUE, -- Активен
created_by INTEGER, -- ID администратора
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tests_category ON tests(category);
CREATE INDEX idx_tests_position ON tests(position);
```
#### 2. `test_questions` - Вопросы тестов
```sql
CREATE TABLE test_questions (
id SERIAL PRIMARY KEY,
test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
question_text TEXT NOT NULL, -- Текст вопроса
question_type VARCHAR(50) NOT NULL, -- single_choice, multiple_choice, text, matching, ordering
points INTEGER DEFAULT 1, -- Баллы за вопрос
question_order INTEGER, -- Порядок вопроса
explanation TEXT, -- Пояснение к ответу
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_test_questions_test_id ON test_questions(test_id);
```
#### 3. `test_answers` - Ответы на вопросы
```sql
CREATE TABLE test_answers (
id SERIAL PRIMARY KEY,
question_id INTEGER NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE,
answer_text TEXT NOT NULL, -- Текст ответа
is_correct BOOLEAN DEFAULT FALSE, -- Правильный ответ
answer_order INTEGER, -- Порядок (для сопоставления/порядка)
points_if_correct INTEGER DEFAULT 1 -- Баллы (если отличаются от question.points)
);
CREATE INDEX idx_test_answers_question_id ON test_answers(question_id);
```
#### 4. `test_assignments_extended` - Расширенные назначения тестов
```sql
CREATE TABLE test_assignments_extended (
id SERIAL PRIMARY KEY,
test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
staff_id INTEGER NOT NULL REFERENCES staff_members(id) ON DELETE CASCADE,
assigned_by INTEGER, -- ID администратора
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deadline TIMESTAMP, -- Срок сдачи
attempts_allowed INTEGER, -- Переопределение количества попыток (null = из теста)
status VARCHAR(50) DEFAULT 'pending', -- pending, in_progress, completed, expired
UNIQUE(test_id, staff_id)
);
CREATE INDEX idx_test_assignments_test_id ON test_assignments_extended(test_id);
CREATE INDEX idx_test_assignments_staff_id ON test_assignments_extended(staff_id);
```
#### 5. `test_attempts` - Попытки прохождения
```sql
CREATE TABLE test_attempts (
id SERIAL PRIMARY KEY,
assignment_id INTEGER NOT NULL REFERENCES test_assignments_extended(id) ON DELETE CASCADE,
attempt_number INTEGER NOT NULL, -- Номер попытки
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP, -- Завершена
score_points INTEGER, -- Набрано баллов
score_percent NUMERIC(5,2), -- Процент
passed BOOLEAN, -- Пройден/не пройден
time_spent_seconds INTEGER -- Потраченное время
);
CREATE INDEX idx_test_attempts_assignment_id ON test_attempts(assignment_id);
```
#### 6. `test_answers_given` - Ответы пользователя
```sql
CREATE TABLE test_answers_given (
id SERIAL PRIMARY KEY,
attempt_id INTEGER NOT NULL REFERENCES test_attempts(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE,
given_answer_ids INTEGER[], -- ID выбранных ответов (для choice)
given_text TEXT, -- Текстовый ответ
is_correct BOOLEAN, -- Правильный/неправильный
points_earned INTEGER, -- Полученные баллы
answered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_test_answers_given_attempt_id ON test_answers_given(attempt_id);
```
#### 7. `test_categories` - Категории тестов
```sql
CREATE TABLE test_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
parent_id INTEGER REFERENCES test_categories(id),
is_active BOOLEAN DEFAULT TRUE
);
```
#### 8. `test_reports` - Сформированные отчеты
```sql
CREATE TABLE test_reports (
id SERIAL PRIMARY KEY,
report_type VARCHAR(50) NOT NULL, -- department, employee, category
parameters JSONB, -- Параметры отчета
file_path VARCHAR(500), -- Путь к файлу
format VARCHAR(10), -- pdf, xlsx
generated_by INTEGER, -- ID администратора
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
---
## Расширение существующих таблиц (миграции)
### training_questions
```sql
-- Добавить тип вопроса
ALTER TABLE training_questions ADD COLUMN IF NOT EXISTS question_type VARCHAR(50) DEFAULT 'single_choice';
-- Добавить баллы
ALTER TABLE training_questions ADD COLUMN IF NOT EXISTS points INTEGER DEFAULT 1;
-- Добавить правильный ответ (индекс)
ALTER TABLE training_questions ADD COLUMN IF NOT EXISTS correct_answer_index INTEGER;
-- Добавить порядок
ALTER TABLE training_questions ADD COLUMN IF NOT EXISTS sort_order INTEGER;
-- Добавить пояснение
ALTER TABLE training_questions ADD COLUMN IF NOT EXISTS explanation TEXT;
```
### training_results
```sql
-- Добавить связь с тестом
ALTER TABLE training_results ADD COLUMN IF NOT EXISTS test_id INTEGER REFERENCES tests(id);
-- Добавить номер попытки
ALTER TABLE training_results ADD COLUMN IF NOT EXISTS attempt_number INTEGER DEFAULT 1;
-- Добавить время прохождения
ALTER TABLE training_results ADD COLUMN IF NOT EXISTS time_spent_seconds INTEGER;
-- Добавить детализацию ответов (JSON)
ALTER TABLE training_results ADD COLUMN IF NOT EXISTS answers_detail JSONB;
```
### training_settings
```sql
-- Добавить связь с тестом
ALTER TABLE training_settings ADD COLUMN IF NOT EXISTS test_id INTEGER REFERENCES tests(id);
-- Добавить категорию
ALTER TABLE training_settings ADD COLUMN IF NOT EXISTS category VARCHAR(100);
-- Добавить случайный порядок
ALTER TABLE training_settings ADD COLUMN IF NOT EXISTS random_order BOOLEAN DEFAULT FALSE;
```
---
## Связь с staff_members
Текущая проблема: используется `telegram_id` для связи с сотрудниками.
Решение: перейти на использование `staff_members.id` как универсального идентификатора:
```sql
-- Добавить staff_id в training_results
ALTER TABLE training_results ADD COLUMN IF NOT EXISTS staff_id INTEGER REFERENCES staff_members(id);
-- Миграция данных
UPDATE training_results tr
SET staff_id = sm.id
FROM staff_members sm
WHERE tr.telegram_id = sm.telegram_id;
-- Создать внешний ключ после миграции
ALTER TABLE training_results
ADD CONSTRAINT training_results_staff_id_fkey
FOREIGN KEY (staff_id) REFERENCES staff_members(id);
```
---
## Типы вопросов
| Тип | Код | Описание |
|-----|-----|----------|
| Одиночный выбор | `single_choice` | Один правильный ответ из нескольких |
| Множественный выбор | `multiple_choice` | Несколько правильных ответов |
| Текстовый ответ | `text` | Свободный текст |
| Сопоставление | `matching` | Сопоставление пар |
| Порядок элементов | `ordering` | Расстановка в правильном порядке |
---
## API Endpoints (рекомендуемые)
### Тесты
- `GET /api/tests` - Список тестов
- `POST /api/tests` - Создать тест
- `GET /api/tests/{id}` - Получить тест с вопросами
- `PUT /api/tests/{id}` - Обновить тест
- `DELETE /api/tests/{id}` - Удалить тест
### Вопросы
- `GET /api/tests/{test_id}/questions` - Список вопросов
- `POST /api/tests/{test_id}/questions` - Добавить вопрос
- `PUT /api/questions/{id}` - Обновить вопрос
- `DELETE /api/questions/{id}` - Удалить вопрос
### Назначения
- `GET /api/assignments` - Список назначений
- `POST /api/assignments` - Назначить тест
- `GET /api/employees/{id}/assignments` - Назначения сотрудника
### Прохождение
- `POST /api/tests/{id}/start` - Начать тест
- `POST /api/attempts/{id}/answer` - Ответить на вопрос
- `POST /api/attempts/{id}/complete` - Завершить тест
### Отчеты
- `GET /api/reports/department` - Отчет по отделениям
- `GET /api/reports/employee/{id}` - Отчет по сотруднику
- `GET /api/reports/category/{id}` - Отчет по категории
- `GET /api/reports/export` - Экспорт отчета (PDF/Excel)
+291
View File
@@ -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
View File
@@ -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`. Бизнес-логика **не** добавлялась. |
+87
View File
@@ -0,0 +1,87 @@
# Перенос тестирования на кабинет HR — простым языком
Это **короткий проектный документ** для заказчика и команды: зачем две базы, как они «сходятся» по людям, что делаем по шагам. Технические детали, таблицы и спринты — в отдельном файле: [migration-to-tgflaskform.md](migration-to-tgflaskform.md).
---
## 1. В чём суть
Сейчас модуль тестирования может жить так:
- **Старое приложение** (то, что уже есть): своя программа и своя база **`clinic_tests`**. В ней заведены «пользователи модуля» (логин, пароль и т.д.) и все тесты, попытки, ответы.
- **Целевое место** — общий HR-кабинет на Python (**`tgFlaskForm`**): там уже есть раздел тестирования, данные лежат в другой базе — **`hr_bot_test`**, и каждый человек привязан к **карточке сотрудника** в HR (`staff_members`).
**Перенос** — это не «скопировать файлы», а **аккуратно переложить смысл** из одной базы в другую так, чтобы в HR было понятно: *этот тест написал тот же Иванов, эту попытку прошла та же Петрова*, и чтобы баллы и история не потерялись.
На переходный период можно держать **новый экран на Flask отдельно** (папка `flask_app` в этом репозитории) — тот же подход, что в кабинете, но свой адрес в браузере, пока не готовы полностью перейти на один вход.
---
## 2. Две базы — зачем и как они связаны
| База | Простыми словами |
|------|------------------|
| **`clinic_tests`** | «Песочница» модуля тестирования: здесь живут тесты, версии, попытки в том виде, в каком их делало старое приложение. |
| **`hr_bot_test`** | «Общий дом» HR: сотрудники, отделы, права, и при переносе — **те же тесты**, но уже в таблицах вида `testing_*`, привязанные к сотрудникам. |
Обе базы обычно стоят **на одном сервере PostgreSQL**, но это **разные логические хранилища** (как два разных диска с разными папками). Скрипт переноса подключается к обеим и **переписывает данные** из одной в другую по правилам (сначала тесты и вопросы, потом попытки и ответы — чтобы ничего не «повисло» без ссылки).
---
## 3. Связка «пользователь модуля» ↔ «сотрудник в HR»
В **`clinic_tests`** человек заведён как запись в таблице **`users`** (логин, роль в модуле и т.д.).
В **`hr_bot_test`** человек — это **`staff_members`** (та самая карточка из кадрового контура).
Чтобы перенос сработал, для **каждого**, кто важен для истории (автор теста, кто проходил, кто назначал), нужно знать одно число: **идентификатор сотрудника в HR**`staff_members.id`.
На практике это делается так:
1. В таблице **`users`** (в `clinic_tests`) есть поле **`staff_id`** — туда записывается как раз **`staff_members.id`** из HR. Тогда программа понимает: *логин `ivanov` в модуле тестов = сотрудник № 12345 в HR*.
2. Если **`staff_id` пустой** — автоматом не понять, кто это. Тогда до переноса нужно **вручную или полуавтоматом** составить соответствия: например таблица «логин / email / ФИО → номер сотрудника в HR», заполнить `staff_id` или отдать это скрипту миграции отдельным файлом.
**Имеется в виду не «настроить взаимодействие двух баз в реальном времени»** (как два приложения, которые постоянно синхронизируются), а **один раз правильно сопоставить людей**, чтобы при копировании данных в HR не оказалось «логин есть, а сотрудник не найден».
---
## 4. Что делаем по этапам (без жаргона)
**Подготовка**
- Решить: пока живём на **старой базе** в новом Flask или сразу пишем в **HR-базу** — и не вести параллельно «два источника правды» без правил.
- Список сценариев: создание теста, версии, назначение, прохождение, разбор, отчёты — и отметить, что уже есть в кабинете, чего не хватает.
**Данные**
- Проверить **`staff_id`** у нужных `users`.
- Сделать **резервную копию** обеих баз.
- Запустить скрипт в режиме **«только посмотреть»** (`--dry-run`): он ничего не пишет в HR, только показывает, сколько чего нашёл.
- На **копии** HR-базы один раз прогнать **настоящий перенос**, открыть несколько тестов и попыток глазами.
- В согласованное короткое окно (когда никто не правит тесты) — перенос на боевую HR-базу, проверка, смена ссылок для пользователей на кабинет.
**После переноса**
- Старое приложение можно оставить только для чтения или выключить, когда убедились, что в кабинете всё ок.
- Бэкап старой базы и журнал переноса хранить по правилам клиники.
Подробные шаги ETL, порядок таблиц и ограничения текущего скрипта — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md), раздел 4. Скрипт: в монорепозитории HR, файл `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`.
---
## 5. Что может пойти не так
- **Не все люди сопоставлены с HR** — часть тестов или попыток не перенесётся или перенесётся с ошибками. Лечится заранее: отчёт по пустым `staff_id` и дозаполнение.
- **Два места, куда одновременно пишут** — данные разъедутся. Лечится правилом: в период перехода пишем только в одно место (или второе только для пилота).
- **Назначения «на весь отдел»** в старой базе — в HR их нужно либо развернуть в список конкретных сотрудников на дату переноса, либо доработать логику отдельно — это заранее обсуждается с заказчиком.
---
## 6. Куда смотреть дальше
| Нужно | Файл |
|--------|------|
| Технический план, спринты, таблицы | [migration-to-tgflaskform.md](migration-to-tgflaskform.md) |
| Состояние кода старого приложения | [PROJECT_STATUS.md](PROJECT_STATUS.md) |
| Запуск нового Flask-контура в Docker | [../flask_app/README.md](../flask_app/README.md) |
| Установка и базы в целом | [../README.md](../README.md) |
+102
View File
@@ -0,0 +1,102 @@
# Этап 2 (на будущее) — слияние TestingWebApp с `HR_TG_Bot/tgFlaskForm`
> **Не текущая фаза.** Текущая работа — **Этап 1**: унификация стека внутри TestingWebApp (Express → Flask + React → Jinja). См. [migration-final.md](migration-final.md). Этот документ — план **последующего** слияния, когда заказчик решит объединять.
**Простое объяснение тех же шагов:** [migration-to-tgflaskform-plain.md](migration-to-tgflaskform-plain.md).
**Связано:** [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`.
---
## 0. Предусловие — Этап 1 закрыт
К моменту, когда этот документ берётся в работу, в TestingWebApp **уже** должно быть:
- Бэкенд переписан с Express на Flask внутри [`flask_app/`](../flask_app/), все 22 эндпоинта работают.
- Фронтенд переписан с React на Jinja-шаблоны в `flask_app/app/templates/`.
- БД — по-прежнему `clinic_tests`, схема не менялась.
- В репозитории остался один сервис приложения.
Если что-то из этого ещё не готово — Этап 2 не начинается.
---
## 1. Что меняется при слиянии
| Аспект | После Этапа 1 (отдельный сервис) | После Этапа 2 (часть HR-кабинета) |
|---|---|---|
| Репозиторий | `TestingWebApp/flask_app/` | `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/` |
| Деплой | Свой Docker-сервис, свой URL/порт | Часть основного `tgFlaskForm`, общий URL `/cabinet/testing/...` |
| БД | `clinic_tests`, UUID | `hr_bot_test`, integer ID, схема `testing_*` |
| Авторизация | JWT/bcrypt + опциональный `HR_AUTH` | Сессии общего HR-кабинета, привязка к `staff_members` |
| Модели | Свои (как в Express, но на Python) | Существующие `TestingTest`, `TestingTestVersion`, `TestingQuestion`, `TestingAnswer`, `TestingAssignment`, `TestingAttempt`, `TestingAttemptAnswer`, `TestingSetting`, `TestingHeadPosition` в `db/models.py` |
| UI | Jinja-шаблоны в `flask_app/app/templates/` | Jinja-шаблоны в `tgFlaskForm/webApp/templates/cabinet/testing/` |
---
## 2. План Этапа 2 (по спринтам)
### E2.0 — Сверка кода и моделей
- Сравнить структуру `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)).
- **Критерий выхода:** документ соответствий + решение по спорным точкам (например, кто прав — `is_active` на цепочке или на версии).
### E2.1 — Перенос кода как blueprint
- Скопировать роуты, сервисы, шаблоны из `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-тест зелёный.
### E2.2 — Миграция данных (ETL)
Скрипт уже готов: [`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`).
Перед прогоном **на актуальных данных** дописать:
- **Перенос `test_assignments` 1:1** — сейчас скрипт переносит только пары «тест-сотрудник» через попытки; нужны и «висящие» назначения без попыток. (Решение Этапа 2.)
- **Логирование пользователей без `staff_id`:** автор → WARN, попытка → WARN; никаких хардовых ошибок. (Решение Этапа 2.)
**Порядок:**
1. Бэкап `clinic_tests` и `hr_bot_test`.
2. `--dry-run` на копии прод-БД, разбор лога.
3. `--apply` на той же копии, ручная сверка через UI HR-кабинета.
4. После приёмки — `--dry-run` + `--apply` на боевой БД.
### E2.3 — Cutover
Если к этому моменту у TestingWebApp всё ещё «песочница для тестировщиков» (как сейчас) — простое переключение, без окна простоя и баннеров. Если появятся реальные пользователи — добавить пункт E2.3.1: коммуникация и redirect.
- Заморозка записи в `flask_app/` старой инсталляции (read-only).
- Прогон ETL на боевом.
- Маршрутизация: внешние ссылки `clinic-tests.example.com/*``hr-cabinet.example.com/cabinet/testing/*`.
- В корневом репозитории TestingWebApp — ветка `legacy/clinic-tests-flask`, в README — ссылка на этот документ и дату EOL.
**Критерий выхода:** мониторинг ошибок (например Sentry, уже подключён в `webApp/__init__.py`), отсутствие P1 в первую неделю.
---
## 3. Что трогаем в HR-кабинете до Этапа 2
**Ничего.** Существующий модуль `tgFlaskForm/webApp/interfaces/testing/` развивается своим темпом командой HR-кабинета и **не** должен подстраиваться под TestingWebApp до момента слияния. Если в нём появляются полезные правки — переносим в `flask_app/` обратным потоком.
---
## 4. Риски Этапа 2 и как их снимать
| Риск | Мера |
|------|------|
| Несовпадение `users.staff_id``staff_members.id` | Проверка перед `--apply`; пользователей без `staff_id` пропускаем по решению. |
| Расхождение моделей (UUID vs integer, поля «на цепочке» vs «на версии») | Закрыть в E2.0; подкрепить unit-тестами на конвертацию. |
| Назначения «отдел → N сотрудников» | Логировать развёртку с пометкой `created_from_department=...`. |
| Двойное развитие модуля HR-кабинета | До Этапа 2 — не править `tgFlaskForm/cabinet/testing` под нужды TestingWebApp. |
---
## 5. Производительность кабинета (общее)
Если после переноса страницы кабинета или мини-приложения работают медленно — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md). Это вспомогательный документ, не часть этапов миграции.
+191
View File
@@ -0,0 +1,191 @@
# Производительность страниц Flask (кабинет / мини-приложение): рабочий документ
Документ написан так, чтобы **человек без контекста проекта** мог по нему понять: *что за система, где код, что именно оптимизировать, в каком порядке и как понять, что задача сделана*.
---
## 1. Для кого и зачем этот файл
- **Аудитория:** ты сам через полгода, новый разработчик, DevOps, тимлид на планировании.
- **Проблема от пользователей:** «страницы мини-приложения на Flask грузятся долго».
- **Цель документа:** не угадать решение («перепишем на React»), а **зафиксировать процесс**: сначала измерить и локализовать узкое место, потом применить исправления с наибольшим эффектом при наименьшем риске.
---
## 2. Где живёт проект (карта репозитория)
Рабочая копия — монорепозиторий **`HR`** (корень: `ClinicProjects/HR` или аналог). Для задачи производительности важны в первую очередь два контура:
| Контур | Путь в репозитории | Назначение |
|--------|-------------------|------------|
| **Основной веб-кабинет HR** | `HR_TG_Bot/tgFlaskForm/` | Flask-приложение: авторизация, кабинет, разделы в т.ч. **тестирование сотрудников**. Именно сюда чаще всего относят жалобы «мини-приложение / кабинет на Flask». |
| **Отдельный Flask-скелет под тестирование** | `TestingWebApp/flask_app/` | Упрощённое приложение того же стека (переходный контур, Docker-сервис `testing-flask`, порт **3108** в `TestingWebApp/docker-compose.dev.yml`). Может быть медленным по тем же причинам (БД, шаблоны, отсутствие кэша статики), но **это не обязательно тот же инстанс**, что видят пользователи в проде. |
Связанные по смыслу документы (миграция данных, две БД):
- `TestingWebApp/docs/migration-to-tgflaskform.md` — технический план.
- `TestingWebApp/docs/migration-to-tgflaskform-plain.md` — коротко «для людей».
**Важно:** жалоба «долго грузится» может относиться к:
1. **Веб-кабинет в браузере** (`tgFlaskForm`, типичный порт локально **3104** в `web_run.py`).
2. **Встроенный WebView в мини-приложении** (Telegram MAX и т.п.) — тот же HTML с того же хоста, но **другая сеть, кэш, DNS, TLS**; воспроизведение обязательно на целевом клиенте.
3. **Переходный контур** `TestingWebApp` на **3108** — проверять отдельно, если пользователи реально ходят туда.
Перед оптимизацией **уточнить URL/контур** у тех, кто жалуется.
---
## 3. Что такое «страница грузится долго» в технических терминах
Раздели время на части (это основа всей работы):
1. **Сеть до сервера** — DNS, TCP/TLS, RTT, прокси (nginx перед Flask).
2. **Время до первого байта (TTFB)** — всё, что происходит на сервере до начала ответа: middleware, сессия, запросы к БД, рендер Jinja2, формирование заголовков.
3. **Загрузка тела ответа** — размер HTML, сжатие (gzip/brotli).
4. **Параллельная загрузка подресурсов** — CSS, JS, шрифты, картинки: их число, размер, кэширование (`Cache-Control`), HTTP/2.
5. **Выполнение JS на клиенте** — если на странице тяжёлый скрипт; для классического SSR-кабинета часто вторично по сравнению с TTFB.
**Твоя первая задача** — для 2–3 типичных страниц (логин после редиректа, дашборд тестирования, список тестов, прохождение теста) записать: **TTFB**, **DOMContentLoaded**, **полный LCP** (или хотя бы «визуально готово»). Без этого нельзя честно выбрать между «чиним SQL» и «чиним статику».
Инструменты:
- Chrome DevTools → **Network** (колонка Time, размер, waterfall), **Performance**.
- На сервере: логирование длительности запроса (middleware или reverse proxy `request_time` в nginx).
---
## 4. Как устроена загрузка страницы в `tgFlaskForm` (ментальная модель)
Упрощённая цепочка для защищённого маршрута, например дашборда тестирования:
1. Браузер запрашивает URL вида **`/cabinet/testing/`** (blueprint в `webApp/interfaces/testing/__init__.py`, префикс `/cabinet/testing`).
2. Срабатывают глобальные хуки Flask (в т.ч. **cabinet access gate** в `webApp/auth.py`: `register_cabinet_access_gate` — проверка пути, сессии, статуса «Работает»).
3. Декоратор **`@login_required`** на view: редирект на `/login` или вызов функции.
4. View (например `routes_dashboard.py`) вызывает функции из **`db/queries/testing_queries.py`** и др., собирает контекст и вызывает **`render_template(...)`**.
5. Jinja2 собирает HTML из шаблонов в `webApp/templates/` (часто с `extends` / `include` — чем больше вложенность и данных в контексте, тем дольше CPU на рендер).
6. Ответ уходит клиенту; дальше грузятся статические файлы с `/static/...`.
Узкое место может быть на **любом** шаге; чаще всего в таких приложениях — **шаг 4 (БД + много мелких запросов)** и **шаг 5 (большой шаблон)**.
---
## 5. Гипотезы, специфичные для этого кода (куда смотреть первым делом)
Ниже — не обвинение кода, а **чек-лист для проверки** после замеров.
### 5.1. Создание движка БД на каждый вызов сессии
Файл: `HR_TG_Bot/tgFlaskForm/db/session.py`.
Раньше `get_engine()` на каждом вызове делал `create_engine(...)` — новый пул и большие накладные расходы при десятках `get_session()` из `db/queries/*.py` (в т.ч. **`testing_queries.py`**).
**Сделано (код):** в `db/session.py` один **потокобезопасный** engine на процесс и один переиспользуемый `sessionmaker`; `get_session()` по-прежнему возвращает новую ORM-сессию, но поверх общего пула.
**Дальше:** при необходимости сокращать число **отдельных** сессий на один HTTP-запрос (§5.2) — это отдельная оптимизация.
### 5.2. Много открытий/закрытий сессий и запросов на одну страницу
Паттерн в `testing_queries.py`: почти каждая функция делает `s = get_session()`, `try/finally: s.close()`. Одна страница может дернуть **несколько** таких функций подряд → несколько раундов к БД.
**Что сделать:** для «тяжёлых» страниц — либо **одна сессия на request** и передача её вниз, либо **один агрегирующий запрос** вместо N мелких (устранение N+1). Конкретные места — смотреть по trace конкретного URL.
### 5.3. Декораторы и before_request
`login_required` и `cabinet_employment_ok_from_session()` в основном опираются на **сессию**, но gate и другие хуки могут добавлять логику. Если туда когда-нибудь добавят тяжёлые проверки в БД на **каждый** запрос — это сразу ударит по TTFB.
**Что сделать:** убедиться, что на горячем пути нет лишних запросов к БД без необходимости.
### 5.4. Шаблоны и статика
- Большие базовые layout’ы, много `include`, тяжёлые циклы в Jinja — растёт CPU на рендер.
- Статика без длинного кэша — каждый переход визуально «тормозит».
**Что сделать:** Network → сколько запросов к `/static`, какие размеры; для продакшена — заголовки кэша и сжатие на nginx (если nginx есть в цепочке — см. `HR_TG_Bot/docker-compose*.yml` и свою прод-конфигурацию).
### 5.5. Окружение
- `web_run.py`: в non-production используется встроенный сервер Flask; для нагрузочного теста ближе к прод — **waitress** / gunicorn (как в `TestingWebApp/flask_app/run.py` через `WEB_USE_WAITRESS`).
- Сравнение «локально быстро, у пользователей медленно» — почти всегда **сеть, БД на другом хосте, холодный пул, отсутствие индексов на прод-данных**.
---
## 6. План работы (что делать по шагам)
### Фаза 0 — уточнение (полдня максимум)
- [ ] Точный **URL/продукт** (кабинет HR vs TestingWebApp:3108 vs мини-app WebView).
- [ ] **Роль пользователя** и сценарий (первый заход, каждый клик, только раздел тестирования).
- [ ] Есть ли **nginx / CDN** перед приложением.
### Фаза 1 — измерение (обязательно)
- [ ] Зафиксировать 3–5 URL и для каждого: TTFB, размер HTML, число запросов, суммарный вес.
- [ ] На сервере: время обработки запроса (middleware: `before_request` timestamp vs `after_request`).
- [ ] Для самого медленного URL: **список вызовов к БД** (SQLAlchemy events, логирование, или APM, если есть).
**Выход фазы:** одно предложение вида: «узкое место — TTFB из-за БД» или «узкое месте — 40 запросов к статике без кэша».
### Фаза 2 — правки по приоритету (типичный порядок)
1. **Инфраструктура БД:** один engine на процесс; пул; при необходимости индексы (после анализа `EXPLAIN` самых тяжёлых запросов).
2. **Сократить число round-trips к БД** на страницу: объединение запросов, eager loading где уместно, кэш редко меняющихся справочников (с инвалидацией или коротким TTL).
3. **Шаблоны:** убрать лишние данные из контекста; упростить самые тяжёлые `include`.
4. **Статика:** fingerprint + `Cache-Control: immutable` для бандлов; минификация; не тянуть огромные библиотеки на каждую страницу без нужды.
5. **Прод-сервер приложений:** waitress/gunicorn, адекватное число воркеров за reverse proxy.
### Фаза 3 — если «всё ещё медленно именно при переходах между страницами»
Это уже про **полную перезагрузку HTML**, а не про «Flask медленный»:
- Вариант **A:** [HTMX](https://htmx.org/) / **Turbo** — сервер по-прежнему отдаёт HTML, обновляются фрагменты; стек остаётся Python + Jinja.
- Вариант **B:** точечный **React/Vite** только для тяжёлого экрана (остальной кабинет не трогать) — выше стоимость сопровождения.
Выбор между A и B — после фазы 1: если TTFB уже низкий, а больно от полной перезагрузки — имеет смысл A/B; если узкое место всё ещё сервер — сначала дожать фазу 2.
---
## 7. Критерии готовности (Definition of Done)
Задачу по производительности можно закрыть, когда:
1. Есть **замеры до/после** по тем же URL и тем же окружению (или согласованная методика).
2. Задокументировано **узкое место** и **что изменено** (12 абзаца в changelog или в этом файле внизу секция «Итог»).
3. Для пользовательского сценария выполняется согласованный **SLO** (например: TTFB p95 &lt; X ms, полная загрузка ключевой страницы &lt; Y s на 4G) — пороги задаёт продукт/команда, не этот документ.
---
## 8. Риски и что не делать
- **Не** менять стек на SPA «с нуля» без измерений — высокий риск и долгий срок при том, что проблема может быть в пуле БД или кэше статики.
- **Не** оптимизировать только локально на пустой БД — планы запросов на прод-объёме другие.
- **Не** кэшировать персональные страницы на CDN без понимания заголовков и кук — риск утечки данных между пользователями.
---
## 9. Быстрый указатель файлов
| Тема | Путь |
|------|------|
| Точка входа веба | `HR_TG_Bot/tgFlaskForm/web_run.py` |
| Регистрация приложения / blueprints | `HR_TG_Bot/tgFlaskForm/webApp/__init__.py` (и связанные модули) |
| Модуль тестирования (маршруты) | `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/routes_*.py` |
| Запросы к БД тестирования | `HR_TG_Bot/tgFlaskForm/db/queries/testing_queries.py` |
| Сессия и engine | `HR_TG_Bot/tgFlaskForm/db/session.py` |
| Авторизация и gate | `HR_TG_Bot/tgFlaskForm/webApp/auth.py` |
| Шаблоны | `HR_TG_Bot/tgFlaskForm/webApp/templates/` |
| Переходный Flask + waitress | `TestingWebApp/flask_app/run.py`, `TestingWebApp/flask_app/app/` |
| Docker dev (пример порта 3108) | `TestingWebApp/docker-compose.dev.yml` |
| Docker dev кабинета | `HR_TG_Bot/docker-compose.dev.yml` |
---
## 10. Секция «Итог» (заполнять по мере работы)
| Дата | Контур | Узкое место | Что сделано | Метрика до → после |
|------|--------|-------------|-------------|-------------------|
| 2026-04-27 | `tgFlaskForm` | Новый SQLAlchemy engine на каждый `get_session()` | Singleton `get_engine()` + кэш `sessionmaker` в `db/session.py` | _замерить на стенде_ |
---
*Документ создан как рабочая инструкция по задаче «медленная загрузка страниц Flask». Обновляй таблицу в §10 и при необходимости добавляй ссылки на PR/коммиты.*
+93
View File
@@ -0,0 +1,93 @@
# Декомпозиция доработки (по ТЗ [revision_task/task.md](task.md))
**Стек (репозиторий [TestingWebApp](../..)):** **Node.js** (backend), **PostgreSQL**, **Docker**; фронт — desktop-first SPA. Экосистема клиники: см. [HR_TG_Bot README](../../../HR_TG_Bot/README.md); перенос на Python/FastAPI **не** считается обязательным для этого репо — **контракт** данных и [card1.md](revision_task/card1.md) важнее.
**Карта больших кусков работ:** [card1.md](card1.md) (версии **V**, документ **D**, авторизация **HR A**).
**Идеи и пожелания (простой язык):** [BACKLOG_IDEAS.md](BACKLOG_IDEAS.md)
**Журнал проверок по спринтам (авто + ручные шаги для заказчика):** [TESTING_JOURNAL.md](TESTING_JOURNAL.md)
**Сводка «что сделано / что дальше» (простым языком):** [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · [инструкция для dev-стенда](../DEV_CONTOUR_USER_GUIDE.md)
**Этап 1 (ТЗ §4)** — пять фич: 4.1–4.5 (части можно параллелить).
**Этап 2 (ТЗ §5)** — дашборды.
**Этапы 35** — интеграция в общий HR, MAX, уведомления.
**Первые спринты:** [sprint-01.md](sprint-01.md), [sprint-02.md](sprint-02.md) (и при наличии `sprint-02-testing`).
**Данные и интеграция с HR (зафиксировано):** один инстанс Postgres, **`clinic_tests`** — схема тестов; **`hr_bot_test`** — сотрудники и **RBAC**. Идентичность в процессах — **`staff_members.id`**; **`telegram_id`** только для справки, не для логики. Итоговая проверка прав — существующая HR-модель / API, без параллельной «второй» RBAC. Подробно: [card1](card1.md#хранение-связь-с-сотрудниками-rbac-зафиксировано), [README](../../README.md#данные-сотрудники-интеграция-с-hr).
---
## A. Подготовка и база (фундамент)
| ID | Подзадача |
| --- | --- |
| A.1 | Репозиторий, Docker Compose (PostgreSQL), .env, health |
| A.2 | Схема БД, миграции: пользователи, подразделения, тесты, версии, попытки (см. [card1 V.1](card1.md)) |
| A.3 | Аутентификация: **локальная** (dev) **или** **через** [Postgres_TG_Bots / HR users](card1.md#часть-a--авторизация-по-паролю-бд-postgres_tg_bots); в прод — привязка к `staff_members.id`, RBAC из HR |
| A.4 | CRUD тест/назначение/прохождение (база шагов `docs/шаги/`) + затем **B** |
*Если A.1–A.4 частично сданы — добить по [sprint-01](sprint-01.md) и [card1](card1.md).*
---
## B. Фича 4.1 — Версионирование тестов
См. полностью [card1.md — Часть V](card1.md#часть-v--версионирование-цель-корректная-история-при-правках-автора) (V.1V.10).
---
## C. Фича 4.2 — AI-помощник (DeepSeek)
| ID | Подзадача |
| --- | --- |
| C.1 | Ключ в БД; `/settings`; «Проверить»; ключ не на фронт |
| C.2 | OpenAI-совместимый клиент, `json_object` |
| C.3 | Сгенерировать/проверить/улучшить тест; модалки, было→стало |
| C.4 | Вопрос: улучшить, дистракторы, подсказка (с 4.4) |
*Импорт из документа ([card1 D](card1.md)) тянет C.1C.3.*
---
## D. Импорт из документа
См. [card1 D.1D.5](card1.md#часть-d--загрузка-документа--черновик-теста).
---
## E–F. Подсказки и режимы (§4.4–4.5)
| ID | Кратко |
| --- | --- |
| E.x | Подсказка в вопросе, показ по режиму |
| F.x | Таймер, мгновенная оценка, итог в конце |
---
## G. Этап 2 — Дашборды (§5)
| ID | Подзадача |
| --- | --- |
| G.1 | Дашборд сотрудника |
| G.2 | Руководитель подразделения |
| G.3 | Директор / вся клиника |
---
## H. Спринт 1 — сопоставление
| Спринт | Охват |
| --- | --- |
| **Спринт 1** | A (дозакрытие) + **B** = [card1 V](card1.md) + при согласовании [A](card1.md) (Postgres_TG_Bots) |
| **Спринт 2** | **C** + начало D при наличии LLM; иначе D без генерации = только текст/ручной ввод |
| Далее | E, F, G, интеграция MAX, уведомления |
---
## Сопоставление с файлами
| Документ | Содержание |
| --- | --- |
| [card1.md](card1.md) | Задачи Card 1: версии, документ, auth HR |
| [sprint-01.md](sprint-01.md) | Спринт 1, кратко |
| [task.md](task.md) | ТЗ 1.0 |
+33
View File
@@ -0,0 +1,33 @@
# Идеи и пожелания по доработкам (для согласования с заказчиком)
*Язык простой, без жаргона разработки. Сюда попадает всё, что всплыло в обсуждениях и ещё не вошло в жёсткое ТЗ.*
**Как пользоваться:** приоритеты и «да/нет» фиксируем отдельно; пункты **не** удаляем — переносим в раздел **Решено** с кратким итогом, если идея закрыта или отклонена.
**Что уже в продукте (кратко):** [../PROJECT_STATUS.md](../PROJECT_STATUS.md).
---
## На рассмотрении
| № | Суть (что даст клинике) | Примечание |
|---|-------------------------|------------|
| 1 | **Напоминания** о сроке теста в мессенджере (когда срок близок или прошёл) | Связь с будущим HR-приложением в MAX; не путать с самим прохождением теста — только напоминание зайти в кабинет. |
| 2 | **Один раз скачать** свод по отделу или по клинике в **таблицу** (для руководителя), без «технических» деталей | Уточнить, нужен ли **Excel** / PDF и какие столбцы обязательны. |
| 3 | **Памятка рядом с тестом** — кратко: зачем тест, на что обратить внимание (текст от автора) | Улучшает вовлечение; не подменяет **описание** теста, если оно уже есть. |
| 4 | **Сравнение** «как сотрудник ответил в прошлый раз» с текущим прохождением | Для **повторных** тестов по той же теме; важна приватность и согласие кадров. |
| 5 | **Крупный шрифт** и **контраст** в режиме «стресс/смена» для сотрудников, много работающих в перчатках с экраном | Доступность; опциональная **тема** в настройках профиля. |
| 6 | **Печатная** версия **итога** (сдал/не сдал) для **личного** дела — один лист, без лишнего | Не путать с полноценной «выгрузкой для 1С»; это про человеко-понятный **итог** для сотрудника. |
| 7 | **Повтор** одного вопроса в конце теста — «самопроверка» (опционально, как у автора) | Снижает нервозность; выключаемо **на уровне** теста, чтобы на экзамене не мешать. |
| 8 | **Аудит:** кто из администраторов менял активную **версию** теста и когда | Для **разборов** при споре «кому показывали старую/новую редакцию». |
---
## Решено или «не делаем в этой волне»
| № | Суть | Итог |
|---|------|------|
| — | *Пока пусто* | |
---
*Обновляйте дату: **2026-04-23** (создание файла).*
+95
View File
@@ -0,0 +1,95 @@
# Единый журнал проверок по спринтам
**Для кого этот документ.** Часть проверок — на стороне разработки (раздел A). **Ручные проверки с заказчиком** ведутся **так:**
1. **Ассистент** в чате выдаёт **ровно одно** поручение за раз, обычно в духе: «зайди в…», «нажми…», «посмотри, видно ли…» — **без** длинного списка вперёд.
2. Вы отвечаете **только** **ОК** или **не ОК** (при **не ОК****одна** короткая фраза, что не сработало).
3. **Ассистент сам** вносит результат в **раздел B** (таблицу): код шага, суть поручения **по факту**, ваш ответ, дата. В таблице **не** нужно ждать, пока вы сами куда-то переносите — это делает ассистент.
Ниже в разделе B таблица — **журнал уже прошедших** шагов. Новые шаги приходят **сначала в чат**, потом дублируются сюда.
**Ветка / коммит последней привязки:** `dev` (обновлять при релизе на проверку; актуализация документации 2026-04-24 — [../PROJECT_STATUS.md](../PROJECT_STATUS.md))
**Адрес стенда:** `http://localhost:3107` (UI; при стеке `docker compose -f docker-compose.dev.yml up` — тот же origin для `/api/…` через Nginx; прямой API с хоста — `http://localhost:3001`).
**Актуальный UI (после 2026-04-24):** старт прохождения — **не** с карточки теста, а со **списка «Тесты»**: в каждой строке **справа** кнопка **«Пройти»**; **слева** — ссылка на карточку. Под названием — **«Автор: Вы»** или **«Автор: Фамилия И. О.»**; в шапке — **Фамилия И. О.**, полное ФИО в подсказке. После **«Завершить тест»** — **разбор** по вопросам; у автора в карточке — **«Прогоны и разбор»** по завершённым попыткам. Шаги **S1-07** и **S1-13** в таблице ниже описывают **старый** вариант («Старт/Начать попытку» на карточке) — оставлены в журнале как история. Регресс по новому потоку — **S1-14** и далее.
**Текущий шаг для ручной проверки (код в чате = тот же номер):** **S1-14** — см. раздел B.
---
## Спринт 1 — Версии тестов и честная история прогонов
**Смысл для бизнеса.** Если руководитель поправил тест после того, как кто-то уже прошёл его, **старые результаты** должны оставаться привязаны к **той редакции**, по которой человек реально отвечал — без путаницы в разборе ошибок.
### Раздел A — Проверки без участия заказчика (разработка / ассистент)
| № | Что проверено | Статус | Дата |
|---|----------------|--------|------|
| A1 | В проекте есть миграция базы: связь версий «родитель» (`parent_id`) и правило «только одна активная версия на тест» | [x] `002_…sql` | 2026-04-24 |
| A2 | Линтер (`npm run lint`): **0 errors**; остаются **warnings** `no-console` в существующих файлах | готово (errors) | 2026-04-24 |
| A3 | `npm test` в `backend/`: hasAny, Werkzeug, V.9 smoke, **D.2** `documentExtract``src/**/*.test.js` (10+ тестов) | [x] готово | 2026-04-25 |
| A4 | Запрос «здоров ли сервер» по адресу `/api/health` при запущенном backend | [x] `{"status":"ok"}` | 2026-04-24 |
| A5 | Реализация card1: API тестов/версий, черновик, HR-login (опц.), D.1 upload, UI списка/версий/черновика (в `dev`) | [x] код | 2026-04-25 |
**Техническая заметка:** реализация `hasAnyAttemptForTest` в `backend/src/services/testChainService.js`, тесты в `testChainService.test.js`.
---
### Раздел B — Журнал ручных шагов (заполняет ассистент после ответа в чате)
| Код | Что попросили сделать (кратко) | Ваш ответ | Дата |
|-----|------------------------------|-----------|------|
| S1-00 | Открыть `TESTING_JOURNAL.md`, просмотреть верх и раздел B; в таблице — строка S1-00 «ожидает…» | ОК | 2026-04-23 |
| S1-01 | Открыть `card1.md`, убедиться, что есть блок про V.1 / V.2 / V.3 (сохранение / форк) | ОК | 2026-04-24 |
| S1-02 | Открыть в браузере `http://localhost:3107` — должна загрузиться **страница входа** (заголовок «Клинические тесты» / «Войдите в систему», поля логин и пароль, кнопка «Войти») | ОК | 2026-04-23 |
| S1-03 | В браузере открыть `http://localhost:3107/api/health` (или `http://localhost:3001/api/health` при прямом доступе к API) — в ответе виден JSON c полем `status` со значением `ok` (страница не «404» и не пустая ошибка) | ОК | 2026-04-23 |
| S1-04 | С экрана входа войти: учётка **вашей** среды (локальный `users` в `clinic_tests` **или** при `HR_AUTH=1` — логин HR). После **«Войти»** должен открыться экран **«Тесты»** с **шапкой** (слева бренд, справа **Фамилия И. О.**/роль и кнопка **«Выйти»**; не обязательно полное тройное ФИО в одну строку). Список тестов может быть пустым. | ОК | 2026-04-24 |
| S1-05 | На экране «Тесты» в поле **«Новый тест — название»** ввести любое имя, нажать **«Создать»**. Должен открыться экран карточки теста (ссылка «← к списку», блок **Версии**, черновик и т.д.). | ОК | 2026-04-24 |
| S1-06 | На карточке теста в блоке **«Черновик (V.3)»** (при необходимости изменить текст вопроса) нажать **«Сохранить черновик»**. Под кнопкой появляется пояснение (например, что черновик применён) или пусто без ошибки на красном. Раздел **Версии** остаётся / обновляется без сообщения «Доступ запрещён». | ОК | 2026-04-24 |
| S1-07 | В блоке **«Прохождение (V.4)»** нажать **«Старт попытки»**. Под кнопкой/рядом появляется сообщение, что **попытка стартовала** (с id или без), без «Доступ запрещён» / без красного текста с ошибкой API. | ОК | 2026-04-24 |
| S1-08 | Нажать **«← к списку»** и убедиться, что **ваш тест** отображается в списке (название, строка с v… и фрагментом id активной версии). | ОК | 2026-04-24 |
| S1-09 (опц.) | В шапке нажать **«Выйти»** — должен открыться экран входа. Снова **«Войти»** с теми же данными — снова экран **«Тесты»** (список на месте). | ОК | 2026-04-24 |
| S1-10 | **История версий** (card1 V.7): в карточке теста видны **заголовок**, таблица версий (версия, активна, дата). Если **≥2** версий — нажать **«сделать активной»** на неактивной, согласиться в confirm; в таблице **текущая** переносится; в списке **«Тесты»** в метке строки обновился **фрагмент id** активной версии. | ОК | 2026-04-25 |
| S1-11 | **Публикация / V.6**: **«Скрыть из списка»** — в верхнем списке теста нет; на странице «Тесты» внизу блок **«Скрытые вами из списка»** — открыть карточку — **«Снова показать в списке»** — тест снова в верхнем списке. | ОК | 2026-04-25 |
| S1-12 | В блоке **«Содержание: вопросы…»** задать вопрос(ы) и варианты, отметить верные, **«Сохранить черновик»** — без красной ошибки; **История версий** / заголовок обновляются при необходимости. | ОК | 2026-04-24 |
| S1-13 | **«Начать попытку»** — открывается экран с вопросами (радио/чекбоксы); **«Завершить тест»** — виден результат: правильно из N, %, сравнение с порогом, без 400 «нет вопросов» при сохранённых вопросах. | ОК | 2026-04-24 |
| S1-14 | Экран **«Тесты»** (`/tests`): у строки с тестом **справа** видна кнопка **«Пройти»**; **слева** клик по названию открывает **карточку** без автоматического старта попытки. | *ожидает* | — |
| S1-15 | Со **списка** нажать **«Пройти»** у теста с сохранёнными вопросами: открывается экран попытки; **«Завершить тест»** — результат (корректно из N, %, порог), без красной ошибки API. | *ожидает* | — |
| S1-16 | **Карточка в режиме «не автор»** (сотрудник / другой пользователь): **нет** кнопки «Начать попытку»; есть короткий текст, что пройти тест из **каталога** кнопкой «Пройти» справа. | *ожидает* | — |
| S1-17 (опц.) | **Автор** в карточке своего теста: раздела **«Прохождение»** с «Начать попытку» **нет**; после **«Сохранить черновик»** сообщение о статусе — **под** кнопками в блоке **«Содержание: название, порог, вопросы»**. | *ожидает* | — |
*Старые номера S1-01… сведём к той же таблице, когда появятся экраны; формулировки шагов вы получите **только** в чате, по одному.*
**Итог спринта 1:** дата **2026-04-25** комментарий заказчика одной фразой: **смоук + V.6V.7 (S1-02…S1-11) и сценарий черновик→прохождение (S1-12, S1-13) пройдены; карточка card1 в объёме приёмки сценария закрыта, остаётся бэклог D.2+ / V.9 E2E**
---
## Спринт 2 — *(заготовка)*
### Раздел A — автопроверки
| № | Описание | Статус | Дата |
|---|----------|--------|------|
| | | [ ] | |
### Раздел B — поручения заказчику
| Код | Действие | Ответ | Зафиксировано |
|-----|----------|-------|----------------|
| | | | |
---
## Сводка по спринтам (для статус-встречи)
| Спринт | Тема простыми словами | Раздел A | Раздел B |
|--------|------------------------|----------|----------|
| 1 | Версии, история прогонов | приём (код в dev) | S1-02…S1-13 ОК; **регресс UI:** S1-14… *(в процессе)* |
| 2 | *(по мере появления)* | | |
---
*Связанные файлы: [sprint-01-testing.md](sprint-01-testing.md) (черновик чек-листа), [card1.md](card1.md) (задачи).*
**Очередь (по запросу / спринт 2):** закрыть **S1-14****S1-17** (новый сценарий «Пройти»); затем — регресс после релизов; **D.2D.5**; **V.9** E2E; углубление V.8 (назначения / «мои тесты») по card1.
+109
View File
@@ -0,0 +1,109 @@
# Карта задач: Card 1 — история прохождений, документ, авторизация HR
**Связь со спринтами:** основная масса пунктов **V.x****Спринт 1** (версионирование + инфра); **D.x** — загрузка документа (может пересекаться со **Спринтом 2** / AI, если без LLM черновик не делается); **A.x** — авторизация **базой** `Postgres_TG_Bots` (выполнять после/параллельно с V.1, чтобы не плодить дубли пользователей на долгий срок).
**Фактический стек репозитория [TestingWebApp](../../README.md):** Node.js (backend), PostgreSQL, Docker; фронт — SPA в `frontend/`. План доработок **не** привязывать к FastAPI, если в коде API на Express/Node.
### Хранение, связь с сотрудниками, RBAC (зафиксировано)
- **Один кластер PostgreSQL** (как в [Postgres_TG_Bots](../../../Postgres_TG_Bots)): отдельные **базы****`clinic_tests`** (тесты, назначения, попытки, миграции модуля) и **`hr_bot_test`** (штат, справочники, уже реализованный **RBAC**). Таблицы модуля тестов **не** вешаем в `hr_bot_test`, чтобы не конфликтовать по именам (`users`, `departments` и т.д. уже заняты смыслами HR).
- **Сотрудник в бизнес-процессах модуля тестирования** идентифицируется по **`staff_members.id`** (БД `hr_bot_test` / экосистема [hr_web_viewer](../../../Postgres_TG_Bots/hr_web_viewer)). В `clinic_tests` храним **суррогатные ссылки** на сотрудника (например `staff_id` / UUID той же сущности), без дублирования ФИО и кадровых данных в долгую.
- **`telegram_id`** в данных сотрудника — **только справка** (в т.ч. для исторических выгрузок, отображения). **Ни назначения, ни проверок прав, ни выбор сотрудника в сценариях модуля** от `telegram_id` **не** зависят и не должны зависеть.
- **RBAC:** единая цель — опираться на **уже существующую** в клинике модель (роли, привязки к сотруднику, permissions). Проверка «можно ли действие» в конечном варианте — через **HR API** / общий auth-контур и/или согласованное чтение RBAC-таблиц; в `clinic_tests` **не** строим параллельную полную матрицу ролей. На переходных этапах допустимы упрощения (MVP-флаги), пока в контракте не зафиксировано иное.
---
## Часть V — Версионирование (цель: корректная история при правках автора)
**Правила (приёмка):**
1. Пока по **цепочке теста** (`tests.id`) **не было ни одной** попытки в `test_attempts` (через любую `test_version_id` этой цепочки) — автор **редактирует на месте** текущую единственную строку `test_versions` и дочерние вопросы/ответы; поле `version` **не** увеличивается.
2. Как только появилась **хотя бы одна** попытка — **каждое** сохранение с изменениями контента создаёт **новую** строку `test_versions`: `version = max+1`, `parent_id` → id предыдущей версии, прежняя версия `is_active = false`, новая `is_active = true`; старые вопросы/ответы **копируются** в новую версию.
3. Все версии одной цепочки — общий `test_id`; цепочка линейна по `parent_id` (и/или по монотонному `version` при одном `test_id`).
4. `test_attempts.test_version_id` **NOT NULL** — попытка всегда на снимок версии, разбор старых результатов не «плывёт» при новых правках.
5. Списки тестов для **сотрудников** и **авторов**: только **активная** версия цепочки (`test_versions.is_active` и `tests` не скрыт деактивацией цепочки).
6. **История версий** в UI: просмотр, **ручной** выбор активной версии; при выборе в транзакции: у всех версий данного `test_id` `is_active = false`, у выбранной `is_active = true`. Новые старты попыток — по **текущей** активной версии.
7. **Деактивация теста целиком** — флаг на уровне `tests` (скрыть цепочку из списков, **без** удаления строк).
**Задачи (детализация):**
| ID | Задача | Критерий |
| --- | --- | --- |
| V.1 | Миграция БД: `test_versions.parent_id` (FK на `test_versions.id`, ON DELETE NO ACTION/RESTRICT), частичный уникальный индекс: не более **одной** `is_active = true` на `test_id` (если СУБД поддерживает; иначе — уникальный индекс + проверка в сервисе) | `migrate` отрабатывает пусто на повтор |
| V.2 | Сервис `hasAnyAttemptForTest(testId)` + unit-тесты | Покрыты кейсы: 0 попыток / 1+ по любой версии цепочки |
| V.3 | `saveTestDraft(author, testId, payload)`: ветвление in-place **vs** `forkNewVersion` (копия вопросов/опций) | Соответствие правилам 1–2 |
| V.4 | Старт попытки: в `test_attempts` писать `test_version_id` **той** версии, что была **активна** в момент старта | Нет перезаписи `version_id` позже |
| V.5 | API: `GET /tests` (роль) — только активные цепочки; `GET /tests/:id/versions`; `POST /tests/:id/versions/:vid/activate` | 403/404 по политике |
| V.6 | API: `PATCH /tests/:id` (деактивация цепочки `tests.is_active` или отдельное поле) | Список пустеет, данные на месте |
| V.7 | UI автора: номер/метка версии, предупреждение при «после первой попытки», экран **история версий**, кнопка **сменить активную** (с confirm) | Смоук `sprint-01-testing.md` |
| V.8 | UI списки сотрудника/автора: **один** ряд на цепочку, без дублей версий | `GET /tests/:id/summary` + упрощённая карточка для не-автора; список `GET /tests` с JOIN на активную версию |
| V.9 | Интеграционные тесты API + регресс «разбор старой попытки» по старым `question_id` | `backend/src/integration/v9card1.test.js` при `CLINIC_TESTS_INTEGRATION=1` и миграциях; без БД — skip |
| V.10 | *Продукт:* при новой версии `test_assignments` **не** переносим на новый `test_version_id`; старт попытки — по **активной** версии (см. [task.md §2.6](task.md)) | Зафиксировано в ТЗ |
---
## Часть D — Загрузка документа → черновик теста
**Цель:** загрузить файл (PDF, DOCX — перечислить лимиты), извлечь текст, **сгенерировать** структуру вопросов (логично тянуть **LLM** из ТЗ §4.2) или дать мастер «вставьте текст».
| ID | Задача | Критерий |
| --- | --- | --- |
| D.1 | Эндпоинт `POST /tests/import/document` (multipart), валидация размера/типа, сохранение во временное хранилище | 413/400 при нарушении |
| D.2 | Извлечение текста: PDF (библиотека на выбор), DOCX (zip/xml) | Юнит на фикстуре |
| D.3 | Вызов слоя **генерации** (если **есть** ключ DeepSeek — → промпт; иначе — заглушка «только вручную») с ответом JSON по схеме вопросов/ответов | Согласовано с §4.2 |
| D.4 | UI: кнопка «Из документа», превью, применение → дальше **тот же** поток сохранения, что и ручной редактор (в т.ч. V.1–V.3) | — |
| D.5 | Удаление временных файлов после обработки / TTL | — |
---
## Часть A — Авторизация по паролю, БД [Postgres_TG_Bots](../../../Postgres_TG_Bots)
**Контекст:** в `Postgres_TG_Bots`/`hr_web_viewer` учёт `users` с полями `username`, `password_hash` (Werkzeug `pbkdf2:sha256` / `scrypt` через `werkzeug.security` — см. `hr_web_viewer/models/user_models.py`). **Идентичность сотрудника** для сценариев тестов — по **`staff_members.id`** (см. блок «Хранение…» выше); **`telegram_id` не используем** в логике входа, назначений и прав.
| ID | Задача | Критерий |
| --- | --- | --- |
| A.1 | Вторичное соединение: `HR_DATABASE_URL` (или DSN) к **`hr_bot_test`**, read-only **или** отдельный пользователь с `SELECT` на `users` и `staff_members` (для связки логин → `staff_id`, подразделение и т.д.) **отдельно** от `DATABASE_URL` в **`clinic_tests`** | Пример `.env` в `TestingWebApp` |
| A.2 | Реализация `verifyPassword` в Node, **совместимой** с `check_password_hash` (использовать пакет, понимающий префиксы `pbkdf2:sha256:` и `scrypt:`) | Тест: тот же хеш, что сгенерировал Werkzeug |
| A.3 | Логин: по `username``users` в `hr_bot_test`; при успехе — токен **TestingWebApp** с привязкой к **`staff_members.id`** (и при необходимости к локальному `users` в `clinic_tests` только как технический mirror **без** отдельного жизненного цикла пароля). Пароли **только** в HR-таблице `users` | Нет дублирования паролей в долгую |
| A.4 | Отключить/не использовать регистрацию с локальным `password_hash` в прод-режиме, если включён `HR_AUTH=1` | Флаг в `.env` |
| A.5 | Маппинг ролей: взять из **существующей** RBAC-модели HR (см. `staff_role_assignments` / roles & permissions) **или** согласованный вызов **HR API**; MVP — ограниченный набор, без опоры на `telegram_id` | [README — данные и интеграция](../../README.md#данные-сотрудники-интеграция-с-hr) |
---
## Порядок работ (рекомендация)
1. **V.1****V.2****V.3** (ядро версий).
2. **A.1**–**A.3** параллельно или сразу после **V.1** (нужен стабильный логин для стенда).
3. **V.4****V.9** и UI **V.7****V.8**.
4. **D.*** после появления клиента LLM (или D.1–D.2 + ручной встав текста без LLM).
5. **V.10** — решение по назначениям до **V.5** если назначения уже в проде.
---
## Ссылки
- ТЗ: [task.md](task.md) §4.1, §4.2
- Спринт 1: [sprint-01.md](sprint-01.md)
- Проверки и журнал: [TESTING_JOURNAL.md](TESTING_JOURNAL.md)
- Старый чек-лист: [sprint-01-testing.md](sprint-01-testing.md)
- Анализ таблиц (если ведёте): [TEST_TABLES_ANALYSIS.md](../TEST_TABLES_ANALYSIS.md)
---
## Статус реализации (сводно, 2026-04-25+)
| ID | Статус | Комментарий |
| --- | --- | --- |
| V.1V.6, V.7, V.10 | Код + API + UI приёмка | [TESTING_JOURNAL S1-10, S1-11](TESTING_JOURNAL.md); публикация, скрытые, история. |
| V.8 | Расширен MVP | `GET /api/tests`**только свои** (`created_by`) **и** тесты с **назначением** на `users.id` (`test_accessService.js`). Карточка/попытка/starter/chain — та же логика. Старт **«Пройти»** в списке. Назначение: `POST /api/tests/:id/assign` (только **автор**), при production — `CLINIC_ASSIGNMENT_ENABLED=1` (`featureFlags.js`); в `development` включено. UI: блок «Назначение сотрудникам» + `GET /api/auth/dev/assignment-directory` при `assignmentUi` из `/api/auth/me`. |
| V.9 | Частично | + `v9card1.test.js`: 0 попыток → in-place; после попытки → форк, старая попытка на старых `version_id` / `question_id`. Запуск: `CLINIC_TESTS_INTEGRATION=1` + `DATABASE_URL` (или DB_*), `npm run migrate`. |
| D.1 | Готово | `POST /tests/import/document`, 400/413, multipart. |
| D.2 | Готово | `documentExtractService.js`: PDF (`pdf-parse`), DOCX (`mammoth`), TXT/MD. |
| D.3 | MVP (импорт) | `documentGenService.js`: вызов Chat Completions (DeepSeek по умолчанию или OpenAI), JSON-черновик → `generation.draft` в `POST /import/document`; `LLM_NO_JSON=1` при несовместимости API. |
| D.4 | MVP | Карточка теста: выбор файла, превью, «Вставить в поле вопроса» → тот же черновик. |
| D.5 | Готово | Временный файл удаляется после чтения; Nginx `client_max_body_size 10m`. |
| A.1A.4 | Код + compose | `HR_AUTH` + `HR_DATABASE_URL` в `docker-compose.dev.yml`. |
| A.5 | MVP | `mapHrRoleToApp` без полного RBAC из HR-таблиц. |
| UI | 2026-04+ | Список тестов: подпись **автора** (Вы / Фамилия И. О.); шапка: **Фамилия И. О.** Разбор попыток: API + UI после `submit` и маршрут `/tests/:id/attempts/:aid/review` ([PROJECT_STATUS.md](../PROJECT_STATUS.md)). |
**Следующий шаг по card1:** **V.9** — довести supertest/HTTP-регресс при необходимости; **D.3+** — отдельные кнопки в редакторе (сгенерировать/проверить/улучшить), ключ в БД, `/settings` (см. [sprint-02](sprint-02.md)); **A.5**`staff_role_assignments` / HR API; по желанию назначения по **отделу**. Навигация по сценариям: [DEV_CONTOUR_USER_GUIDE.md](../DEV_CONTOUR_USER_GUIDE.md).
+68
View File
@@ -0,0 +1,68 @@
# Спринт 1 — тестирование (версионирование)
**Актуальный ведённый журнал (авто + ручные шаги, ответы ОК/не ОК):** [TESTING_JOURNAL.md](TESTING_JOURNAL.md) — *этот файл остаётся как подробный черновик сценариев*.
**Сводка «что в коде»:** [../PROJECT_STATUS.md](../PROJECT_STATUS.md).
Ниже: **автоматизировано / проверено при разработке** и **ручная приёмка** — дублирует структуру; статусы переносите в `TESTING_JOURNAL.md`.
**Окружение:** зафиксировать ветку/коммит, URL стенда, тестовые учётки (роль автора, роль сотрудника).
---
## 1. Автоматизировано и self-check (разработка)
_Отмечайте [ ] → [x] по мере выполнения._
### 1.1 Автотесты и статический анализ
- [ ] Unit-тесты правила «0 попыток — правка на месте без новой версии»
- [ ] Unit-тесты: после появления попытки — сохранение создаёт новую версию, старая неактивна
- [ ] Тест: попытка хранит `version_id` совпадающий с версией, по которой проходили
- [ ] Integration/API: нельзя «потерять» цепочку при смене активной версии
- [ ] Линтеры/CI зелёные на MR спринта 1
### 1.2 Смоук вручную (быстрый) перед передачей
- [ ] Создать тест, несколько раз сохранить **до** назначения/прохождения — версия одна
- [ ] Назначить, пройти тест, снова изменить тест — новая версия, старая в истории
- [ ] Список тестов у сотрудника без дублей цепочки
- [ ] Смена активной версии: новые прохождения идут по новой активной; старая попытка в разборе по старой версии
---
## 2. Ручная приёмка (я / заказчик)
_Сценарии для прогона в «боевом» темпе, без доступа к коду._
### 2.1 Автор: жизненный цикл без попыток
- [ ] Создать тест, изменить вопросы/порог — всё в одной версии, номер версии ожидаемо стабилен
- [ ] Убедиться, что в UI явно видно, что тест ещё «ни разу не проходили» (если предусмотрено ТЗ/UX)
### 2.2 Сотрудник + первая попытка
- [ ] Назначить тест, пройти его полностью
- [ ] Как автор изменить вопрос/варианты, сохранить — появляется **новая** версия; старая доступна в истории
### 2.3 Корректность данных
- [ ] Открыть разбор/результат **старой** попытки: формулировки вопросов и правильные ответы соответствуют **той** версии, с которой проходили
- [ ] Новое назначение/новое прохождение — по **актуальной активной** версии
### 2.4 Управление версиями
- [ ] История версий отображается полностью и понятно (номера, даты при наличии)
- [ ] Переключение **активной** версии на предыдущую: списки обновляются; новая попытка идёт по выбранной версии
- [ ] **Деактивация** теста: цепочка не светится сотруднику; данные на месте, старые результаты открываются (если доступ по ролям предусмотрен)
### 2.5 Визуальная согласованность
- [ ] Экраны редактора и списков **визуально** согласованы с остальным internal web (отступы, шрифты, кнопки, таблицы, ошибки) — **без отклонения** от принятого дизайна
### 2.6 Негатив
- [ ] Попытка не может «сломать» цепочку (ошибки пользователю понятны)
---
+60
View File
@@ -0,0 +1,60 @@
# Спринт 1 — Редактор тестов: версионирование (desktop web)
**Формат:** отдельное веб desktop-приложение (согласно ТЗ `task.md`, этап 1; приёмка руководителями подразделений).
**Граница спринта:** **до** готовой цепочки **версий** теста, **привязки попыток** к версии, **UI** списков/истории/смены активной версии и **деактивации** цепочки.
**Детальная нарезка и правила приёмки:** [card1.md](card1.md) (части **V.x**, **A.x** по мере готовности).
**Дизайн:** не отходить от визуального языка внутренних веб-экранов клиники (типографика, отступы, таблицы, модалки).
**Стек (факт по репозиторию [TestingWebApp](../../README.md)):** **Node.js** (API в `backend/`), **PostgreSQL**, **Docker**; фронтенд **desktop-first** SPA. Интеграция с [HR_TG_Bot](../../../HR_TG_Bot/README.md) / экосистемой — по готовности API-контрактов, **не** обязана совпадать с FastAPI, если в этом репо бэкенд на Node. При переносе на Python — пересмотреть только слой API, **смысл** [card1.md](card1.md) не меняется.
**Данные:** БД `clinic_tests` на общем кластере; сотрудник в сценариях — `staff_members.id`; `telegram_id` — только справка; RBAC — из HR. См. [card1 (вступление)](card1.md#хранение-связь-с-сотрудниками-rbac-зафиксировано).
**Текущая реализация (сводка):** [../PROJECT_STATUS.md](../PROJECT_STATUS.md).
---
## Цель спринта
Реализовать **§4.1 ТЗ** (версионирование) end-to-end: модель данных, бизнес-правила, API, UI автора, поведение для сотрудника и разбора **старых** результатов.
---
## Функциональные критерии готовности (из ТЗ 4.1)
1. Пока по тесту **не было попыток** — автор редактирует **на месте**, номер версии **не** инкрементируется.
2. После **первой** попытки каждое сохранение изменений создаёт **новую** версию (`version + 1`, связь `parent_id`), предыдущая версия **неактивна**, данные **сохраняются**.
3. Попытки привязаны к **конкретной** версии; разбор старых попыток **валиден** после правок.
4. В списках — **только одна** активная версия цепочки.
5. **История версий** + **ручная** смена активной версии; остальные — неактивны.
6. Тест можно **деактивировать** целиком (скрыть из списков, **без** удаления данных).
---
## Технические подзадачи (этапы внутри спринта)
| # | Задача | См. в card1 |
| --- | --- | --- |
| 1 | Миграция: `parent_id`, инвариант «одна активная версия на `test_id`», флаг цепочки (деактивация) | V.1, V.6 |
| 2 | Сервис: «0 попыток — in-place; иначе — fork + копия вопросов» | V.2, V.3 |
| 3 | Попытка: `test_version_id` фиксируется **на старте**; не перезаписывается | V.4 |
| 4 | API: CRUD с версиями; переключение активной; деактивация; списки без дублей | V.5, V.6, V.10 |
| 5 | UI: версия, история, смена активной, списки | V.7, V.8 |
| 6 | Тесты: unit + integration, регресс разбора | V.9 |
**Авторизация на БД [Postgres_TG_Bots](../../../Postgres_TG_Bots):** [card1.md](card1.md) **A.1****A.5** — **в рамке спринта 1**, если согласовано (иначе — отдельной задачей сразу после V.1).
**Загрузка документа → тест:** [card1.md](card1.md) **D.1****D.5** — **вне** замыкания **только** на версии; может пойти **после** V.3 или параллельно, зависит от **LLM** (§4.2).
---
## Вне спринта 1 (как в ТЗ)
- Полный **AI-**модуль (§4.2) сверх **импорта** из карточки, **медиа** (§4.3), **подсказки** (§4.4), **режимы** (§4.5), **дашборды** (этап 2).
---
## Документы тестирования
- **Единый журнал** (автопроверки + **ваши** шаги с ответом ОК/не ОК): [TESTING_JOURNAL.md](TESTING_JOURNAL.md)
- Черновик чек-листа: [sprint-01-testing.md](sprint-01-testing.md)
+73
View File
@@ -0,0 +1,73 @@
# Спринт 2 — тестирование (AI-помощники)
**Состояние продукта:** [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · чек-лист импорта и API — ниже.
**Предпосылка:** спринт 1 (версии) принят.
**Секреты:** для стенда допускается отдельный ключ DeepSeek; в логах/скриншотах **не** светить полный ключ.
**MVP 2026-04 (импорт + LLM, до полного спринта 2):** в `backend` задать `DEEPSEEK_API_KEY` *или* `OPENAI_API_KEY``POST /api/tests/import/document` при загрузке файла возвращает `generation.draft` → в карточке теста, блок «Импорт из файла», кнопка **«Применить сгенерированный черновик»** → затем **«Сохранить черновик»**. Юнит-тесты: `documentGenService.test.js`.
---
## 1. Автоматизировано и self-check (разработка)
_Отмечайте [ ] → [x] по мере выполнения._
### 1.1 Безопасность и API
- [ ] Эндпоинты настроек не возвращают сырой ключ на клиент
- [ ] Ошибка при отсутствии/невалидном ключе — структурированная, с кодом/текстом для UI
- [ ] «Проверить подключение» при валидном ключе — успех; при фейле — сообщение об ошибке сети/401 и т.д.
### 1.2 Моки / контракты (по возможности)
- [ ] Парсинг JSON-ответов LLM: при невалидном JSON — пользователю сообщение, не 500 без текста
- [ ] Unit-тесты маппинга «ответ LLM → черновик теста/вопроса» (на фикстурах)
### 1.3 Смоук перед передачей
- [ ] Сгенерировать тест: только с названием; превью; применить — вопросы в редакторе
- [ ] Проверить тест: модалка с текстом
- [ ] Улучшить вопрос: чекбоксы, применение частично
- [ ] Дистракторы: к существующим ответам добавилось 3 варианта
- [ ] После применения AI-изменений сохранение теста согласовано с правилами **версий** (если были попытки)
---
## 2. Ручная приёмка (я / заказчик)
### 2.1 Настройки
- [ ] Сохранить ключ, обновить страницу — приложение не показывает ключ, но AI-функции работают
- [ ] Очистить/испортить ключ — AI показывает ошибку и **есть** переход/ссылка на настройки
- [ ] «Проверить подключение» отражает реальное состояние (успех/ошибка)
### 2.2 Уровень теста
- [ ] **Сгенерировать тест** недоступен при пустом названии; при заполненном — выдаёт осмысленный черновик, применяется **целиком** по кнопке
- [ ] **Проверить тест** — рекомендации читаемы, модалка закрывается, данные теста не портит без явного применения
- [ ] **Предложить улучшение** — сравнение было/стало, выбор чекбоксами, применяется только отмеченное
### 2.3 Уровень вопроса
- [ ] **Улучшить вопрос** — нет молчаливой перезаписи; подтверждение через чекбоксы/применить
- [ ] **Дистракторы** — три новых **не** заменяют старые ответы
- [ ] **Сгенерировать подсказку** — текст появляется в поле, можно отредактировать и сохранить
### 2.4 Версионирование + AI (регресс)
- [ ] На тесте **без** попыток: массовое применение AI не создаёт лишних версий бессмысленно (ожидание как в спринте 1)
- [ ] На тесте **с** попытками: осмысленные сохранения ведут себя по правилам 4.1 (новая версия при изменении)
### 2.5 Дизайн
- [ ] Кнопки, модалки, превью, состояния загрузки/ошибок **визуально** в одном ряду с остальным модулем (как спринт 1)
### 2.6 Качество UX
- [ ] Долгий ответ LLM: индикатор ожидания, нельзя «задвоить» запросы без контроля
- [ ] Понятные сообщения при сбое сети или API DeepSeek
---
**Итог приёмки спринта 2:** дата __________, комментарий _________________________
+65
View File
@@ -0,0 +1,65 @@
# Спринт 2 — Редактор тестов: AI-помощники (desktop web)
**Формат:** отдельное веб desktop-приложение (этап 1, фича §4.2).
**Граница спринта:** начинается **после** приёмки спринта 1; заканчивается готовностью **всех** функций AI-помощника из ТЗ (настройки ключа, проверка подключения, сценарии уровня теста и уровня вопроса) на базе **DeepSeek** и сохранения **идентичности дизайна** с остальным приложением.
**Предпосылка:** версионирование (спринт 1) работает; сгенерированные/изменённые черновики сохраняются в модель **текущей редактируемой версии** согласно правилам 4.1.
**Стек (целевой в ТЗ):** Python, FastAPI — **в репозитории TestingWebApp фактически Node.js + Express**, LLM через HTTP (OpenAI-совместимый API, в т.ч. DeepSeek); ключ в окружении или (в целевом виде спринта) в БД, не на клиенте. Сводка MVP: [../PROJECT_STATUS.md](../PROJECT_STATUS.md).
---
## Цель спринта
Реализовать **§4.2 ТЗ**: интеграция DeepSeek, страница настроек, все табличные функции уровня теста и вопроса, обработка отсутствия ключа, UI с превью и подтверждением (без «тихой» перезаписи там, где ТЗ требует сравнения с чекбоксами).
---
## Функции (контрольный список из ТЗ)
### Интеграция и настройки
- Ключ DeepSeek на `/settings`, хранение в БД, не отдаётся на фронт
- «Проверить подключение» — тестовый запрос
- Все AI-действия при отсутствии ключа — понятная ошибка + ссылка на настройки
### Уровень теста
| Функция | Критерий |
| --- | --- |
| Сгенерировать тест | Только при заполненном названии; превью; применение целиком |
| Проверить тест | Модалка с рекомендациями |
| Предложить улучшение всего теста | Постатейно было → стало, чекбоксы, применение выбранного |
### Уровень вопроса
| Функция | Критерий |
| --- | --- |
| Улучшить вопрос | Модалка, было/стало по частям, чекбоксы, **без** прямой замены без подтверждения |
| Дистракторы | +3 неправдоподобных варианта **добавляются**, не заменяют |
| Сгенерировать подсказку | Текст в поле подсказки; автор правит/удаляет (связь с §4.4 в следующих спринтах) |
---
## Технические подзадачи
| # | Задача |
| --- | --- |
| 1 | Модель настроек (ключ), API save/test, маскирование в ответах |
| 2 | Промпты и контракты JSON для каждой функции; валидация ответа LLM |
| 3 | Эндпоинты/сервисы: 6 сценариев + единая обёртка ошибок/квот |
| 4 | UI: кнопки в редакторе, модалки, превью «сгенерировать тест», дифы с чекбоксами |
| 5 | Соблюдение правил 4.1 при сохранении применённых AI-изменений |
| 6 | Логи без утечки ключа; rate-limit/таймауты по best effort |
---
## Вне спринта 2
- Медиа (§4.3), полноценное поведение подсказок в прохождении (§4.4 + §4.5) — отдельные спринты, если не входят в минимум для кнопки «подсказка» в редакторе
- Дашборды (этап 2 ТЗ), HR-интеграция, MAX
---
## Документ тестирования
Чек-лист: `sprint-02-testing.md`.
+264
View File
@@ -0,0 +1,264 @@
# Техническое задание на доработку
**Система тестирования сотрудников клиники**
| Поле | Значение |
| --- | --- |
| Версия | 1.0 |
| Дата | 2026-04-23 |
| Статус | Черновик |
| Адресат | Константин Л. (разработчик) |
| Базовый репозиторий | https://git.pirogov.ai/l_konstantin/TestingWebApp |
**Состояние репозитория (не ТЗ, а факт):** ветка `dev` — [PROJECT_STATUS.md](../PROJECT_STATUS.md); как пользоваться локальным стендом — [DEV_CONTOUR_USER_GUIDE.md](../DEV_CONTOUR_USER_GUIDE.md).
## 1. Контекст и зачем это делается
### Зачем клинике система тестирования
Система нужна, чтобы сделать проверку знаний сотрудников управляемым процессом, а не бумажной рутиной. Сегодня проверка знаний по регламентам, стандартам работы с пациентами, новым протоколам и внутренним правилам проходит устно или на бумаге — это тяжело воспроизводить, невозможно сравнивать результаты между людьми и отделами, а руководитель не видит целостной картины по своему подразделению.
### Что система даёт каждой роли
- **Руководителю подразделения** — единое место, где видно, кто из сотрудников прошёл обязательные тесты, кто не справился, у кого приближается дедлайн. Вместо рассылки регламентов «в чат и на почту» — проверяемая практика: назначил тест → увидел результат → понял, где у отдела пробелы. AI-помощник снимает главный барьер: придумать и сформулировать хороший тест — это отдельная работа, которой у руководителя обычно нет времени. С помощником создание теста занимает минуты, а не часы.
- **Сотруднику** — понятный личный кабинет: какие тесты назначены, когда сдать, какой результат был в прошлый раз, какие ошибки стоит разобрать. Разбор ошибок после теста превращает проверку в короткий обучающий цикл. Если автор составит задорный тест — с живыми формулировками, неочевидными вариантами ответов, лёгким юмором, — прохождение перестаёт восприниматься как рутинная формальность и приобретает элементы фана и геймификации. Это снижает сопротивление и делает регулярные тесты нормальной частью рабочего дня.
- **Директору и его помощнику** — объективная картина по всей клинике: какие подразделения сильнее, какие отстают, где нужно вмешательство. Это инструмент управленческих решений по обучению и кадрам, а не просто отчётность.
- **HR** — структурированная история знаний каждого сотрудника, которая в будущем ляжет в общий HR-контур (онбординг, индивидуальные планы развития, кадровые решения).
### Стратегическая роль: точка входа в HR-приложение в MAX
У модуля тестирования есть ещё одна важная роль — он становится одной из первых регулярно используемых частей общего HR-приложения в боте MAX. Тесты назначаются регулярно и требуют обязательного прохождения, а значит, сотрудник будет заходить в HR-приложение не время от времени, а стабильно. Это формирует привычку: открыть HR в MAX — привычное действие. Следом подтянутся и другие HR-сервисы (справочная информация, заявки, графики, отпуска, обратная связь), которые без точки притяжения могли бы оставаться «пустым модулем». Таким образом, система тестирования популяризует само HR-приложение внутри MAX и помогает ему стать ежедневным рабочим инструментом, а не редко открываемым разделом.
Отдельно важен формат взаимодействия. Внутри бота MAX система тестирования открывается как полноценное веб-приложение (мини-приложение) — с нормальной вёрсткой форм, навигацией, медиа в вопросах, удобными таблицами результатов. Это принципиально лучший пользовательский опыт, чем предыдущая реализация в Telegram, где интерфейс был ограничен форматом бота (сообщения, кнопки, линейные диалоги) и не давал ни удобного ввода, ни нормального отображения сложного контента. Перевод в веб-формат внутри MAX — это не просто смена канала, а качественный скачок в удобстве работы с тестами для всех ролей.
### Текущее состояние
Уже реализовано: авторизация по логину/паролю, создание тестов автором, назначение теста сотрудникам из списка, прохождение теста сотрудником в браузере.
Доработка расширяет эту реализацию новыми возможностями и готовит её к встраиванию в общую HR-систему клиники. В HR-системе уже есть собственная авторизация и собственная система разграничения прав (кто какие разделы и данные видит), поэтому на более поздних этапах собственная авторизация модуля тестирования будет отключена в пользу HR-авторизации. До этого момента текущая авторизация используется как есть.
## 2. Ключевые дополнительные возможности
Помимо базового функционала (создание тестов, назначение, прохождение), который уже реализован, в этой доработке добавляются пять возможностей, радикально расширяющих ценность системы. Они перечислены в порядке значимости для пользователя.
### 2.1. AI-помощники при создании тестов — ключевая возможность
Это главный выигрыш всей доработки. Создание теста с нуля — это отдельная работа: нужно придумать вопросы, сформулировать варианты ответов, подобрать неочевидные неправильные ответы (дистракторы), проверить качество формулировок. У руководителя подразделения этого времени обычно нет. Без AI-помощника сама задача «писать тесты регулярно» фактически невыполнима — она превращается в проект.
AI-помощник меняет это кардинально:
- **Сокращение времени в разы.** Вместо нескольких часов ручной работы — несколько минут на редактирование черновика, который сгенерировал AI.
- **Более удобный формат работы.** Автор не пишет с пустого листа, а работает в режиме редактора: получает готовое, правит, подтверждает. Отдельные кнопки позволяют улучшить конкретный вопрос, добавить дистракторы, сгенерировать подсказку, проверить весь тест на качество.
- **Выше качество формулировок.** AI предлагает варианты, о которых автор мог не подумать — правдоподобные дистракторы, более чёткие формулировки, корректную подсказку.
Без этой возможности регулярное создание тестов останется узким местом и система не заработает в полную силу. С ней — создание теста становится быстрой повседневной операцией, которую может делать любой руководитель.
### 2.2. Версионирование тестов
Руководитель может править тест после первых прохождений, не теряя корректность старых результатов. Старые попытки по-прежнему корректно разбираются по той версии теста, которая была на момент их прохождения.
### 2.3. Медиа в вопросах
К вопросу можно прикрепить изображение или видео: фото инструмента, рентгеновский снимок, ролик с правильной техникой манипуляции. Тесты перестают быть «только про текст» — это особенно важно в медицинской тематике.
### 2.4. Подсказки и режимы прохождения (таймер, мгновенная оценка)
Один и тот же тест можно проводить как строгую проверку (с таймером, без подсказок, итог в конце) или как мягкий обучающий тренажёр (с подсказками, без таймера, мгновенная обратная связь по каждому вопросу). Набор режимов — независимые настройки, автор комбинирует их под задачу.
### 2.5. Дашборды для всех ролей
Замена ручного сбора статистики на один экран с нужным срезом: сотрудник видит свои тесты и историю, руководитель — своё подразделение, директор — всю клинику с возможностью посмотреть любое подразделение и любого сотрудника.
### 2.6. Хранение в PostgreSQL, сотрудники и RBAC (зафиксировано для реализации)
- **Один кластер PostgreSQL** (как в экосистеме Postgres_TG_Bots / HR): отдельная база **`clinic_tests`** — тесты, версии, назначения, попытки и миграции модуля; база **`hr_bot_test`** — штат, справочники, веб-логины и **уже реализованный RBAC**. Схемы не объединять в одну БД без отдельного решения: в `hr_bot_test` заняты имена и смыслы таблиц (`users`, `departments` и др.) под HR.
- Во **всех бизнес-процессах** модуля тестирования сотрудник идентифицируется по **`staff_members.id`**. В `clinic_tests` хранятся **ссылки** на этот идентификатор; кадровые данные и структура подразделений — из HR, без дублирования «второго реестра людей».
- Поле **`telegram_id`** у сотрудника **не используется** в логике модуля (вход, назначения, фильтры, права) — только как **справочная** информация при необходимости.
- **Разграничение прав** в целевом виде — через **существующую** в клинике систему (роли, permissions, привязки к сотруднику); модуль **не** строит параллельную полную копию RBAC. Допустимы временные упрощения до согласования API с HR.
- **Назначения и новые версии (V.10 / card1):** запись `test_assignments` **не** перепривязываем автоматически к новой `test_version_id` при форке версии. **Старт попытки** (V.4) фиксирует **активную** версию на момент «Старт», а не версию из строки назначения. Авто-обновление `test_assignments` при смене активной версии **не** делаем.
Детализация: [card1.md](card1.md) (вступление), [README](../../README.md#данные-сотрудники-интеграция-с-hr).
## 3. Этапы
| № | Этап | Формат | Кто принимает |
| --- | --- | --- | --- |
| 1 | Доработка редактора тестов | Web desktop | Руководители подразделений |
| 2 | Дашборды (сотрудника / руководителя / директора) | Web desktop | Руководители подразделений + директор |
| 3 | Интеграция с HR-системой | Backend-интеграция + изменения авторизации | Совместно с командой HR |
| 4 | Адаптация под мини-приложение в боте MAX | Mini-app | Совместно с командой HR |
| 5 | Уведомления | В рамках общей системы HR | Совместно с командой HR |
Этапы 1 и 2 реализуются как отдельные desktop-приложения и принимаются независимо друг от друга. Этапы 3–5 выполняются позже, совместно с командой большой HR-системы — в этом ТЗ описаны верхнеуровнево.
## 4. Этап 1 — Доработка редактора тестов
Этап расширяет существующий редактор пятью возможностями. Все пять — независимые фичи, могут реализовываться в любом порядке и приниматься отдельно.
### 4.1. Версионирование тестов
**Цель:** сохранить корректность истории прохождений, когда автор правит тест после первых попыток.
**Правила:**
- Пока по тесту не было ни одной попытки — автор редактирует тест на месте, номер версии не меняется.
- Как только появилась хотя бы одна попытка — любое сохранение изменений создаёт новую версию теста (`version + 1`, связь со старой версией через `parent_id`). Старая версия становится неактивной, но сохраняется в базе.
- Все версии теста связаны в цепочку. Каждая попытка прохождения привязана к конкретной версии, по которой сотрудник проходил тест, — разбор ошибок по старым результатам остаётся корректным даже после того, как автор изменил тест.
- В списке тестов для сотрудников и авторов показывается только одна активная версия каждой цепочки.
- Автор может открыть историю версий теста и вручную переключить активную версию на любую другую из цепочки — остальные при этом автоматически становятся неактивными.
- Тест можно деактивировать целиком (скрыть всю цепочку из списка, данные не удаляются).
### 4.2. AI-помощник при создании и редактировании тестов
**Цель:** ускорить и повысить качество создания тестов силами LLM.
**Интеграция:**
- Используется DeepSeek API (совместим с форматом OpenAI — подключается через библиотеку `openai` с `base_url=https://api.deepseek.com`, модель `deepseek-chat`).
- Для структурированных ответов использовать `response_format={"type": "json_object"}`.
- API-ключ DeepSeek вводится на отдельной странице настроек (`/settings`) и хранится в БД. На фронтенд ключ не передаётся.
- На странице настроек — кнопка «Проверить подключение», которая выполняет тестовый запрос к API.
- Все AI-функции требуют настроенного ключа; при его отсутствии возвращается понятная ошибка со ссылкой на «Настройки».
**Функции уровня всего теста:**
| Функция | Описание |
| --- | --- |
| Сгенерировать тест | По названию теста AI генерирует набор вопросов с вариантами ответов. Кнопка доступна только когда название заполнено. Результат показывается в превью, автор применяет его целиком. |
| Проверить тест | AI анализирует весь тест и выдаёт рекомендации по улучшению (чёткость формулировок, качество дистракторов, охват темы). Показывается в модальном окне. |
| Предложить улучшение всего теста | AI предлагает улучшенные формулировки всех вопросов и ответов. Результат отображается как постатейное сравнение (было → стало) с чекбоксами — автор выбирает, какие изменения применить. |
**Функции уровня одного вопроса:**
| Функция | Описание |
| --- | --- |
| Улучшить вопрос | AI переформулирует выбранный вопрос и его варианты ответов. Результат показывается в модальном окне с постатейным сравнением и чекбоксами (вопрос + каждый вариант отдельно). Прямая замена без подтверждения не допускается. |
| Дистракторы | AI генерирует 3 новых правдоподобных неправильных варианта ответа. Они добавляются к существующим, а не заменяют их. |
| Сгенерировать подсказку | AI пишет подсказку к вопросу (см. раздел 4.4). Автор может отредактировать или переписать полученный текст. |
### 4.3. Медиа в вопросах
**Цель:** к вопросу можно прикрепить изображение или видео, которое сотрудник увидит при прохождении.
**Требования:**
- К одному вопросу может быть прикреплён один медиа-файл (изображение или видео).
- **Поддерживаемые форматы:** изображения: JPG, PNG, WebP; видео: MP4, WebM.
- **Ограничения по размеру:** изображение — до 5 МБ; видео — до 50 МБ.
- Файлы хранятся локально на сервере (например, в папке `uploads/`). Внешние хранилища (S3/MinIO) не используются.
- В редакторе вопроса — отдельное поле «Медиа» с загрузкой файла и превью, кнопкой удаления.
- При прохождении теста медиа отображается над текстом вопроса. Видео — с нативным плеером браузера.
- Имена файлов на диске должны быть непредсказуемыми (например, UUID), чтобы исключить угадывание ссылок.
### 4.4. Подсказки к вопросам
**Цель:** при прохождении теста в режиме с подсказками сотрудник может запросить подсказку к вопросу.
**Требования:**
- Каждый вопрос имеет одно необязательное текстовое поле «Подсказка».
- Заполнение подсказки — на усмотрение автора. Три способа:
1. Автор пишет подсказку вручную.
2. Автор нажимает «Сгенерировать подсказку» — AI генерирует текст подсказки; автор может сохранить как есть, отредактировать или удалить.
3. Оставить поле пустым — подсказки по этому вопросу не будет даже в режиме с подсказками.
- Подсказка показывается сотруднику только если тест запущен в режиме с подсказками (см. 4.5) и автор её заполнил.
### 4.5. Режимы прохождения теста
**Цель:** автор при создании теста выбирает, как именно сотрудник будет его проходить.
Три независимых настройки теста (устанавливаются при создании, сохраняются в версии теста):
| Настройка | Варианты | Поведение при прохождении |
| --- | --- | --- |
| Подсказки | Включены / выключены | Если включены и у вопроса заполнена подсказка — сотруднику доступна кнопка «Показать подсказку» под вопросом. Факт использования подсказки фиксируется в попытке (в будущем может влиять на балл). |
| Таймер | Выключен / N минут | Если задан — отображается обратный отсчёт. По истечении — тест автоматически завершается, попытка считается сданной с тем, что ответил сотрудник. |
| Мгновенная оценка | Включена / выключена | Если включена — после ответа на каждый вопрос сразу показывается правильный ответ и комментарий (разбор по этому вопросу), затем переход к следующему. Если выключена — разбор и итог только после завершения всего теста. |
Настройки отображаются на странице прохождения так, чтобы сотрудник заранее понимал условия (есть ли таймер, будет ли сразу виден правильный ответ и т. д.).
## 5. Этап 2 — Дашборды
**Цель:** предоставить каждой роли индивидуальный экран с релевантной для неё информацией. Этап реализуется отдельным desktop-приложением (или отдельным разделом того же приложения — на усмотрение разработчика).
### 5.1. Дашборд сотрудника
Что видит сотрудник на своей главной странице:
- **Назначенные тесты** — таблица или карточки со статусом (Не начат, В процессе, Завершён, Просрочен) и датой дедлайна.
- **График дедлайнов** — визуализация (таймлайн или календарь) по ближайшим срокам сдачи.
- **История попыток** — все попытки сотрудника: тест, версия, дата начала/завершения, результат, зачёт/незачёт.
- Из строки истории — переход на разбор ошибок конкретной попытки.
### 5.2. Дашборд руководителя подразделения
Что видит руководитель подразделения — только по своему подразделению:
- **Сводка по сотрудникам:** список сотрудников с колонками — назначено тестов / сдано / просрочено / средний балл. По клику на сотрудника — его история попыток и назначенных тестов.
- **Сводка по назначенным тестам:** по каждому тесту, назначенному подразделению — процент сдавших, список сдавших и несдавших.
- **Фильтры:** по диапазону дат, по конкретному тесту.
### 5.3. Дашборд директора и помощника директора
Что видят директор и его помощник — по всей клинике:
- **Общая сводка:** число активных тестов, число сотрудников, общий процент сдачи, средний балл.
- **Сравнение подразделений:** таблица подразделений с колонками — число сотрудников, процент сдачи, средний балл. Сортировка по любой колонке. По клику на подразделение открывается вид как у руководителя этого подразделения (см. 5.2).
- По клику на сотрудника (из любого уровня) — его история попыток.
## 6. Этап 3 — Интеграция с HR-системой
**Цель:** модуль тестирования становится частью большой HR-системы клиники.
**Ключевые изменения:**
- Собственная авторизация модуля тестирования отключается. Вход выполняется через HR (SSO, JWT или другой механизм, который будет определён командой HR).
- Пользователи, подразделения и роли приходят из HR — не хранятся в локальной БД модуля тестирования (или хранятся как кэш, синхронизируемый с HR). **Идентичность сотрудника** в данных и при интеграции — по **`staff_members.id`** (см. §2.6); идентификаторы **Telegram** в этих цепочках **не** используются.
- Разграничение прав доступа (кто что видит и что может делать) выполняется по **существующей** HR-модели RBAC (роли, permissions, привязка к `staff_members.id`). Соответствие ролей HR-системы и возможностей модуля тестирования определяется отдельно в начале этапа.
- Назначение тестов остаётся внутри модуля тестирования (а не в HR). Это отдельный пользовательский сценарий, который удобнее оставить рядом с редактором и трекером.
- Дашборды используют ФИО, подразделения и иерархию из HR.
Детальные контракты (API HR, формат токена, справочники) будут описаны отдельным документом совместно с командой HR перед стартом этапа.
## 7. Этап 4 — Мини-приложение для бота MAX
**Цель:** сотрудник может проходить назначенные тесты прямо из бота MAX, без перехода в браузер.
**Верхнеуровневые требования:**
- Desktop-интерфейс сотрудника адаптируется под размер мини-приложения MAX (адаптивная вёрстка, упрощённая навигация, без многоуровневого меню).
- Внутри мини-приложения доступны: список назначенных тестов, прохождение теста, результат и разбор ошибок.
- Функции авторов тестов и руководителей в mini-app не выносятся — для них остаётся полноценный desktop-интерфейс.
- Авторизация в mini-app — через MAX → HR (конкретная схема определяется на старте этапа).
## 8. Этап 5 — Уведомления
Реализуются в рамках общей системы уведомлений большой HR-системы, а не как отдельный модуль системы тестирования.
**События**, которые должна знать система тестирования и передавать в общую систему уведомлений:
- Сотруднику назначен новый тест.
- Приближается дедлайн сдачи теста (за N дней, N — настраивается).
- Дедлайн теста просрочен без сдачи.
Канал (MAX / e-mail / другое) и формат сообщений определяются общей системой HR.
## 9. Вне scope
В рамках этой доработки не реализуются:
- Экспорт отчётов в Excel / PDF.
- Собственная система уведомлений внутри модуля тестирования — уведомления будут реализованы в общей HR-системе.
## 10. Порядок приёмки
Общий принцип: каждый этап принимается отдельно. Следующий этап не начинается, пока предыдущий не принят.
1. **Этап 1** — по мере готовности каждой из пяти функций (4.1–4.5) руководители подразделений вручную проходят по ней сценарии использования, заводят замечания, разработчик их исправляет. Этап принят, когда все пять функций прошли приёмку.
2. **Этап 2** — дашборды тестируются по ролям: сотрудник → руководитель подразделения → директор. Проверяется, что каждая роль видит только разрешённые данные и что переходы между уровнями (клиника → подразделение → сотрудник) работают корректно.
3. **Этапы 35** — приёмка проводится совместно с командой большой HR-системы, критерии и сценарии определяются в начале каждого этапа.
Подробные чек-листы тестирования для каждой функции готовятся перед стартом соответствующего этапа и ведутся в отдельных документах в папке DOC/.
@@ -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 вариантов» — устраивают или нужны другие?
@@ -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)
---
**В одном движении:** написали **вопросы****«Сохранить»** → при **необходимости** **показали** в списке и/или **кому-то** **выдали** тест. Остальное — **по ситуации**.
@@ -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)
+2
View File
@@ -5,6 +5,8 @@
**Дата:** 2026-03-21 **Дата:** 2026-03-21
**Статус:** Согласовано **Статус:** Согласовано
*Реализация в ветке `dev` (не дословно ТЗ):* [PROJECT_STATUS.md](PROJECT_STATUS.md) · [инструкция dev-стенда](DEV_CONTOUR_USER_GUIDE.md).
--- ---
## 1. Назначение системы ## 1. Назначение системы
+7 -2
View File
@@ -26,8 +26,8 @@
### 1.3. Настройка окружения ### 1.3. Настройка окружения
- Docker Compose для PostgreSQL - PostgreSQL: **по умолчанию** общий кластер [Postgres_TG_Bots](../../../Postgres_TG_Bots) / [HR_TG_Bot](../../../HR_TG_Bot) — `DATABASE_URL` в `backend/.env``localhost:5432` / БД `clinic_tests` (см. [README](../../README.md#установка-и-запуск)). Локальный отдельный инстанс только по необходимости: `docker compose --profile standalone up` (порт 5433). Сотрудник в интеграции — `staff_members.id`; `telegram_id` в логике модуля не используем.
- Переменные окружения (.env) - Переменные окружения (`.env` по образцу `backend/.env.example`)
- Настройка линтеров и форматтеров - Настройка линтеров и форматтеров
### 1.4. Базовая структура API ### 1.4. Базовая структура API
@@ -42,3 +42,8 @@
- Работающий сервер с подключением к БД - Работающий сервер с подключением к БД
- Структура проекта готова для разработки - Структура проекта готова для разработки
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+5
View File
@@ -137,3 +137,8 @@
- Созданы все таблицы с связями - Созданы все таблицы с связями
- Накатанные миграции - Накатанные миграции
- Схема БД готова - Схема БД готова
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+5
View File
@@ -54,3 +54,8 @@ function requireDepartment(departmentId: string) {
- Работающий вход по логину/паролю - Работающий вход по логину/паролю
- Защищённые API роуты - Защищённые API роуты
- Разграничение прав по ролям - Разграничение прав по ролям
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+5
View File
@@ -47,3 +47,8 @@
- Админка для управления сотрудниками - Админка для управления сотрудниками
- Справочник подразделений - Справочник подразделений
- Назначение ролей сотрудникам - Назначение ролей сотрудникам
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+11
View File
@@ -47,8 +47,19 @@
--- ---
*Фактические маршруты 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).
## Результат ## Результат
- Полноценный конструктор тестов - Полноценный конструктор тестов
- Версионирование с сохранением истории - Версионирование с сохранением истории
- Управление вопросами и ответами - Управление вопросами и ответами
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+5
View File
@@ -48,3 +48,8 @@
- Назначение тестов подразделениям или сотрудникам - Назначение тестов подразделениям или сотрудникам
- Ограничение по дедлайну и попыткам - Ограничение по дедлайну и попыткам
- Список назначений для сотрудника - Список назначений для сотрудника
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+5
View File
@@ -70,3 +70,8 @@
- Таймер с автозавершением - Таймер с автозавершением
- Сохранение прогресса - Сохранение прогресса
- Навигация по вопросам - Навигация по вопросам
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+6
View File
@@ -4,6 +4,8 @@
Реализовать отображение результатов теста и разбора ответов сотруднику. Реализовать отображение результатов теста и разбора ответов сотруднику.
**Реализация в dev:** ответ `POST …/attempts/:id/submit` включает поле `review`; отдельно `GET /api/tests/:testId/attempts/:attemptId/review` (см. [../PROJECT_STATUS.md](../PROJECT_STATUS.md)).
--- ---
## Задачи ## Задачи
@@ -76,3 +78,7 @@
- Итоговый балл и процент - Итоговый балл и процент
- Статус зачёта - Статус зачёта
- Полный разбор по каждому вопросу - Полный разбор по каждому вопросу
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+5
View File
@@ -51,3 +51,8 @@
- Таблица попыток с фильтрами - Таблица попыток с фильтрами
- Ограничение данных по роли - Ограничение данных по роли
- Полная история прохождений - Полная история прохождений
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+5
View File
@@ -87,3 +87,8 @@
- AI-генерация вопросов - AI-генерация вопросов
- Улучшение формулировок - Улучшение формулировок
- Рекомендации по качеству - Рекомендации по качеству
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+5
View File
@@ -61,3 +61,8 @@
- Страница `/settings` - Страница `/settings`
- Ввод и сохранение API ключа - Ввод и сохранение API ключа
- Проверка подключения к DeepSeek - Проверка подключения к DeepSeek
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*
+9
View File
@@ -0,0 +1,9 @@
# Пошаговая спецификация (`docs/шаги/`)
Файлы **01****11** — **проектные шаги** (целевое поведение и API), а не автоматическая копия кода. Фактическое состояние фич, сценарии «как у пользователя» и ветка **`dev`** описаны в:
- [../PROJECT_STATUS.md](../PROJECT_STATUS.md) — что сделано и что в планах;
- [../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).
+6
View File
@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
.env
.git
*.md
+25
View File
@@ -0,0 +1,25 @@
# ─── HTTP сервер ─────────────────────────────────────────────────
# Порт (не пересекать с :3107 текущего docker-compose.dev.yml)
PORT=3108
FLASK_DEBUG=1
# В Docker задайте WEB_USE_WAITRESS=1 (см. docker-compose.dev.yml)
# 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
+21
View File
@@ -0,0 +1,21 @@
# Новый Flask-контур (отдельный от Node/React). Dev: см. docker-compose.dev.yml
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PORT=3108 \
WEB_USE_WAITRESS=1 \
FLASK_DEBUG=0
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY run.py .
COPY app ./app
EXPOSE 3108
CMD ["python", "run.py"]
+61
View File
@@ -0,0 +1,61 @@
# Flask-контур тестирования (тот же стек, отдельный деплой)
Здесь — **новое** приложение на **Python / Flask** в духе `HR_TG_Bot/tgFlaskForm` (шаблоны + серверный рендер, без React). Старый стек (`backend/` + `frontend/`) пока не удаляется: оба контура могут существовать параллельно, пока не зафиксирована политика «один источник записи» и cutover.
## Запуск в Docker (рекомендуется)
Из **корня** репозитория TestingWebApp. Сервис **не** зависит от `testing-backend` и **не** требует внешней сети Postgres для старта (только внутренняя сеть compose).
```bash
docker compose -f docker-compose.dev.yml up -d --build testing-flask
```
Когда подключите БД из контейнера к `hr_postgres_dev` / `clinic_tests`, в `docker-compose.dev.yml` у сервиса `testing-flask` добавьте сеть `postgres` (как у `testing-backend`).
- **URL:** http://localhost:3108
- **Проверка:** http://localhost:3108/health
Вместе со старым UI и API:
```bash
docker compose -f docker-compose.dev.yml up -d --build
```
Тогда Node-интерфейс остаётся на **http://localhost:3107**, Flask — на **3108**.
## Запуск локально (без Docker)
```bash
cd flask_app
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # при необходимости поправьте PORT
python run.py
```
По умолчанию: **http://127.0.0.1:3108** , проверка: **http://127.0.0.1:3108/health**
Для режима как в Docker (waitress): в `.env` задайте `WEB_USE_WAITRESS=1`.
## Зачем отдельная папка
- Один **стек** с кабинетом / мини-приложением — проще переносить экраны и запросы из `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`.
- **Отдельный** процесс и порт — без риска сломать текущий `docker-compose.dev.yml` с Node до готовности.
## Дальнейшие шаги (код)
Этот каталог — место разработки **Этапа 1** ([migration-final.md](../docs/migration-final.md)).
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-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)
+75
View File
@@ -0,0 +1,75 @@
"""Фабрика Flask-приложения.
Этап 1:
E1.0 фундамент (БД-пул, sessions, base.html).
E1.1 авторизация (cookie-сессии Flask, bcrypt + Werkzeug, опц. HR_AUTH).
"""
from __future__ import annotations
import os
import secrets
from datetime import timedelta
from flask import Flask, jsonify, render_template, request
def create_app() -> Flask:
app = Flask(
__name__,
instance_relative_config=True,
template_folder='templates',
static_folder='static',
static_url_path='/static',
)
sk = (os.environ.get('SECRET_KEY') or '').strip()
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),
)
from .blueprints.main import main_bp
from .blueprints.settings import settings_bp
from .auth import auth_bp
from .tests import tests_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(tests_bp)
app.register_blueprint(settings_bp)
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
def _is_api_path() -> bool:
return request.path.startswith('/api/')
+5
View File
@@ -0,0 +1,5 @@
"""Auth: логин/логаут/me — пара UI-страниц и JSON-API.
См. routes.py, services.py, decorators.py.
"""
from .routes import auth_bp # noqa: F401
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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'],
)
+31
View File
@@ -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
View File
@@ -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())

Some files were not shown because too many files have changed in this diff Show More