Compare commits
51 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
1ea83aa6b4 | 2 weeks ago |
|
|
0229bc250b | 2 weeks ago |
|
|
1494b839f5 | 2 weeks ago |
|
|
9511fcb555 | 2 weeks ago |
|
|
b72b485fce | 2 weeks ago |
|
|
df6e770f90 | 2 weeks ago |
|
|
44366a2865 | 2 weeks ago |
|
|
2b429f0b08 | 2 weeks ago |
|
|
e6b85f3944 | 2 weeks ago |
|
|
db9851eeda | 2 weeks ago |
|
|
ebb58d4b5a | 2 weeks ago |
|
|
c4a7d2ef08 | 2 weeks ago |
|
|
09d996ead0 | 2 weeks ago |
|
|
fba11ff4cc | 2 weeks ago |
|
|
bba96f8f9f | 2 weeks ago |
|
|
eff3fda5b0 | 2 weeks ago |
|
|
1c4dacbc85 | 3 weeks ago |
|
|
c3bdb406d6 | 3 weeks ago |
|
|
47d673496b | 3 weeks ago |
|
|
2d6d75fb3c | 3 weeks ago |
|
|
547840d671 | 3 weeks ago |
|
|
4b0d56ff0e | 3 weeks ago |
|
|
31b51b7768 | 3 weeks ago |
|
|
5cd94c05ad | 3 weeks ago |
|
|
f1f5223076 | 3 weeks ago |
|
|
72a5863871 | 3 weeks ago |
|
|
f7d9cbb1c4 | 3 weeks ago |
|
|
9a85a13e08 | 3 weeks ago |
|
|
3e70f4322d | 3 weeks ago |
|
|
1db3653e66 | 3 weeks ago |
|
|
5db12c2348 | 3 weeks ago |
|
|
2a05f41b65 | 3 weeks ago |
|
|
b3e3757a92 | 3 weeks ago |
|
|
47279c72e3 | 3 weeks ago |
|
|
42b5e9ad44 | 3 weeks ago |
|
|
0fe04d4d99 | 3 weeks ago |
|
|
a68331c86b | 3 weeks ago |
|
|
4801ea9f19 | 3 weeks ago |
|
|
89da5b60b7 | 3 weeks ago |
|
|
8ffd104f64 | 3 weeks ago |
|
|
699277be07 | 3 weeks ago |
|
|
5631d85238 | 3 weeks ago |
|
|
7fa6f98ee1 | 3 weeks ago |
|
|
675555531f | 3 weeks ago |
|
|
c381283ee4 | 3 weeks ago |
|
|
26b5eddefa | 3 weeks ago |
|
|
fcc6fae463 | 3 weeks ago |
|
|
997a71b974 | 3 weeks ago |
|
|
e87168d3a0 | 3 weeks ago |
|
|
4eeb3fbc62 | 3 weeks ago |
|
|
93dcfcf4ff | 3 weeks ago |
@ -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` по чек-листу из предложения — остаётся на стороне исполнителя. |
||||||
@ -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` (мета-строка). |
||||||
@ -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). |
||||||
@ -1,130 +1,200 @@ |
|||||||
# Система тестирования сотрудников клиники |
# Система тестирования сотрудников клиники |
||||||
|
|
||||||
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. Руководители подразделений и HR-менеджеры создают тесты и назначают их сотрудникам. Система фиксирует все попытки и результаты. |
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. |
||||||
|
Руководители подразделений и HR-менеджеры создают тесты и назначают их |
||||||
|
сотрудникам. Все попытки и результаты сохраняются. |
||||||
|
|
||||||
**Версия ТЗ:** 1.2 |
- **Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** |
||||||
**Дата:** 2026-03-21 |
- **Ветка разработки:** `dev` |
||||||
**Статус:** Согласовано |
- **ТЗ:** v1.2 от 2026-03-21 (статус — согласовано) |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Содержание |
## Стек и состояние |
||||||
|
|
||||||
- [Функциональные возможности](#функциональные-возможности) |
**Целевой и единственный рабочий стек** — Python 3.11 + Flask 3 + |
||||||
- [Роли и права доступа](#роли-и-права-доступа) |
Jinja2 + Tailwind CDN + SQLAlchemy / psycopg2, код в |
||||||
- [Установка и запуск](#установка-и-запуск) |
[`flask_app/`](flask_app/). На нём работает и прод, и dev (кабинетный UI, |
||||||
- [Нефункциональные требования](#нефункциональные-требования) |
порт **:3107** в Docker, см. ниже). |
||||||
- [Вне scope](#вне-scope-не-реализуется-в-данной-версии) |
|
||||||
|
|
||||||
--- |
Старые каталоги `backend/` (Node.js / Express) и `frontend/` |
||||||
|
(React + Vite) — **архив**: не разворачиваются, в `docker-compose.dev.yml` |
||||||
|
поднимается сервис **`testing-flask`**, удаление папок запланировано |
||||||
|
в спринте **E1.6**. Использовать их не надо, миграции и SQL-схема |
||||||
|
сохранены в `backend/src/db/migrations/` исключительно как источник |
||||||
|
структуры БД. |
||||||
|
|
||||||
## Функциональные возможности |
БД — **`clinic_tests`** (PostgreSQL, UUID-ключи). В Этапе 1 схема |
||||||
|
не меняется. |
||||||
|
|
||||||
### Управление пользователями и подразделениями |
**Этап 2** — слияние с общим HR-кабинетом `HR_TG_Bot/tgFlaskForm` — |
||||||
|
запланирован на будущее, сейчас не делается. План: |
||||||
|
[`docs/migration-to-tgflaskform.md`](docs/migration-to-tgflaskform.md) |
||||||
|
([простыми словами](docs/migration-to-tgflaskform-plain.md)). |
||||||
|
|
||||||
- Создание/редактирование/деактивация учётных записей сотрудников |
--- |
||||||
- Каждый сотрудник принадлежит одному подразделению |
|
||||||
- Создание/редактирование справочника подразделений |
|
||||||
- Назначение роли сотруднику: HR-менеджер / Руководитель подразделения / Сотрудник |
|
||||||
|
|
||||||
### Создание и редактирование тестов |
## Интерфейс (кабинет) |
||||||
|
|
||||||
**Тест содержит:** |
Единственный вариант UI — **как у основного HR-веба**: в |
||||||
- Название теста |
[`base.html`](flask_app/app/templates/base.html) корень |
||||||
- Описание (опционально) |
`cabinet-app` → шапка `cabinet-header` → контент `cabinet-main`; на |
||||||
- Список вопросов (минимум 7) |
`<body>` всегда класс **`ui-legacy`**, стили в [`app.css`](flask_app/app/static/css/app.css) |
||||||
- Порог зачёта — минимальный % правильных ответов |
с префиксом **`body.ui-legacy`** (primary/teal, `.btn`, `.surface-card`, |
||||||
- Таймер прохождения — лимит в минутах (опционально) |
`legacy-list-shell`, `test-detail-page` и т.д.). |
||||||
|
|
||||||
**Вопрос содержит:** |
В [`docker-compose.dev.yml`](docker-compose.dev.yml) один сервис |
||||||
- Текст вопроса |
**`testing-flask`** (`container_name: testing_webapp_flask`), порт **3107**. |
||||||
- Минимум 3 варианта ответа |
|
||||||
- Один или несколько правильных ответов |
|
||||||
|
|
||||||
**Настройки теста:** |
--- |
||||||
- Разрешить возврат к предыдущему вопросу: да / нет |
|
||||||
|
|
||||||
**Версионирование:** |
## Что уже работает на новом (Flask) контуре |
||||||
- Автор может редактировать тест пока никто его не проходил |
|
||||||
- Если тест уже проходили — создаётся новая версия (`version + 1`), старая сохраняется |
E1.0–E1.3 и E1.8 закрыты. Чек-лист и журнал — |
||||||
- Все версии теста хранятся; результаты привязаны к конкретной версии |
[`docs/migration-final.md`](docs/migration-final.md). |
||||||
- Активная версия — та, которую видят сотрудники; автор может вручную переключить активную версию |
|
||||||
- Тест можно деактивировать (скрыть из списка, не удалять) |
- **Авторизация** через куки-сессию Flask: bcrypt (локальные пользователи |
||||||
|
`clinic_tests.users`) или Werkzeug-хеши при включённом `HR_AUTH=1` |
||||||
|
(UPSERT в `clinic_tests.users` по `staff_id` из `hr_bot_test`). |
||||||
|
UI: `/login`, JSON: `/api/auth/{login,logout,me}`. |
||||||
|
- **Каталог тестов** `/tests` (видны активные + блок «Скрытые вами»), |
||||||
|
создание теста через модалку. |
||||||
|
- **Редактор** `/tests/<id>/edit`: правка названия/описания/проходного |
||||||
|
балла, добавление/удаление/перемещение вопросов и вариантов, |
||||||
|
переключатель «Цепочка активна», авто-форк новой версии при правке |
||||||
|
после первой попытки. |
||||||
|
- **AI-помощник** в редакторе: |
||||||
|
- «По названию» — генерация всего теста по теме (количество вопросов |
||||||
|
и вариантов задаёт автор); |
||||||
|
- «По текущей сетке» — генерация по уже расставленным карточкам; |
||||||
|
- «Проверить» — рецензия теста с вердиктом и разделами рекомендаций; |
||||||
|
- «Улучшить» — массовое «было → стало» с чекбоксами; |
||||||
|
- «AI: вопрос/переформулировать» — на отдельной карточке вопроса. |
||||||
|
- **Импорт документа** в редакторе: PDF / DOCX / TXT / MD до 16 МБ, |
||||||
|
через `pypdf` и `python-docx` → AI-черновик. |
||||||
|
- **Настройки** `/settings` — статус общего LLM-ключа из ENV (DeepSeek |
||||||
|
или OpenAI-совместимый), кнопка «Проверить подключение». |
||||||
|
|
||||||
|
Подробная инструкция для тестировщика (только UI, без консоли) — |
||||||
|
[`docs/QA-versioning-and-ai.md`](docs/QA-versioning-and-ai.md). |
||||||
|
|
||||||
|
## Что ещё не реализовано |
||||||
|
|
||||||
|
| Спринт | Что включает | |
||||||
|
|---|---| |
||||||
|
| **E1.4** — Назначение и прохождение | Назначить тест сотруднику, экран прохождения, экран результата с разбором ошибок. | |
||||||
|
| **E1.5** — Трекер и настройки модуля | Единый список попыток с фильтрами, страница настроек цепочки. | |
||||||
|
| **E1.6** — Cutover внутри репозитория | Удаление `backend/` и `frontend/`, чистка `docker-compose.dev.yml` от архивных Node/React-сервисов. | |
||||||
|
| **E1.7** — UX-полировка редактора | 4 аккордеона (Шапка / AI / Вопросы / Действия) и drag-n-drop из [Спринта 3](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). | |
||||||
|
|
||||||
### Назначение теста |
--- |
||||||
|
|
||||||
- Список получателей (отдел или конкретные сотрудники) |
## Установка и запуск |
||||||
- Срок сдачи — дата дедлайна |
|
||||||
- Допустимое количество попыток (1 или более) |
|
||||||
|
|
||||||
### Прохождение теста |
### Предпосылка: общий Postgres |
||||||
|
|
||||||
- На главной странице сотрудник видит список назначенных тестов со статусами: |
Используется **тот же** PostgreSQL, что и в |
||||||
- `Не начат` — ещё не открывал |
[Postgres_TG_Bots](../Postgres_TG_Bots) (контейнер `hr_postgres_dev`, |
||||||
- `В процессе` — начал, не завершил |
сеть `hr_postgres_dev_net`, учётка `hr_bot_user`). |
||||||
- `Завершён` — сдал/не сдал |
|
||||||
- `Просрочен` — дедлайн прошёл, не сдан |
|
||||||
- Если задан таймер — отображается обратный отсчёт, по истечении тест завершается автоматически |
|
||||||
- Порядок вопросов **случайный** при каждом прохождении |
|
||||||
- Возможность вернуться к предыдущему вопросу — определяется настройкой теста |
|
||||||
|
|
||||||
### Результаты после завершения теста |
```bash |
||||||
|
# (один раз) создать базу |
||||||
|
psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" \ |
||||||
|
-c "CREATE DATABASE clinic_tests;" |
||||||
|
|
||||||
- Итоговый балл и процент правильных ответов |
# (один раз) внешняя сеть, если ещё не создана соседом |
||||||
- Факт зачёта: **сдал / не сдал** |
docker network create hr_postgres_dev_net || true |
||||||
- Разбор ошибок: по каждому вопросу — его ответ и правильный ответ |
``` |
||||||
|
|
||||||
### Трекер попыток |
### Dev-стенд |
||||||
|
|
||||||
Единый интерфейс просмотра всех попыток прохождения тестов: |
```bash |
||||||
- Фильтрация по подразделению, сотруднику, тесту, статусу, результату |
docker compose -f docker-compose.dev.yml up -d --build |
||||||
- Пагинация и сортировка |
``` |
||||||
|
|
||||||
### AI-помощник |
| Что | URL | |
||||||
|
|---|---| |
||||||
|
| Приложение (Flask) | <http://localhost:3107> | |
||||||
|
| Health-check | <http://localhost:3107/health> | |
||||||
|
|
||||||
Интеграция с LLM для помощи при создании тестов: |
`docker-compose.dev.yml` пробрасывает в контейнер **`testing-flask`**: |
||||||
|
- `DATABASE_URL` (по умолчанию на контейнерный Postgres `clinic_tests`); |
||||||
|
- `HR_AUTH=1` / `HR_DATABASE_URL` по умолчанию — вход через HR-кабинет; |
||||||
|
- `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` — |
||||||
|
для AI-функций. Достаточно положить ключ в корневой `.env` репозитория. |
||||||
|
|
||||||
| Функция | Описание | |
### Локально без Docker |
||||||
|---------|----------| |
|
||||||
| Генерация теста | AI генерирует готовый набор вопросов с вариантами ответов по теме | |
|
||||||
| Улучшение формулировки | AI переформулирует выбранный вопрос более чётко | |
|
||||||
| Добавление дистракторов | AI генерирует правдоподобные неправильные варианты ответов | |
|
||||||
| Проверка качества | AI анализирует весь тест и выдаёт рекомендации | |
|
||||||
|
|
||||||
--- |
См. [`flask_app/README.md`](flask_app/README.md) — `venv` + |
||||||
|
`pip install -r requirements.txt` + `python run.py`. |
||||||
|
|
||||||
## Роли и права доступа |
--- |
||||||
|
|
||||||
| Роль | Кто | Создаёт тесты | Назначает тесты | Видит результаты | |
## Данные и интеграция с HR |
||||||
|------|-----|:---:|:---:|:---:| |
|
||||||
| **HR-менеджер** | Руководитель службы HR, Директор клиники | ✅ | Всем сотрудникам клиники | Всех сотрудников | |
- **Две роли кластера Postgres.** В **`clinic_tests`** — только сущности |
||||||
| **Руководитель подразделения** | Главный врач, рук. службы администраторов | ✅ | Только своему подразделению | Только своего подразделения | |
модуля тестирования (тесты, версии, назначения, попытки, локальные |
||||||
| **Сотрудник** | Все остальные работники | ❌ | ❌ | Только свои | |
технические учётки). В **`hr_bot_test`** (Postgres_TG_Bots / |
||||||
|
hr_web_viewer) — штат, справочники, RBAC и веб-логины. Схемы не |
||||||
|
смешиваем, второй кадровый учёт в `clinic_tests` не ведём. |
||||||
|
- **Сотрудник** во всех бизнес-процессах — по |
||||||
|
**`staff_members.id`** из `hr_bot_test`. В `clinic_tests` храним тот же |
||||||
|
идентификатор; ФИО / отдел / роли подтягиваем из HR при отображении. |
||||||
|
- **`telegram_id` сотрудника** в бизнес-логике модуля **не участвует** |
||||||
|
(ни вход, ни проверка прав, ни выбор сотрудника, ни фильтрация). |
||||||
|
- **Целевой RBAC** — единая система разрешений HR |
||||||
|
(`staff_role_assignments`, `permissions`). Модуль тестирования |
||||||
|
не дублирует матрицу; пока единый API не готов — в `clinic_tests` |
||||||
|
допустимы временные флаги, явно помеченные как MVP. |
||||||
|
- **`HR_AUTH=1`**: в Flask-контуре включает вход через `hr_bot_test.users` |
||||||
|
(Werkzeug-хеши) с UPSERT в `clinic_tests.users`. См. |
||||||
|
[`flask_app/.env.example`](flask_app/.env.example). |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Установка и запуск |
## Роли и права (по ТЗ) |
||||||
|
|
||||||
|
| Роль | Кто | Создаёт тесты | Назначает | Видит результаты | |
||||||
|
|---|---|:---:|:---:|:---:| |
||||||
|
| **HR-менеджер** | Руководитель HR, директор | ✅ | Всем | Всех | |
||||||
|
| **Руководитель подразделения** | Главврач, рук. отделения | ✅ | Только своему подразделению | Только своего подразделения | |
||||||
|
| **Сотрудник** | Все остальные | ❌ | ❌ | Только свои | |
||||||
|
|
||||||
Инструкции по установке и запуску приложения будут добавлены после выбора технологического стека. |
> На текущем Flask-контуре (E1.0–E1.3, E1.8) проверяется только |
||||||
|
> `@login_required`; разделение по ролям задействуется на E1.4–E1.5. |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Нефункциональные требования |
## Нефункциональные требования |
||||||
|
|
||||||
| Параметр | Значение | |
| Параметр | Значение | |
||||||
|----------|----------| |
|---|---| |
||||||
| Количество пользователей | 50–200 человек | |
| Количество пользователей | 50–200 человек | |
||||||
| Платформа | Веб-приложение, браузер (desktop-first) | |
| Платформа | Веб, браузер; mobile-friendly | |
||||||
| Доступность | Внутренняя сеть клиники | |
| Доступность | Внутренняя сеть клиники | |
||||||
| Язык интерфейса | Русский | |
| Язык интерфейса | Русский | |
||||||
| Время отклика | < 2 секунды | |
| Время отклика | < 2 секунды | |
||||||
|
|
||||||
--- |
## Вне scope (в текущей версии не делаем) |
||||||
|
|
||||||
|
- Интеграция с AD / LDAP. |
||||||
|
- Нативное мобильное приложение. |
||||||
|
- Вопросы с вложениями (картинки, видео). |
||||||
|
- Экспорт отчётов в Excel / PDF. |
||||||
|
- Уведомления в MAX (отдельный спринт). |
||||||
|
|
||||||
## Вне scope (не реализуется в данной версии) |
--- |
||||||
|
|
||||||
- Интеграция с AD/LDAP |
## Документация |
||||||
- Мобильное приложение |
|
||||||
- Вопросы с вложениями (изображения, видео) |
| Файл | О чём | |
||||||
- Экспорт отчётов в Excel / PDF |
|---|---| |
||||||
- Уведомления в MAX (отдельный спринт) |
| [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) | Что работает «прямо сейчас», что в работе, что в бэклоге. | |
||||||
|
| [`docs/migration-final.md`](docs/migration-final.md) | Главный трекер миграции: спринты Этапа 1, журнал, критерии готовности. | |
||||||
|
| [`docs/migration-final-inventory.md`](docs/migration-final-inventory.md) | Карта 22 эндпоинтов Express + gap-analysis с `tgFlaskForm`. | |
||||||
|
| [`docs/migration-to-tgflaskform.md`](docs/migration-to-tgflaskform.md) | План Этапа 2 (слияние с HR-кабинетом). | |
||||||
|
| [`docs/QA-versioning-and-ai.md`](docs/QA-versioning-and-ai.md) | Инструкция для тестировщика — только через сайт. | |
||||||
|
| [`docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md`](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) | Целевой мобильный UX редактора (база для E1.7). | |
||||||
|
| [`docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md`](docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) | Кратко для врачей-кураторов. | |
||||||
|
| [`flask_app/README.md`](flask_app/README.md) | Конкретные команды для нового контура. | |
||||||
|
| [`docs/ТЗ.md`](docs/ТЗ.md) | Исходное ТЗ заказчика. | |
||||||
|
|||||||
@ -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= |
||||||
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
{ |
||||||
|
"semi": true, |
||||||
|
"singleQuote": true, |
||||||
|
"tabWidth": 2, |
||||||
|
"trailingComma": "es5", |
||||||
|
"printWidth": 100 |
||||||
|
} |
||||||
@ -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"] |
||||||
@ -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 |
||||||
@ -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 |
||||||
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
}); |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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'; |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
/** |
||||||
|
* Правка цепочки теста (черновик, версии, публикация, редактор) — только создатель (`tests.created_by`). |
||||||
|
*/ |
||||||
|
export function isTestAuthor(createdBy, userId) { |
||||||
|
return createdBy === userId; |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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; |
||||||
@ -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 }; |
||||||
@ -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(); |
||||||
@ -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; |
||||||
@ -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, |
||||||
|
}; |
||||||
|
} |
||||||
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
|
); |
||||||
@ -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: 'Выбран вариант ответа, не относящийся к вопросу.', |
||||||
|
}; |
||||||
@ -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, |
||||||
|
}; |
||||||
@ -0,0 +1,188 @@ |
|||||||
|
/** |
||||||
|
* A.1–A.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; |
||||||
@ -0,0 +1,634 @@ |
|||||||
|
/** |
||||||
|
* V.4–V.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; |
||||||
@ -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'}`); |
||||||
|
}); |
||||||
@ -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'); |
||||||
|
}); |
||||||
@ -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 |
||||||
|
); |
||||||
|
}); |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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] |
||||||
|
); |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
@ -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); |
||||||
|
}); |
||||||
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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, |
||||||
|
}; |
||||||
@ -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'; |
||||||
|
} |
||||||
@ -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); |
||||||
|
}); |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
# Система тестирования + общий 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;" |
||||||
|
# |
||||||
|
# Flask UI (кабинетный стиль): http://localhost:3107 |
||||||
|
|
||||||
|
services: |
||||||
|
testing-flask: |
||||||
|
build: |
||||||
|
context: ./flask_app |
||||||
|
dockerfile: Dockerfile |
||||||
|
container_name: testing_webapp_flask |
||||||
|
environment: |
||||||
|
PORT: "3107" |
||||||
|
WEB_USE_WAITRESS: "1" |
||||||
|
FLASK_DEBUG: "0" |
||||||
|
SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me} |
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests} |
||||||
|
HR_AUTH: ${HR_AUTH:-1} |
||||||
|
HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test} |
||||||
|
DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-} |
||||||
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} |
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-} |
||||||
|
LLM_BASE_URL: ${LLM_BASE_URL:-} |
||||||
|
LLM_MODEL: ${LLM_MODEL:-} |
||||||
|
ports: |
||||||
|
- "3107:3107" |
||||||
|
networks: |
||||||
|
- app |
||||||
|
- postgres |
||||||
|
|
||||||
|
networks: |
||||||
|
app: |
||||||
|
postgres: |
||||||
|
name: hr_postgres_dev_net |
||||||
|
external: true |
||||||
@ -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: |
||||||
@ -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, БД, переменные окружения. |
||||||
@ -0,0 +1,148 @@ |
|||||||
|
# Состояние проекта |
||||||
|
|
||||||
|
**Репозиторий:** [TestingWebApp](https://git.pirogov.ai/l_konstantin/TestingWebApp) · ветка разработки **`dev`** |
||||||
|
**Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** |
||||||
|
**Дата среза:** 2026-04-28 |
||||||
|
|
||||||
|
Не дубль ТЗ, а карта «что реально работает в коде, на каком контуре, |
||||||
|
и что логично сделать дальше». |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## TL;DR |
||||||
|
|
||||||
|
- Прод и dev работают **только на Flask-контуре** (`flask_app/`, |
||||||
|
Python 3.11 + Flask 3 + Jinja2 + Tailwind CDN + SQLAlchemy). |
||||||
|
- Каталоги `backend/` (Express) и `frontend/` (React) — архив, не |
||||||
|
разворачиваются и не используются; удаление запланировано в |
||||||
|
спринте **E1.6**. |
||||||
|
- БД — **`clinic_tests`** (PostgreSQL). Схема в Этапе 1 не меняется. |
||||||
|
- Этап 2 (слияние с `HR_TG_Bot/tgFlaskForm`) пока не делаем — |
||||||
|
[`migration-to-tgflaskform.md`](migration-to-tgflaskform.md). |
||||||
|
|
||||||
|
Главный трекер по спринтам — [`migration-final.md`](migration-final.md). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Что уже работает на новом контуре (E1.0–E1.3, E1.8) |
||||||
|
|
||||||
|
### Вход |
||||||
|
- `/login` (форма) и `/api/auth/login` (JSON), `/api/auth/logout`, |
||||||
|
`/api/auth/me`. |
||||||
|
- По умолчанию — bcrypt-хеши из `clinic_tests.users`. |
||||||
|
- `HR_AUTH=1` + `HR_DATABASE_URL` — вход через `hr_bot_test.users` |
||||||
|
(Werkzeug); запись синхронизируется в `clinic_tests.users` UPSERT-ом по |
||||||
|
`staff_id`. Сценарий «пользователь без `staff_id`» — пропускается с |
||||||
|
предупреждением в логах. |
||||||
|
|
||||||
|
### Каталог тестов (`/tests`) |
||||||
|
- Видны цепочки, где вы автор, и активные публичные. |
||||||
|
- Создание теста через модалку («Название» + «Описание»). |
||||||
|
- Кнопка «Скрыть» / «Вернуть» работает на цепочку целиком. |
||||||
|
|
||||||
|
### Редактор теста (`/tests/<id>/edit`) |
||||||
|
- Поля шапки: название, описание, проходной балл, переключатель |
||||||
|
«Цепочка активна». |
||||||
|
- Вопросы и варианты: добавить / удалить / переместить, отметить верные. |
||||||
|
- **Версионирование.** Пока по цепочке нет завершённых попыток — |
||||||
|
правки идут «на месте». После первой попытки любое содержательное |
||||||
|
сохранение делает форк (`version + 1`, `parent_id` = прежняя), |
||||||
|
старая версия остаётся в БД и не видна в каталоге. |
||||||
|
- Подробная модель поведения и проверочные сценарии — |
||||||
|
[`QA-versioning-and-ai.md`](QA-versioning-and-ai.md). |
||||||
|
|
||||||
|
### AI-помощник в редакторе |
||||||
|
| Кнопка | Что делает | |
||||||
|
|---|---| |
||||||
|
| По названию | Генерирует весь набор вопросов по теме. Параметры — кол-во вопросов и вариантов. | |
||||||
|
| По текущей сетке | Дописывает варианты для уже расставленных карточек. | |
||||||
|
| Проверить | Рецензирует тест: вердикт + блоки рекомендаций. | |
||||||
|
| Улучшить | «Было → стало» по каждому вопросу/варианту с чекбоксами. | |
||||||
|
| AI: вопрос | На карточке вопроса — переформулировка / генерация дистракторов. | |
||||||
|
|
||||||
|
При отсутствии ключа — единая ошибка с ссылкой на `/settings`. |
||||||
|
|
||||||
|
### Импорт документа |
||||||
|
- PDF / DOCX / TXT / MD до 16 МБ. |
||||||
|
- `pypdf` для PDF, `python-docx` для DOCX, плоский текст — как есть. |
||||||
|
- Извлечённый текст идёт в LLM, на выходе — черновик теста, который |
||||||
|
открывается в редакторе. |
||||||
|
|
||||||
|
### Настройки (`/settings`) |
||||||
|
- Статус общего LLM-ключа (берётся из ENV: `DEEPSEEK_API_KEY` → |
||||||
|
`OPENAI_API_KEY`). |
||||||
|
- Провайдер, модель, base URL. |
||||||
|
- Кнопка «Проверить подключение» — пинг `/v1/chat/completions` через |
||||||
|
`ping_llm()`. |
||||||
|
- Ключ на клиента не уходит и в БД не пишется. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Чего на Flask пока нет |
||||||
|
|
||||||
|
Эти сценарии будут реализованы в E1.4–E1.5. До этого в приложении они |
||||||
|
просто отсутствуют (старый Express-контур не используется и не |
||||||
|
поднимается): |
||||||
|
|
||||||
|
- **Назначение теста сотруднику** — поиск по справочнику, «Выбрать |
||||||
|
всех», фильтры по подразделениям. |
||||||
|
- **Прохождение** — экран вопросов, таймер, сохранение попытки. |
||||||
|
- **Результат и разбор ошибок** — отдельная страница с ответами |
||||||
|
пользователя и правильными вариантами. |
||||||
|
- **Трекер попыток** — единый список завершённых попыток с фильтрами |
||||||
|
(подразделение / сотрудник / тест / статус / результат). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Что в работе и в планах |
||||||
|
|
||||||
|
### Этап 1 — паритет внутри TestingWebApp |
||||||
|
|
||||||
|
| Спринт | Содержание | Статус | |
||||||
|
|---|---|---| |
||||||
|
| E1.0 | База Flask-приложения (БД-пул, сессии, `base.html`). | ✅ | |
||||||
|
| E1.1 | Auth + `/api/me` (bcrypt + Werkzeug, опц. `HR_AUTH`). | ✅ | |
||||||
|
| E1.2 | Каталог тестов и редактор (функциональный минимум). | ✅ | |
||||||
|
| E1.3 | Импорт документов (PDF / DOCX / TXT / MD). | ✅ | |
||||||
|
| E1.4 | Назначения и прохождение тестов. | ⬜ Следующий. | |
||||||
|
| E1.5 | Трекер попыток + страница настроек цепочки. | ⬜ | |
||||||
|
| E1.6 | Cutover внутри репозитория (удаление `backend/` + `frontend/`). | ⬜ | |
||||||
|
| E1.7 | UX-полировка редактора: 4 аккордеона + drag-n-drop. | ⬜ | |
||||||
|
| E1.8 | AI-функции v2 (`/settings`, generate-by-title, check, improve). | ✅ | |
||||||
|
|
||||||
|
Подробности — [`migration-final.md`](migration-final.md). |
||||||
|
|
||||||
|
### Этап 2 — слияние с HR-кабинетом (на будущее) |
||||||
|
|
||||||
|
- Перенос blueprint'ом в `HR_TG_Bot/tgFlaskForm` под путь |
||||||
|
`/cabinet/testing`. |
||||||
|
- ETL `clinic_tests → hr_bot_test`. Скрипт-заготовка: |
||||||
|
[`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`). |
||||||
|
- Авторизация — через сессию HR-кабинета. |
||||||
|
- Подробности и риски — [`migration-to-tgflaskform.md`](migration-to-tgflaskform.md) |
||||||
|
(и [простыми словами](migration-to-tgflaskform-plain.md)). |
||||||
|
|
||||||
|
### Долгий бэклог |
||||||
|
|
||||||
|
| Направление | Суть | |
||||||
|
|---|---| |
||||||
|
| Дашборды (ТЗ этап 2) | Единая картина по отделу / клинике, фильтры, история. | |
||||||
|
| MAX / мини-приложение | Встраивание в общий HR-контур клиники. | |
||||||
|
| Таймер, подсказки, медиа в вопросах | Режимы прохождения и вложения — отдельные этапы ТЗ. | |
||||||
|
| E2E и интеграционные тесты | Расширение `V.9`, стабильный CI. | |
||||||
|
| Назначения по отделу | Сроки, лимит попыток, групповые назначения. | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Связанные документы |
||||||
|
|
||||||
|
- [README репозитория](../README.md) |
||||||
|
- [Главный трекер миграции — `migration-final.md`](migration-final.md) |
||||||
|
- [Карта Express + gap-analysis с `tgFlaskForm` — `migration-final-inventory.md`](migration-final-inventory.md) |
||||||
|
- [План Этапа 2 — `migration-to-tgflaskform.md`](migration-to-tgflaskform.md) |
||||||
|
- [Инструкция тестировщику — `QA-versioning-and-ai.md`](QA-versioning-and-ai.md) |
||||||
|
- [Спринты мобильного UX редактора](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) |
||||||
|
- [Кратко для врачей-кураторов](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) |
||||||
|
- [Руководство по dev-контуру](DEV_CONTOUR_USER_GUIDE.md) |
||||||
|
- [ТЗ заказчика](ТЗ.md) |
||||||
@ -0,0 +1,414 @@ |
|||||||
|
# Инструкция для тестировщика: версионирование тестов и AI |
||||||
|
|
||||||
|
Сайт: **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** |
||||||
|
|
||||||
|
Учётка: подойдёт **любая** учётная запись на сайте — никаких особых ролей |
||||||
|
не требуется. Любой залогиненный пользователь, который создаёт тест, |
||||||
|
автоматически становится его автором и может его редактировать. Если |
||||||
|
учётки нет — попросите её у разработчика. |
||||||
|
|
||||||
|
> Всё, что описано ниже, проверяется **только через сайт**. Если в каком-то |
||||||
|
> сценарии написано «недоступно сейчас» — это **не баг**, это значит, что |
||||||
|
> функция UI ещё не сделана и появится позже. Просто пропускайте. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Часть 1. Версии теста — что меняется при правках |
||||||
|
|
||||||
|
### О чём вообще задача |
||||||
|
|
||||||
|
Когда автор правит тест, в системе важно не сломать историю прохождений |
||||||
|
сотрудников. Поэтому правила такие: |
||||||
|
|
||||||
|
- Пока **никто не прошёл** тест — автор правит на месте, версия одна. |
||||||
|
- Как только **хотя бы один сотрудник прошёл** тест — следующее сохранение |
||||||
|
изменений создаёт **новую версию** (v2, v3, …), старая сохраняется. |
||||||
|
- В каталоге всегда видна **только одна** активная версия. |
||||||
|
- Автор может **скрыть** тест целиком (чекбокс «Цепочка активна»). |
||||||
|
- Автор может **переключить** активную версию на другую из истории. |
||||||
|
|
||||||
|
> Сейчас на сайте нельзя пройти тест сотруднику и нельзя из UI открыть |
||||||
|
> историю версий — это будет в следующих спринтах. Поэтому из шести |
||||||
|
> правил тестировщик пока проверяет четыре, остальные помечены ниже. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 1.1. Создание нового теста |
||||||
|
|
||||||
|
**Что проверяем:** автор может создать тест с нуля. |
||||||
|
|
||||||
|
**Как проверять:** |
||||||
|
1. Откройте [https://edullm.pirogov.ai/](https://edullm.pirogov.ai/) и войдите. |
||||||
|
2. Нажмите в шапке иконку **«Тесты»** (список) → попадаете в каталог. |
||||||
|
3. Нажмите кнопку **«Создать тест»**. |
||||||
|
4. В появившемся окне заполните **Название** (например, «Тест A»), |
||||||
|
при желании — Описание. |
||||||
|
5. Нажмите **«Создать»**. |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- Открывается экран редактора нового теста. |
||||||
|
- В поле «Название» — то, что вы ввели. |
||||||
|
- Список вопросов пуст, счётчик «Вопросы (0)». |
||||||
|
- Внизу — кнопка **«Сохранить»** и чекбокс **«Цепочка активна»** (по |
||||||
|
умолчанию включён). |
||||||
|
|
||||||
|
**Если что-то не так — баг:** |
||||||
|
- Окно «Создать тест» не открывается. |
||||||
|
- После «Создать» никуда не перенаправило. |
||||||
|
- В поле «Название» в редакторе пусто, хотя ввели текст. |
||||||
|
- Список тестов в каталоге не обновился (не появилась новая карточка). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 1.2. Правка теста до прохождений (версия не растёт) |
||||||
|
|
||||||
|
**Что проверяем:** пока никто не проходил тест, автор может править его |
||||||
|
сколько угодно — это всё одна и та же версия. |
||||||
|
|
||||||
|
**Как проверять:** |
||||||
|
1. Откройте только что созданный «Тест A» (если уже не открыт): шапка → **«Тесты»** → нажмите на карточку теста. |
||||||
|
2. Нажмите **«Добавить вопрос»** — появится карточка вопроса. |
||||||
|
3. Введите текст вопроса. |
||||||
|
4. Заполните 3–4 варианта ответа в поле «Вариант ответа», у одного из них поставьте чекбокс «Правильный» (квадратик слева от текста). |
||||||
|
5. Добавьте ещё один-два вопроса тем же способом. |
||||||
|
6. Нажмите **«Сохранить»** в нижней панели. |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- Под шапкой появляется надпись **«Сохранено.»** (без слов про новую версию). |
||||||
|
- Если перезагрузить страницу — все вопросы и варианты на месте. |
||||||
|
|
||||||
|
**Повторите правку:** |
||||||
|
1. На том же экране **измените** текст одного вопроса, **добавьте** ещё один вариант к другому, **удалите** третий вопрос (кнопка «корзина» справа в карточке вопроса). |
||||||
|
2. Нажмите **«Сохранить»**. |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- Снова надпись **«Сохранено.»**. |
||||||
|
- Никаких слов «создана новая версия». |
||||||
|
- Перезагрузка страницы — изменения сохранились. |
||||||
|
|
||||||
|
**Если что-то не так — баг:** |
||||||
|
- Появляется сообщение про «новую версию» (его быть не должно — попыток ещё нет). |
||||||
|
- Изменения не сохранились после перезагрузки страницы. |
||||||
|
- Сообщение «Сохранено.» не появилось вовсе. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 1.3. Правка после прохождений (создаётся v2) |
||||||
|
|
||||||
|
> ⏳ **Сейчас недоступно для проверки.** |
||||||
|
> |
||||||
|
> На сайте пока нет страницы для прохождения теста сотрудником, поэтому |
||||||
|
> тестировщик не может «накопить» попытки и проверить эту логику через UI. |
||||||
|
> Сценарий вернётся в инструкцию вместе со следующим спринтом, когда |
||||||
|
> появится страница прохождения. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 1.4. Каталог показывает только активные тесты |
||||||
|
|
||||||
|
**Что проверяем:** в каталоге нет неактивных/скрытых тестов. |
||||||
|
|
||||||
|
**Как проверять:** |
||||||
|
1. На экране редактора любого теста снимите внизу чекбокс |
||||||
|
**«Цепочка активна»** и нажмите **«Сохранить»**. |
||||||
|
2. Перейдите в шапке на **«Тесты»** (каталог). |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- Карточки этого теста в основном списке нет. |
||||||
|
- Внизу страницы каталога есть раскрывающийся блок **«Скрытые вами цепочки (N)»**. Раскройте его — там видно ваш тест. |
||||||
|
- Нажмите **«Открыть»** рядом с тестом — снова попадаете в редактор. |
||||||
|
- Поставьте обратно галку **«Цепочка активна»** → **«Сохранить»**. |
||||||
|
- Снова перейдите в **«Тесты»** — тест опять в основном списке каталога. |
||||||
|
|
||||||
|
**Если что-то не так — баг:** |
||||||
|
- После снятия галки тест остаётся в обычном каталоге. |
||||||
|
- Тест полностью пропал и его не видно нигде, даже в «Скрытых». |
||||||
|
- После возврата галки тест не вернулся в основной каталог. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 1.5. Ручное переключение активной версии |
||||||
|
|
||||||
|
> ⏳ **Сейчас недоступно для проверки.** |
||||||
|
> |
||||||
|
> Страница с историей версий теста (где можно нажать «сделать активной») |
||||||
|
> ещё не сделана. Появится в следующем спринте. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 1.6. История прохождений после правок |
||||||
|
|
||||||
|
> ⏳ **Сейчас недоступно для проверки** — см. 1.3 и 1.5. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Часть 2. AI-функции в редакторе теста |
||||||
|
|
||||||
|
### Как проверять, что AI вообще работает |
||||||
|
|
||||||
|
**Что проверяем:** ключ к AI задан и сервис отвечает. |
||||||
|
|
||||||
|
**Как проверять:** |
||||||
|
1. В шапке нажмите иконку **«Настройки»** (шестерёнка). |
||||||
|
2. Посмотрите блок «Подключение к LLM»: |
||||||
|
- **Статус ключа** — должно быть зелёное **«Задан»**. |
||||||
|
- **Провайдер** и **Модель** — заполнены. |
||||||
|
3. Нажмите кнопку **«Проверить подключение»**. |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- В течение 1–10 секунд под кнопкой появляется **зелёный** блок |
||||||
|
с текстом вида **«OK · deepseek / deepseek-chat · 1234 мс»**. |
||||||
|
|
||||||
|
**Если что-то не так:** |
||||||
|
- Если статус **«Не задан»** (красный) — сообщите разработчику, не задан ключ. Тестировать AI-функции в этом режиме не нужно, кроме одного сценария ниже (2.7). |
||||||
|
- Если кнопка отдала **красный** блок «Ошибка» при заданном ключе — это баг, прикладывайте текст ошибки к тикету. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 2.1. Сгенерировать тест по названию |
||||||
|
|
||||||
|
**Простыми словами:** автор пишет только тему, AI сам придумывает вопросы. |
||||||
|
|
||||||
|
**Как проверять:** |
||||||
|
1. **«Тесты»** → **«Создать тест»** → название, например, **«Гигиена рук»**, описание можно оставить пустым → **«Создать»**. |
||||||
|
2. В редакторе нажмите кнопку **«По названию»** (фиолетовая, в блоке «AI-помощник» → «Создать вопросы»). |
||||||
|
3. На вопрос «Сколько вопросов сгенерировать?» введите, например, `8` → **OK**. |
||||||
|
4. На вопрос «Сколько вариантов в каждом вопросе?» введите, например, `4` → **OK**. |
||||||
|
5. Подождите 5–20 секунд. |
||||||
|
6. Появится подтверждение «Готово: «…», вопросов — N. Применить как черновик? Текущие вопросы будут заменены». Нажмите **OK**. |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- В списке появилось примерно 8 вопросов на тему гигиены рук, в каждом примерно 4 варианта ответа на русском языке. |
||||||
|
- В каждом вопросе хотя бы один вариант помечен как «Правильный» (галка слева от текста варианта). |
||||||
|
- Внизу можно нажать **«Сохранить»** — тест сохраняется. |
||||||
|
|
||||||
|
**Дополнительно (что блокировка названия работает):** |
||||||
|
1. Создайте ещё один тест, в редакторе **очистите поле «Название»**. |
||||||
|
2. Нажмите **«По названию»**. |
||||||
|
3. Должен появиться алерт **«Сначала заполните название теста.»**, курсор перейдёт в поле «Название». Никакой генерации не происходит. |
||||||
|
|
||||||
|
**Если что-то не так — баг:** |
||||||
|
- Кнопка **«По названию»** работает при пустом названии (без алерта). |
||||||
|
- Сгенерированные вопросы — не на русском или не по теме названия. |
||||||
|
- В вопросе нет ни одного правильного варианта. |
||||||
|
- Подтверждение «Применить?» не появилось — вопросы заменились молча. |
||||||
|
- Отказ в подтверждении (Cancel) всё равно заменил вопросы. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 2.2. Сгенерировать тест по сетке |
||||||
|
|
||||||
|
**Простыми словами:** автор сам задаёт «скелет» — сколько вопросов и |
||||||
|
сколько вариантов; AI заполняет вопросы по этому скелету. |
||||||
|
|
||||||
|
**Как проверять:** |
||||||
|
1. Создайте новый тест с названием **«Тест по сетке»**. |
||||||
|
2. Нажмите **«Добавить вопрос»** пять раз — получится 5 пустых карточек. |
||||||
|
3. В **третьей и пятой** карточках поставьте галку **«Несколько правильных ответов»**. |
||||||
|
4. В каждом вопросе по умолчанию 0 вариантов — нажмите **«Добавить вариант»** в каждом вопросе по 3 раза, чтобы стало по 3 варианта. |
||||||
|
5. Нажмите кнопку **«По текущей сетке»** в блоке «AI-помощник». |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- В списке снова 5 вопросов (не больше, не меньше). |
||||||
|
- В каждом — по 3 варианта. |
||||||
|
- В третьем и пятом вопросах несколько вариантов помечены правильными; |
||||||
|
в остальных — ровно один. |
||||||
|
- Тексты — на русском, по теме названия. |
||||||
|
|
||||||
|
**Если что-то не так — баг:** |
||||||
|
- Стало другое число вопросов или вариантов (не 5×3). |
||||||
|
- В третьем/пятом вопросе только один правильный ответ, а в остальных — несколько. |
||||||
|
- Пришла ошибка типа **«AI: ошибка»** без понятного объяснения. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 2.3. Проверить тест |
||||||
|
|
||||||
|
**Простыми словами:** AI читает весь тест и пишет, что в нём не так. |
||||||
|
|
||||||
|
**Как проверять:** |
||||||
|
1. Откройте любой тест с 3+ вопросами (например, «Гигиена рук» из 2.1). |
||||||
|
2. Желательно специально испортить пару вопросов: переписать |
||||||
|
формулировку расплывчато («что-то про что-то»), сделать варианты |
||||||
|
ответа очень похожими друг на друга или явно дурацкими. |
||||||
|
3. Нажмите **«Сохранить»**. |
||||||
|
4. Нажмите кнопку **«Проверить»** в блоке «AI-помощник» → «Улучшить существующее». |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- Открывается окно «Проверка теста». |
||||||
|
- Сверху — цветная плашка с одним из вердиктов: **«Годен»** (зелёный), |
||||||
|
**«Есть замечания»** (жёлтый) или **«Серьёзные проблемы»** (красный) |
||||||
|
+ одно-два предложения резюме. |
||||||
|
- Ниже — список разделов: «Чёткость формулировок», «Качество дистракторов», |
||||||
|
«Охват темы», «Сбалансированность сложности». Под каждым — конкретные |
||||||
|
пункты, что улучшить. |
||||||
|
- Закрытие крестиком сверху или кнопкой «Закрыть» внизу — работает. |
||||||
|
- В тесте при этом **ничего не меняется**, AI только советует. |
||||||
|
|
||||||
|
**Если что-то не так — баг:** |
||||||
|
- Окно пустое или текст не на русском. |
||||||
|
- Все вопросы AI признал хорошими, хотя вы их специально испортили. |
||||||
|
- После закрытия окна в тесте появились/исчезли вопросы. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 2.4. Улучшить весь тест |
||||||
|
|
||||||
|
**Простыми словами:** AI предлагает улучшенные формулировки и варианты; |
||||||
|
автор отмечает галками, что применить. |
||||||
|
|
||||||
|
**Как проверять:** |
||||||
|
1. Откройте тест из 2.3 (с теми же намеренно слабыми вопросами). |
||||||
|
2. Нажмите **«Сохранить»** на всякий случай. |
||||||
|
3. Нажмите кнопку **«Улучшить»**. |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- Открывается окно «Улучшение теста». |
||||||
|
- Сверху подпись «Отметьте вопросы… N из M» (M — всего вопросов, N — где AI предложил изменения). |
||||||
|
- Каждый изменённый вопрос — отдельный блок: |
||||||
|
- Чекбокс **«Вопрос #N»** (по умолчанию **отмечен**). |
||||||
|
- Слева — «Было» (старый текст и варианты, изменённые куски зачёркнуты). |
||||||
|
- Справа — «Стало» (новые формулировки, выделены). |
||||||
|
- Правильные варианты помечены галочкой **✓**. |
||||||
|
- Внизу — две кнопки: **«Отмена»** и **«Применить выбранное»**. |
||||||
|
|
||||||
|
**Проверьте применение по выбору:** |
||||||
|
1. Снимите галки у двух вопросов из списка. |
||||||
|
2. Нажмите **«Применить выбранное»**. |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- Окно закрывается. |
||||||
|
- В редакторе **только** отмеченные вопросы заменены на улучшенные; |
||||||
|
два вопроса, у которых вы сняли галки, остались в прежнем виде. |
||||||
|
- Появляется подсказка «Изменения применены. Не забудьте сохранить.» |
||||||
|
- После **«Сохранить»** — обычное «Сохранено.» |
||||||
|
|
||||||
|
**Если что-то не так — баг:** |
||||||
|
- Поменялось **число** вопросов или вариантов в каких-то вопросах |
||||||
|
(должно остаться как было). |
||||||
|
- В вопросе изменилось значение «Несколько правильных ответов» (галка |
||||||
|
переключилась сама). |
||||||
|
- Изменились вопросы, у которых вы сняли галку. |
||||||
|
- Кнопка «Отмена» всё равно применила изменения. |
||||||
|
- В колонках «Было» и «Стало» одинаковый текст (нет смысла предлагать). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 2.5. AI: вопрос/переформулировать |
||||||
|
|
||||||
|
**Простыми словами:** работа с одним вопросом. Если поле пустое — AI его |
||||||
|
придумает; если уже заполнено — переформулирует красивее, варианты не трогает. |
||||||
|
|
||||||
|
**Как проверять (новый вопрос):** |
||||||
|
1. В любом тесте нажмите **«Добавить вопрос»** — появилась пустая карточка. |
||||||
|
2. **Не трогайте** поле «Формулировка вопроса». |
||||||
|
3. Нажмите **«Добавить вариант»** 4 раза — должно стать 4 пустых варианта. |
||||||
|
4. Нажмите кнопку **«AI: вопрос/переформулировать»** в этой карточке. |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- Поле «Формулировка вопроса» заполнено осмысленным текстом по теме теста. |
||||||
|
- Все 4 варианта заполнены. |
||||||
|
- Ровно один помечен как правильный. |
||||||
|
- Внизу появляется строка статуса **«AI: вопрос сгенерирован.»** |
||||||
|
|
||||||
|
**Как проверять (переформулировать существующий):** |
||||||
|
1. Возьмите готовый вопрос с уже заполненной формулировкой. |
||||||
|
2. Нажмите **«AI: вопрос/переформулировать»** в его карточке. |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- Меняется **только текст вопроса** — варианты ответа остаются прежними, |
||||||
|
правильные варианты те же. |
||||||
|
- Статус **«AI: формулировка обновлена.»** |
||||||
|
|
||||||
|
**Если что-то не так — баг:** |
||||||
|
- На пустом вопросе AI ничего не сгенерировал. |
||||||
|
- На заполненном вопросе AI поменял варианты ответа или правильность — |
||||||
|
должен трогать только формулировку. |
||||||
|
- Получилось 0 или 1 правильных вариантов в новом вопросе (надо ровно 1 |
||||||
|
для одиночного выбора). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 2.6. Импорт документа |
||||||
|
|
||||||
|
**Простыми словами:** автор загружает PDF/Word/текст со статьёй — |
||||||
|
AI читает файл и сам предлагает черновик теста. |
||||||
|
|
||||||
|
**Подготовьте файл:** возьмите PDF или DOCX на 1–3 страницы со связным |
||||||
|
русским текстом (например, любую методичку или статью). Лимит — 16 МБ. |
||||||
|
|
||||||
|
**Как проверять:** |
||||||
|
1. В редакторе любого нового теста (можно пустого) → блок «AI-помощник» |
||||||
|
→ **«Импортировать»** → нажмите большую кнопку **«Загрузить документ (PDF, DOCX, TXT, MD)»** → выберите файл. |
||||||
|
2. Подождите 5–30 секунд. |
||||||
|
|
||||||
|
**Что должно произойти:** |
||||||
|
- Появляется подтверждение «Сгенерировано: «…», вопросов: N. Применить как новый черновик? Текущие вопросы будут заменены». Нажмите **OK**. |
||||||
|
- Тест заполнен вопросами по содержанию загруженного документа. |
||||||
|
- Можно сохранить кнопкой **«Сохранить»**. |
||||||
|
|
||||||
|
**Дополнительно — отказ:** |
||||||
|
1. Повторите загрузку, но в подтверждении нажмите **«Отмена»**. |
||||||
|
2. Тест должен остаться **в прежнем виде**, ничего не подменилось. |
||||||
|
|
||||||
|
**Дополнительно — большой файл:** |
||||||
|
1. Возьмите файл больше 16 МБ. |
||||||
|
2. Загрузите его. |
||||||
|
|
||||||
|
**Что должно произойти:** ошибка о слишком большом файле; вопросы не |
||||||
|
подменились. |
||||||
|
|
||||||
|
**Дополнительно — неподдерживаемый тип:** |
||||||
|
1. Возьмите файл `.xlsx` или картинку `.png`. |
||||||
|
2. Попробуйте загрузить. |
||||||
|
|
||||||
|
**Что должно произойти:** алерт «Неподдерживаемый формат…»; вопросы не |
||||||
|
подменились. |
||||||
|
|
||||||
|
**Если что-то не так — баг:** |
||||||
|
- Подтверждение не появилось — вопросы заменились молча. |
||||||
|
- Отказ в подтверждении всё равно подменил вопросы. |
||||||
|
- Большой файл не вызвал ошибку, а как-то «прошёл». |
||||||
|
- Файл не на русском дал тест с непонятной кашей вместо вопросов. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 2.7. Поведение, если ключа AI нет |
||||||
|
|
||||||
|
> Этот сценарий обычно проверять не нужно — он сработает автоматически, если разработчик уберёт ключ. |
||||||
|
> Но если в каталоге AI вообще не работает, то проверьте именно это, чтобы понять — это баг или просто ключ не настроен. |
||||||
|
|
||||||
|
**Как проверять:** |
||||||
|
1. **«Настройки»** → если статус **«Не задан»** (красный) — это и есть тестируемая ситуация. |
||||||
|
2. Откройте любой тест и нажмите по очереди: |
||||||
|
- **«По названию»** |
||||||
|
- **«По текущей сетке»** |
||||||
|
- **«Проверить»** |
||||||
|
- **«Улучшить»** |
||||||
|
- **«AI: вопрос/переформулировать»** в любой карточке |
||||||
|
- **«Загрузить документ»** |
||||||
|
|
||||||
|
**Что должно произойти:** в каждом случае появляется понятное сообщение, |
||||||
|
что AI не настроен, **и предложение открыть «Настройки»**. После согласия |
||||||
|
— открывается страница `/settings`. Никакого «AI: ошибка» без объяснений. |
||||||
|
|
||||||
|
**Если что-то не так — баг:** |
||||||
|
- Сообщение об ошибке без слов «Настройки». |
||||||
|
- Ссылка/кнопка «Открыть Настройки» не ведёт на нужную страницу. |
||||||
|
- Сайт молча ничего не делает после нажатия AI-кнопки. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Памятка: общий алгоритм отчёта о баге |
||||||
|
|
||||||
|
Если что-то идёт не так, к тикету приложите: |
||||||
|
|
||||||
|
1. **URL страницы**, на которой воспроизвели баг (полностью, из адресной |
||||||
|
строки). |
||||||
|
2. **Шаги** — что именно нажимали по порядку (1-2-3). |
||||||
|
3. **Что увидели** против **что ожидали увидеть** (по описанию выше). |
||||||
|
4. **Скриншот** экрана с проблемой (для модалок — со всем содержимым окна). |
||||||
|
5. Если ошибка — **точный текст** сообщения. |
||||||
|
6. **Учётная запись**, под которой воспроизвели (логин, без пароля). |
||||||
|
|
||||||
|
Этого достаточно — лезть в консоль/код/базу не нужно и не надо. |
||||||
@ -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) |
||||||
@ -0,0 +1,340 @@ |
|||||||
|
# UX-аудит страницы теста и предложение новой информационной архитектуры |
||||||
|
|
||||||
|
**Продукт:** HR system — модуль тестирования |
||||||
|
**Платформа:** Цифровые сервисы клиники им. Е. Н. Оленевой |
||||||
|
**Объект анализа:** страница `/tests/{id}` — создание/редактирование теста |
||||||
|
**Дата:** 29 апреля 2026 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Краткая сводка |
||||||
|
|
||||||
|
Текущая страница `/tests/{id}` совмещает три разные пользовательские задачи в одном экране: |
||||||
|
|
||||||
|
1. **Авторскую** — придумать и оформить тест (название, описание, вопросы, варианты). |
||||||
|
2. **Управленческую** — назначить тест 1–N сотрудникам. |
||||||
|
3. **Аналитическую** — посмотреть, кто из сотрудников и какие версии проходил. |
||||||
|
|
||||||
|
Эти задачи различаются по ролям, частоте, объёму данных и контексту. Сейчас они смешаны в одном длинном аккордеоне, что приводит к ряду проблем — от потери изменений до невозможности масштабировать список аудитории за пределы 100–200 человек. |
||||||
|
|
||||||
|
Документ состоит из двух частей: |
||||||
|
|
||||||
|
- **Часть 1** — аудит текущей страницы с приоритизированными проблемами (critical / major / minor) и ссылками на скриншоты. |
||||||
|
- **Часть 2** — предложение новой IA с раздельными разделами «Тесты», «Назначения», «Отчёты», ролевой моделью и описанием жизненного цикла версии теста. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
# Часть 1. Аудит текущей страницы |
||||||
|
|
||||||
|
Все скриншоты сделаны 29.04.2026 на странице `https://edullm.pirogov.ai/tests/298a64af-...` под ролью `employee` (см. п. M-3). |
||||||
|
|
||||||
|
## 1.1. Шапка, заголовок и баннер версионирования |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Что мы видим: глобальная шапка «Тестирование», подпись пользователя `Разорвин А. М. · employee`, кнопка «Выйти». Ниже — хлебная крошка «← к списку», название теста, автор, дата обновления, **жёлтый баннер «При сохранении будет создана новая версия теста.»** и схлопнутый аккордеон «О тесте». |
||||||
|
|
||||||
|
Замечания: |
||||||
|
|
||||||
|
- **C-1 [critical] Баннер о новой версии показывается ВСЕГДА**, независимо от того, изменил ли пользователь хоть что-то. Это сбивает с толку: автор открывает существующий тест, ничего не трогает — и думает, что версия уже создана. Должен показываться только при наличии несохранённых изменений (dirty state). |
||||||
|
- **m-2 [minor] Роль `employee` написана по-английски** в шапке. В русском интерфейсе должно быть «сотрудник» / «автор» / другое (см. ролевую модель в Части 2). |
||||||
|
- **m-3 [minor] Опечатка** в `<title>` вкладки: «Система тестирования» (нет «и»). Влияет на закладки, историю, поиск. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.2. Секция «О тесте» |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Замечания: |
||||||
|
|
||||||
|
- **M-1 [major] Аккордеон по умолчанию схлопнут.** Чтобы начать редактировать главный объект страницы (вопросы), нужно сделать лишний клик. На странице редактирования теста раздел «Вопросы» (а возможно, и «О тесте») должен быть открыт по умолчанию. |
||||||
|
- **M-2 [major] Поле «Порог зачёта, %» не имеет валидации min/max.** Что произойдёт при вводе 0, 100, 150, –5, 70.5, или вообще буквы? Минимум: подсказка «от 1 до 100», атрибуты `min/max/step` на input, инлайн-ошибка при некорректном вводе. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.3. Раздел «Вопросы» — генерация и Вопрос 1 |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Что мы видим: блок **«Генерация сетки вопросов (ИИ)»** с полями «Тема», «Вопросов: 7», «Вариантов: 3» и кнопкой «Сгенерировать тест (ИИ)». Ниже — Вопрос 1 с собственной кнопкой «Сгенерировать вопрос (ИИ)» в правом верхнем углу, чекбоксом «Несколько верных ответов», тремя вариантами с радиокнопками и крестиками «удалить». |
||||||
|
|
||||||
|
Замечания: |
||||||
|
|
||||||
|
- **C-2 [critical] ИИ-генерация без подтверждения и без отображения хода работы.** Кнопка «Сгенерировать тест (ИИ)» одной нажатием перезаписывает существующие вопросы — а они уже могут быть наполовину написаны вручную. То же касается кнопки «Сгенерировать вопрос (ИИ)» рядом с уже заполненным вопросом. |
||||||
|
- Нужно: confirm-диалог «Заменить текущие N вопросов?», индикатор прогресса генерации, возможность откатить (undo) последний результат генерации. |
||||||
|
- **M-3 [major] Чекбокс «Несколько верных ответов» меняет семантику варианта без явного намёка.** Когда выкл — радиокнопки (один верный), когда вкл — должны стать чекбоксами (несколько). Лучше переписать подпись в зависимости от состояния: «один верный» / «несколько верных», и/или показать рядом подсказку, как изменится контрол. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.4. Вопросы 3–5 |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Замечания: |
||||||
|
|
||||||
|
- **M-4 [major] Нет нумерации/перетаскивания вопросов.** «Вопрос 1, 2, 3…» — порядок фиксирован тем, в каком порядке добавляли. Для длинных тестов нужен drag-handle или хотя бы стрелки «вверх / вниз». |
||||||
|
- **M-5 [major] «Удалить вопрос» без подтверждения.** Случайный клик уничтожит написанный вопрос. Минимум — confirm-диалог; лучше — undo-toast «Вопрос удалён · Отменить». |
||||||
|
- **m-4 [minor] Маленькая видимая «вода» между вопросами.** Карточки вопросов мало отделены друг от друга визуально, при пролистывании они сливаются в стену форм. Стоит увеличить вертикальный отступ между карточками или добавить разделитель. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.5. Вопрос 7 — обрыв длинного варианта |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Это один из самых наглядных багов: |
||||||
|
|
||||||
|
- **M-6 [major] Длинный текст варианта обрезается.** В Q7 первый вариант отображается как «Максимальное количество токенов, которое модель может о…» — текст уходит за правый край однострочного `<input>`. Для содержательных тестов (особенно медицинских) ответы часто длинные. Нужно: либо `<textarea>` с автовысотой, либо горизонтальный скролл с tooltip всего текста на ховере. |
||||||
|
- **m-5 [minor] Загрузка файла «Документ в вопросы» — без drag-and-drop, без ограничений по размеру/формату на UI, без обратной связи.** Подсказка «PDF, Word или текст — вставьте в черновик вопросов» — хорошая по-человечески, но не объясняет, что произойдёт после загрузки: заменит ли существующие вопросы, добавит ли в конец, есть ли превью результата. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.6. Кнопка «Сохранить черновик» в середине + История |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Здесь главная архитектурная проблема страницы: |
||||||
|
|
||||||
|
- **C-3 [critical] Кнопка «Сохранить черновик» расположена в середине страницы.** Сразу после неё ниже идут ещё две большие секции — «История» и «Показ в каталоге». Пользователь, открывший «Показ в каталоге» и поменявший там аудиторию, психологически ищет «Сохранить» внизу страницы — но там его нет. Очень высокий риск потерять изменения. |
||||||
|
- Решения, любое или все: (а) sticky-панель сохранения внизу страницы; (б) дубль кнопки после последней секции; (в) автосохранение черновика; (г) предупреждение перед уходом со страницы при наличии несохранённых изменений. |
||||||
|
- **M-7 [major] Раздел «Прохождения» показывает сырые ENUM-значения.** Видно `v1 · in_progress` — это техническое значение, а не пользовательский текст. Должно быть «в процессе» / «пройдено» / «не пройдено», лучше с цветной плашкой-индикатором. |
||||||
|
- **M-8 [major] Дубль кнопки «К списку».** Хлебная крошка «← к списку» наверху + кнопка «К списку» рядом с «Сохранить черновик» — две точки выхода с разным визуальным весом. Кнопка справа от primary-кнопки создаёт ложное ощущение симметричности с действием. Оставить либо крошку, либо превратить вторую кнопку в текстовую ссылку «Отмена». |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.7. «Показ в каталоге» — Видимость и фильтры |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
- **M-9 [major] Контрол «Видимость» неясен по текущему состоянию.** Кнопка «Скрыть из списка» — это сейчас действие или текущее состояние? Если тест уже скрыт — кнопка должна называться «Показать в списке». Лучше — переключатель (toggle/switch) с подписью «Тест виден в каталоге», чтобы текущее состояние читалось без действий. |
||||||
|
- **m-6 [minor] Поле поиска и два селекта** «Все отделы» / «Все» расположены без подписей — что делает второй селект, без раскрытия не понятно. Нужны явные label или persistent placeholder. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.8. Список «Кому выдать» — 147 сотрудников |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
Этот блок — корень главной IA-проблемы (см. Часть 2): |
||||||
|
|
||||||
|
- **C-4 [critical] Назначение тестов не должно жить на странице теста.** Это управленческая задача отдельной роли (HR-менеджер, руководитель отделения), а не авторская. Подробно — в Часть 2. |
||||||
|
- **M-10 [major] Список из 147 человек без виртуализации и счётчика выбранных.** Нужно как минимум: счётчик «выбрано N из 147», фильтр «только выбранные», сохранение выбранного при изменении фильтра, виртуальный скролл (на 1000+ сотрудников страница встанет колом). |
||||||
|
- **M-11 [major] «Назначить выбранных» внутри контейнера списка.** Кнопка стоит на нижней границе скролл-контейнера — её очень легко не заметить. И непонятно: «Назначить» — это отдельное действие или часть общего «Сохранить черновик» наверху? |
||||||
|
- **m-7 [minor] Подпись «нет учётки (создадим при назначении)»** — хорошая идея (ленивая выдача учёток), но требует пояснения: что значит «при назначении», что получит сотрудник после, как ему придёт первый пароль. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1.9. Сводная таблица замечаний |
||||||
|
|
||||||
|
| ID | Приоритет | Что | Место | |
||||||
|
|---|---|---|---| |
||||||
|
| C-1 | critical | Баннер «новая версия» виден всегда, не только при изменениях | 1.1 | |
||||||
|
| C-2 | critical | ИИ-генерация без confirm и без прогресса | 1.3 | |
||||||
|
| C-3 | critical | Кнопка «Сохранить» в середине страницы | 1.6 | |
||||||
|
| C-4 | critical | Назначение сотрудников не должно жить на странице теста | 1.8 | |
||||||
|
| M-1 | major | Аккордеоны схлопнуты по умолчанию, включая «Вопросы» | 1.2 | |
||||||
|
| M-2 | major | «Порог зачёта» без валидации min/max | 1.2 | |
||||||
|
| M-3 | major | Чекбокс «Несколько верных» меняет семантику без подсказки | 1.3 | |
||||||
|
| M-4 | major | Нет переупорядочивания вопросов | 1.4 | |
||||||
|
| M-5 | major | «Удалить вопрос» без подтверждения и undo | 1.4 | |
||||||
|
| M-6 | major | Длинный текст варианта ответа обрезается | 1.5 | |
||||||
|
| M-7 | major | Сырые ENUM-значения в статусах прохождений | 1.6 | |
||||||
|
| M-8 | major | Дубль точек выхода («← к списку» + «К списку») | 1.6 | |
||||||
|
| M-9 | major | Контрол «Видимость» неясен по состоянию | 1.7 | |
||||||
|
| M-10 | major | Список 147 сотрудников без виртуализации/счётчиков | 1.8 | |
||||||
|
| M-11 | major | «Назначить выбранных» теряется внутри контейнера | 1.8 | |
||||||
|
| m-1 | minor | Логотип на странице логина обрезан | вне скрина | |
||||||
|
| m-2 | minor | Роль `employee` латиницей в шапке | 1.1 | |
||||||
|
| m-3 | minor | Опечатка «тестирования» в `<title>` | 1.1 | |
||||||
|
| m-4 | minor | Карточки вопросов слабо отделены друг от друга | 1.4 | |
||||||
|
| m-5 | minor | Загрузка файла без drag-and-drop и описания результата | 1.5 | |
||||||
|
| m-6 | minor | Селекты в фильтрах без явных label | 1.7 | |
||||||
|
| m-7 | minor | «Нет учётки (создадим при назначении)» — нужно пояснение | 1.8 | |
||||||
|
|
||||||
|
Не проверено и стоит протестировать отдельно: валидация при сохранении пустого вопроса/вариантов, мобильная вёрстка, клавиатурная навигация и focus ring, контрастность по WCAG 2.2, поведение под другими ролями (руководитель, HR, директор). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
# Часть 2. Предлагаемая новая IA |
||||||
|
|
||||||
|
## 2.1. Что не так с текущей IA |
||||||
|
|
||||||
|
Сейчас одна страница `/tests/{id}` решает три разные задачи разных ролей: |
||||||
|
|
||||||
|
| Задача | Кто делает | Как часто | Какие данные | |
||||||
|
|---|---|---|---| |
||||||
|
| Сочинить тест | автор / методолог | один раз при создании, далее редко | вопросы, варианты, порог | |
||||||
|
| Назначить кому проходить | автор (иногда) или HR / руководитель | каждый раз для нового сотрудника или потока | список из 100–10 000 сотрудников, фильтры | |
||||||
|
| Посмотреть кто прошёл | руководитель / HR / директор | регулярно | результаты, динамика, агрегаты | |
||||||
|
|
||||||
|
Это три разных пользовательских ритма, три разных набора фильтров, три разных уровня доступа. Складывать их в один аккордеон — экономия на маршрутизации и проигрыш во всём остальном (см. C-3, C-4, M-9, M-10, M-11). |
||||||
|
|
||||||
|
## 2.2. Карта разделов после редизайна |
||||||
|
|
||||||
|
``` |
||||||
|
HR system (модуль «Тестирование») |
||||||
|
│ |
||||||
|
├── Главная / Дашборд |
||||||
|
│ сводка: «назначено N тестов, X% прошли, Y просрочены» |
||||||
|
│ (вид зависит от роли — см. 2.4) |
||||||
|
│ |
||||||
|
├── Тесты |
||||||
|
│ ├── Каталог тестов ← список, поиск, фильтры |
||||||
|
│ ├── Создать тест ← минимальный wizard: название → пустой черновик |
||||||
|
│ └── /tests/{id} ← страница теста |
||||||
|
│ ├── Просмотр ← все, у кого есть доступ |
||||||
|
│ │ краткая сводка прохождений (89 / 147, средний 6.2/7) |
||||||
|
│ │ кнопка «Назначить» (открывает модалку из 2.3) |
||||||
|
│ │ кнопка «Редактировать» (если есть права) |
||||||
|
│ └── Редактирование ← только автор / методолог |
||||||
|
│ ├── О тесте |
||||||
|
│ ├── Вопросы |
||||||
|
│ └── Версии теста ← (вместо «История» — только версии) |
||||||
|
│ |
||||||
|
├── Назначения ← новый раздел |
||||||
|
│ ├── Список назначений ← таблица «тест × сотрудник × срок × статус» |
||||||
|
│ ├── Создать назначение ← массовый wizard (см. 2.3) |
||||||
|
│ └── /assignments/{id} ← страница назначения, где можно отозвать, |
||||||
|
│ продлить срок, посмотреть прогресс |
||||||
|
│ |
||||||
|
├── Отчёты ← новый раздел |
||||||
|
│ ├── По тесту ← кто прошёл, средний балл, кривые |
||||||
|
│ ├── По сотруднику ← все тесты сотрудника, история |
||||||
|
│ └── По отделу ← агрегаты для руководителей |
||||||
|
│ |
||||||
|
├── Сотрудники ← справочник, синхронизация с кадрами |
||||||
|
│ |
||||||
|
└── Настройки ← роли, подразделения, шаблоны уведомлений |
||||||
|
``` |
||||||
|
|
||||||
|
## 2.3. Сценарий «Назначить тест» через модалку |
||||||
|
|
||||||
|
Поскольку автор иногда сам назначает тест, а иногда передаёт это HR/руководителю, кнопка «Назначить» нужна **в двух местах**: |
||||||
|
|
||||||
|
- На странице теста (для автора, который сразу выдаёт тест). |
||||||
|
- В разделе «Назначения → Создать» (для HR/руководителя, который отбирает аудиторию массово). |
||||||
|
|
||||||
|
Обе точки открывают **одну и ту же модалку / визард** с шагами: |
||||||
|
|
||||||
|
1. **Кому.** Сначала фильтры по отделу/должности → одной кнопкой «Все из отделения хирургии (38)» или вручную чекбоксами. Сохранение выбранного при смене фильтра. Виртуализированный список. |
||||||
|
2. **Когда.** Дедлайн, опционально дата старта (например, новый сотрудник получает тест на 3-й рабочий день). |
||||||
|
3. **Параметры.** Сколько попыток допустимо, нужен ли пересдача после неуспеха, кому уведомления о результате. |
||||||
|
4. **Подтверждение.** «Назначить тест „Введение про LLM v1“ 38 сотрудникам отделения хирургии до 15 мая 2026 — назначить?» |
||||||
|
|
||||||
|
После назначения автор/HR попадает на страницу созданного назначения, где видит прогресс: кто открыл, кто проходит, кто завершил. |
||||||
|
|
||||||
|
## 2.4. Ролевая модель и матрица доступа |
||||||
|
|
||||||
|
Четыре роли из ваших пояснений: **сотрудник**, **руководитель подразделения**, **HR-менеджер**, **директор**. Плюс отдельно — **методолог/автор**, которая может присваиваться поверх любой из роли (директор, HR или руководитель могут также быть авторами). |
||||||
|
|
||||||
|
| Раздел / действие | Сотрудник | Рук. подр. | HR | Директор | Автор | |
||||||
|
|---|---|---|---|---|---| |
||||||
|
| Главная | свои назначения | свой отдел | вся клиника | вся клиника | свои тесты | |
||||||
|
| Каталог тестов — просмотр | да (только видимые) | да | да | да | да | |
||||||
|
| Создать тест | — | — | да | да | да | |
||||||
|
| Редактировать тест | — | — | (свои) | да | свои | |
||||||
|
| Опубликовать новую версию | — | — | (свои) | да | свои | |
||||||
|
| Удалить/архивировать тест | — | — | (свои) | да | свои | |
||||||
|
| Назначить тест | — | свой отдел | вся клиника | вся клиника | (если сам назначает) | |
||||||
|
| Отозвать назначение | — | свои | свои + HR-уровня | все | свои | |
||||||
|
| Отчёты по сотруднику | свои | подчинённые | все | все | свои тесты | |
||||||
|
| Отчёты по отделу | — | свой отдел | все | все | — | |
||||||
|
| Настройки ролей | — | — | да | да | — | |
||||||
|
|
||||||
|
«—» — действие не доступно. Точные границы (например, может ли HR редактировать чужой тест) уточняются на этапе требований. |
||||||
|
|
||||||
|
## 2.5. Жизненный цикл версии теста и поведение при активных прохождениях |
||||||
|
|
||||||
|
Версионирование уже сделано правильно — оно фиксирует, какую именно версию проходил сотрудник, и не ломает прошлые результаты. Но в UI нужно явно показать состояния и поведение при апдейте. |
||||||
|
|
||||||
|
``` |
||||||
|
┌──────────┐ |
||||||
|
│ Черновик │ ← автор может править свободно, |
||||||
|
└────┬─────┘ назначения нельзя выдать |
||||||
|
│ |
||||||
|
«Опубликовать как v2» |
||||||
|
│ |
||||||
|
▼ |
||||||
|
┌──────────┐ |
||||||
|
│ Активная │ ← новые назначения идут на эту версию; |
||||||
|
└────┬─────┘ уже идущие прохождения остаются на старой |
||||||
|
│ |
||||||
|
«Опубликовать как v3» |
||||||
|
│ |
||||||
|
▼ |
||||||
|
┌──────────┐ |
||||||
|
│ Архив │ ← новые назначения нельзя; старые |
||||||
|
└──────────┘ прохождения видны в отчётах |
||||||
|
``` |
||||||
|
|
||||||
|
Что должно быть видно в UI: |
||||||
|
|
||||||
|
- **Бейдж версии** рядом с названием теста: `Введение про LLM · v2 (активна)`. |
||||||
|
- **На странице редактирования** — явно: «Редактируется черновик v3 на основе активной v2». |
||||||
|
- **При публикации новой версии** — диалог: «Сейчас тест проходят 12 сотрудников на v2. Они закончат на v2; новые назначения пойдут на v3. Опубликовать v3?» |
||||||
|
- **В отчётах** — фильтр по версии теста. |
||||||
|
- **В назначении** — версия зафиксирована: «Назначен на тесте Введение про LLM v2». |
||||||
|
|
||||||
|
## 2.6. Состояние «черновик» страницы теста |
||||||
|
|
||||||
|
Сейчас единственная кнопка — «Сохранить черновик». Лучше добавить два глагола: |
||||||
|
|
||||||
|
- **«Сохранить черновик»** — сохранить промежуточно, не публиковать. Не создаёт новой версии. |
||||||
|
- **«Опубликовать как новую версию»** — фиксирует версию, делает её активной, открывает диалог из 2.5. |
||||||
|
|
||||||
|
Тогда жёлтый баннер из C-1 превращается в осмысленную подсказку: он показывается **только при наличии несохранённых изменений** и говорит «Чтобы изменения попали в назначения — опубликуйте новую версию». |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
# Часть 3. Чеклист изменений |
||||||
|
|
||||||
|
Разбит на три волны по приоритету и независимости работ. |
||||||
|
|
||||||
|
## Волна 1 — быстрые правки на текущей странице (1–2 спринта) |
||||||
|
|
||||||
|
Не требуют структурных изменений, можно делать параллельно с разработкой новой IA: |
||||||
|
|
||||||
|
- [ ] **C-1** Скрыть баннер версионирования при отсутствии изменений (dirty state). |
||||||
|
- [ ] **C-2** Confirm-диалог + прогресс для ИИ-генерации, undo последнего результата. |
||||||
|
- [ ] **C-3** Sticky-панель «Сохранить» внизу + предупреждение `beforeunload` при unsaved changes. |
||||||
|
- [ ] **M-1** Аккордеон «Вопросы» открыт по умолчанию. |
||||||
|
- [ ] **M-2** Валидация порога зачёта (1–100, целое число). |
||||||
|
- [ ] **M-3** Поясняющий текст для «Несколько верных ответов». |
||||||
|
- [ ] **M-5** Confirm + undo для «Удалить вопрос». |
||||||
|
- [ ] **M-6** Длинные варианты — `textarea` с автовысотой. |
||||||
|
- [ ] **M-7** Перевод ENUM-значений статусов прохождения. |
||||||
|
- [ ] **M-9** Toggle-switch для «Видимость» вместо одной кнопки. |
||||||
|
- [ ] **m-1, m-2, m-3** Косметика: логотип логина, роль, опечатка title. |
||||||
|
|
||||||
|
## Волна 2 — выделение разделов (новая IA) |
||||||
|
|
||||||
|
- [ ] Выделить раздел «Назначения» с собственной таблицей и фильтрами. |
||||||
|
- [ ] Перенести «Кому выдать» со страницы теста в модалку «Назначить» из 2.3. |
||||||
|
- [ ] Выделить раздел «Отчёты» из секции «История», расширить фильтрами и агрегатами. |
||||||
|
- [ ] Реализовать ролевую модель из 2.4 (RBAC): меню, разделы и действия зависят от роли. |
||||||
|
- [ ] Реализовать жизненный цикл версии (2.5) и явную публикацию. |
||||||
|
|
||||||
|
## Волна 3 — масштабирование и качество |
||||||
|
|
||||||
|
- [ ] Виртуализация списков сотрудников (поддержка 5 000+). |
||||||
|
- [ ] Drag-and-drop для перестановки вопросов (M-4). |
||||||
|
- [ ] Drag-and-drop загрузка файла с превью результата (m-5). |
||||||
|
- [ ] Аудит доступности (WCAG 2.2 AA): клавиатурная навигация, focus-ring, контрастность. |
||||||
|
- [ ] Адаптивная вёрстка для мобильных и планшетов. |
||||||
|
- [ ] Уведомления (e-mail, в системе) для назначений, дедлайнов, результатов. |
||||||
|
- [ ] Связка с курсами/треками (когда появятся). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
# Что дальше |
||||||
|
|
||||||
|
После согласования этого документа имеет смысл: |
||||||
|
|
||||||
|
1. Сделать кликабельный прототип в Figma на 2 ключевых сценария: «автор создаёт и сразу назначает тест», «HR назначает существующий тест 200 сотрудникам». Это покажет, как именно ложится новая IA на реальные действия и где остались дыры. |
||||||
|
2. Прогнать прототип на 2–3 пользователях каждой роли (автор, HR, руководитель) — модерируемое юзабилити-тестирование на 30–40 минут. По итогам — финальные правки до старта разработки. |
||||||
|
3. Параллельно запустить Волну 1 — она независима от IA и сразу снимает большую часть пользовательской боли. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
*Если по какому-то пункту нужно больше деталей или хочется, чтобы я подготовил визард-сценарий «Назначить» в виде последовательных макетов — скажите.* |
||||||
@ -0,0 +1,220 @@ |
|||||||
|
# Спринты по bugfix-задачам (Тесты / Доступ / Генерация) |
||||||
|
|
||||||
|
Дата: 2026-04-30 |
||||||
|
Контур: `flask_app` (UI + API + сервисы генерации) |
||||||
|
|
||||||
|
## Цели пакета |
||||||
|
|
||||||
|
1. Дать доступ всем авторизованным пользователям ко всем активным тестам (без назначений). |
||||||
|
2. Привести поведение шаблона генерации теста к ожидаемому (кол-во вопросов/вариантов/правильных ответов). |
||||||
|
3. Добавить массовые настройки «несколько вариантов ответа». |
||||||
|
4. Улучшить поток работы с подсказками и прозрачность прогресса генерации. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 1 — Доступ всем авторизованным (без назначений) |
||||||
|
|
||||||
|
### Объём |
||||||
|
|
||||||
|
- Убрать зависимость прохождения теста от назначений (`TestAssignment*`). |
||||||
|
- Разрешить доступ к активным тестам для любого авторизованного пользователя. |
||||||
|
- Проверить, что каталог и старт попытки работают консистентно с новой политикой. |
||||||
|
|
||||||
|
### Задачи |
||||||
|
|
||||||
|
1. **Политика доступа** |
||||||
|
- В `user_has_test_access` вернуть `ok=True` для любого активного теста, если тест существует и пользователь авторизован. |
||||||
|
- Оставить проверки авторства только там, где они нужны для редактора/версий/админских действий. |
||||||
|
|
||||||
|
2. **Проверка API прохождения** |
||||||
|
- `start_attempt`, `play`, `submit`, `review` не должны требовать назначения на пользователя. |
||||||
|
- Ошибка «Доступ запрещён» не возникает для обычного авторизованного сотрудника при прохождении активного теста. |
||||||
|
|
||||||
|
### Критерии приёмки |
||||||
|
|
||||||
|
- Любой авторизованный пользователь может открыть и пройти любой активный тест, даже без назначения. |
||||||
|
- При этом операции автора (редактор, версии, массовые действия) остаются ограничены автором. |
||||||
|
|
||||||
|
### Оценка |
||||||
|
|
||||||
|
- 0.5 дня. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 2 — Шаблон генерации: контракт и предсказуемость |
||||||
|
|
||||||
|
### Проблема |
||||||
|
|
||||||
|
Сейчас пользователь создаёт шаблон (например, 12×4), затем генерирует из документа и получает иной результат (например, 10×3). Ожидания и фактический контракт не совпадают. |
||||||
|
|
||||||
|
### Объём |
||||||
|
|
||||||
|
- Зафиксировать единый контракт: какие параметры шаблона обязательны для генератора. |
||||||
|
- Принудительно соблюдать: |
||||||
|
- количество вопросов, |
||||||
|
- количество вариантов в вопросе, |
||||||
|
- границы количества правильных ответов. |
||||||
|
- Добавить валидацию и пост-проверку результата генерации. |
||||||
|
|
||||||
|
### Задачи |
||||||
|
|
||||||
|
1. **Формальный контракт шаблона** |
||||||
|
- Явно определить обязательные поля shape: |
||||||
|
- `questions_count` |
||||||
|
- `options_per_question` |
||||||
|
- `multiple_answers_default` |
||||||
|
- `correct_answers_min/max` |
||||||
|
- Хранить shape вместе с тестом/версией как источник истины. |
||||||
|
|
||||||
|
2. **Генерация по документу с shape** |
||||||
|
- Передавать shape в генератор при `generate from document`. |
||||||
|
- После генерации валидировать фактическую структуру. |
||||||
|
- При расхождении: |
||||||
|
- либо автоматически нормализовать (добить/сжать до нужного формата), |
||||||
|
- либо показать понятную ошибку и не сохранять черновик. |
||||||
|
|
||||||
|
3. **Пользовательская обратная связь** |
||||||
|
- На экране до запуска показывать «Будет сгенерировано: 12 вопросов, 4 варианта». |
||||||
|
- После завершения показывать фактический итог и предупреждение, если пришлось авто-нормализовать. |
||||||
|
|
||||||
|
### Критерии приёмки |
||||||
|
|
||||||
|
- Для шаблона 12×4 итоговый тест всегда 12 вопросов по 4 варианта. |
||||||
|
- Несоответствие не проходит «тихо»: либо авто-исправление с уведомлением, либо явная ошибка. |
||||||
|
|
||||||
|
### Оценка |
||||||
|
|
||||||
|
- 1.5–2.5 дня. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 3 — Массовый контроль «Несколько вариантов ответа» |
||||||
|
|
||||||
|
### Объём |
||||||
|
|
||||||
|
- Добавить глобальный чекбокс «Несколько вариантов ответа» (для всех вопросов). |
||||||
|
- В шаблоне добавить диапазон правильных ответов: `от _ до _`. |
||||||
|
- При отключении мультивыбора на конкретном вопросе нижняя граница «от» фиксируется в `1`. |
||||||
|
|
||||||
|
### Задачи |
||||||
|
|
||||||
|
1. **UI шаблона** |
||||||
|
- Глобальный switch/checkbox: «Несколько вариантов ответа для всех вопросов». |
||||||
|
- Поля диапазона: |
||||||
|
- `Мин. правильных` (от), |
||||||
|
- `Макс. правильных` (до), |
||||||
|
- валидация `1 <= min <= max <= options_per_question`. |
||||||
|
|
||||||
|
2. **Применение к вопросам** |
||||||
|
- При включении глобального флага обновлять все вопросы: |
||||||
|
- `hasMultipleAnswers=true`. |
||||||
|
- При выключении: |
||||||
|
- `hasMultipleAnswers=false`, |
||||||
|
- `minCorrect=1`, `maxCorrect=1`. |
||||||
|
- На уровне отдельного вопроса разрешить override. |
||||||
|
|
||||||
|
3. **Правило «заморозки min=1»** |
||||||
|
- Если на вопросе `hasMultipleAnswers=false`, то: |
||||||
|
- `minCorrect` автоматически = `1`, |
||||||
|
- поле `minCorrect` read-only/disabled. |
||||||
|
|
||||||
|
4. **Серверная валидация** |
||||||
|
- API сохраняет/проверяет тот же инвариант. |
||||||
|
- Невалидные комбинации отклоняются с понятным сообщением. |
||||||
|
|
||||||
|
### Критерии приёмки |
||||||
|
|
||||||
|
- Глобальный флаг влияет на все вопросы. |
||||||
|
- Локальное отключение мультивыбора фиксирует `min=1`. |
||||||
|
- Генератор и редактор работают в одинаковой логике, без рассинхрона. |
||||||
|
|
||||||
|
### Оценка |
||||||
|
|
||||||
|
- 1.5–2 дня. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 4 — Подсказки и прогресс генерации |
||||||
|
|
||||||
|
### Объём |
||||||
|
|
||||||
|
- В параметрах теста при включённых подсказках показать действие «Сгенерировать подсказки». |
||||||
|
- Генерация подсказок работает только по заполненным вопросам. |
||||||
|
- Если тест генерируется с нуля и подсказки уже включены — подсказки генерируются в том же пайплайне. |
||||||
|
- Добавить прогресс по этапам генерации + локальные индикаторы загрузки в соответствующих блоках UI. |
||||||
|
|
||||||
|
### Задачи |
||||||
|
|
||||||
|
1. **Кнопка/ссылка «Сгенерировать подсказки»** |
||||||
|
- Показ только при `hintsEnabled=true`. |
||||||
|
- Показ количества: `N без подсказок`. |
||||||
|
- Запуск только по вопросам, где есть текст + варианты. |
||||||
|
|
||||||
|
2. **Пайплайн генерации «с нуля»** |
||||||
|
- Если `hintsEnabled=true`, после генерации вопросов автоматически запускать генерацию подсказок. |
||||||
|
- Ошибки подсказок не должны ломать весь тест: частичный результат допустим с отчётом. |
||||||
|
|
||||||
|
3. **Прогресс и статусы** |
||||||
|
- Этапы: |
||||||
|
- подготовка документа, |
||||||
|
- извлечение текста, |
||||||
|
- генерация структуры, |
||||||
|
- генерация вопросов, |
||||||
|
- генерация подсказок, |
||||||
|
- финализация. |
||||||
|
- В UI показывать текущий этап и процент/счётчик. |
||||||
|
- Спиннеры показывать только у активного блока (не глобально на всю форму). |
||||||
|
|
||||||
|
4. **Наблюдаемость** |
||||||
|
- Логи этапов и длительности. |
||||||
|
- В ответ API возвращать breakdown по шагам/ошибкам. |
||||||
|
|
||||||
|
### Критерии приёмки |
||||||
|
|
||||||
|
- Пользователь видит, что именно сейчас генерируется. |
||||||
|
- Подсказки отдельно запускаются и генерируются только для валидных вопросов. |
||||||
|
- При генерации «с нуля» с включёнными подсказками подсказки появляются автоматически. |
||||||
|
|
||||||
|
### Оценка |
||||||
|
|
||||||
|
- 2–3 дня. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Спринт 5 — Регрессия, UX-полировка, выпуск |
||||||
|
|
||||||
|
### Объём |
||||||
|
|
||||||
|
- Сквозное тестирование сценариев. |
||||||
|
- Документация для пользователей и команды. |
||||||
|
- Подготовка релиз-нота. |
||||||
|
|
||||||
|
### Тест-кейсы (минимум) |
||||||
|
|
||||||
|
1. Неавторизованный пользователь открывает приватные URL → редирект на логин. |
||||||
|
2. Каталог тестов: есть переход «На главную». |
||||||
|
3. Шаблон 12×4 + генерация из PDF → на выходе 12×4. |
||||||
|
4. Глобальный мультивыбор + локальное выключение на 1 вопросе → `min=1` на этом вопросе. |
||||||
|
5. Включены подсказки: |
||||||
|
- кнопка «Сгенерировать подсказки» доступна, |
||||||
|
- генерируются только по заполненным вопросам. |
||||||
|
6. Генерация с нуля + подсказки включены → подсказки сгенерированы в том же запуске. |
||||||
|
7. Прогресс этапов отображается корректно, загрузка локальная. |
||||||
|
|
||||||
|
### Оценка |
||||||
|
|
||||||
|
- 1–1.5 дня. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Приоритеты |
||||||
|
|
||||||
|
- **P0:** Спринт 1 (доступ всем авторизованным), критичный функциональный bugfix. |
||||||
|
- **P1:** Спринт 2 (контракт шаблона), устранение основного функционального несоответствия. |
||||||
|
- **P1:** Спринт 3 (массовый мультивыбор), важная продуктовая логика. |
||||||
|
- **P2:** Спринт 4 (подсказки + прогресс), прозрачность и удобство. |
||||||
|
- **P2:** Спринт 5 (регрессия + выпуск). |
||||||
|
|
||||||
|
## Суммарная оценка |
||||||
|
|
||||||
|
Ориентир: **6.5–10 рабочих дней** (в зависимости от объёма автотестов и глубины рефакторинга генератора). |
||||||
@ -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 Flask (порт 3107) | `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`. Бизнес-логика **не** добавлялась. | |
||||||
@ -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) | |
||||||
@ -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). Это вспомогательный документ, не часть этапов миграции. |
||||||
@ -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`, порт **3107**). Может быть медленным по тем же причинам (БД, шаблоны, отсутствие кэша статики), но **это не обязательно тот же инстанс**, что видят пользователи в проде. | |
||||||
|
|
||||||
|
Связанные по смыслу документы (миграция данных, две БД): |
||||||
|
|
||||||
|
- `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` на **3107** — проверять отдельно, если пользователи реально ходят туда. |
||||||
|
|
||||||
|
Перед оптимизацией **уточнить 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:3107 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. Задокументировано **узкое место** и **что изменено** (1–2 абзаца в changelog или в этом файле внизу секция «Итог»). |
||||||
|
3. Для пользовательского сценария выполняется согласованный **SLO** (например: TTFB p95 < X ms, полная загрузка ключевой страницы < 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 (по умолчанию 3107 legacy) | `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/коммиты.* |
||||||
@ -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)** — дашборды. |
||||||
|
**Этапы 3–5** — интеграция в общий 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.1–V.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.1–C.3.* |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## D. Импорт из документа |
||||||
|
|
||||||
|
См. [card1 D.1–D.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 | |
||||||
@ -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** (создание файла).* |
||||||
@ -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.6–V.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.2–D.5**; **V.9** E2E; углубление V.8 (назначения / «мои тесты») по card1. |
||||||
@ -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.1–V.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.1–A.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). |
||||||
@ -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 Негатив |
||||||
|
|
||||||
|
- [ ] Попытка не может «сломать» цепочку (ошибки пользователю понятны) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
@ -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) |
||||||
@ -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:** дата __________, комментарий _________________________ |
||||||
@ -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`. |
||||||
@ -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. **Этапы 3–5** — приёмка проводится совместно с командой большой HR-системы, критерии и сценарии определяются в начале каждого этапа. |
||||||
|
|
||||||
|
Подробные чек-листы тестирования для каждой функции готовятся перед стартом соответствующего этапа и ведутся в отдельных документах в папке DOC/. |
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
@ -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. Список тестов |
||||||
|
|
||||||
|
Все тесты, к которым у вас есть доступ. **Название** (слева) ведёт в **редактирование** или просмотр, **«Пройти»** (справа) — **сдать** тест, если вам тест **назначили** или открыт самопроход. Редактирование — не у всех ролей. |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 2. О тесте |
||||||
|
|
||||||
|
Название, **описание** для коллег, **порог зачёта** (%). «Паспорт» теста: **что проверяете** и **с какой планкой** зачёт/незачёт. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 3. Вопросы |
||||||
|
|
||||||
|
**Вопросы и варианты** пишите здесь. Слева от варианта — **верные** отметки: один вариант как контрольный; несколько верных — чекбокс **«Несколько верных ответов»**. |
||||||
|
|
||||||
|
- **+ вопрос** / **+ вариант** — добавить. **Крестик** у варианта — убрать лишний ответ. |
||||||
|
- **Документ в вопросы** — при необходимости загрузить файл (PDF, Word, текст) и вставить в черновик; не обязательно, если ввели всё вручную. |
||||||
|
- **ИИ** (если включён) — подсказка, не готовый клинический документ: **проверьте и исправьте** перед публикацией. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 4. Сохранить |
||||||
|
|
||||||
|
**«Сохранить черновик»** (часто **внизу** на телефоне) — чтобы не потерять правки. **«К списку»** — выход; если **уже сохранялись** — данные в черновике записаны. |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 5. История |
||||||
|
|
||||||
|
- **Версии** — когда и как менялся тест. Актуальная отмечена. **«Сделать активной»** — редко, обычно согласуя с IT/методистом. |
||||||
|
- **Прохождения** — кто уже **сдавал**; **«Разбор»** — ответы по вопросам (если вам **открыт** доступ). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
### 6. Показ в каталоге |
||||||
|
|
||||||
|
- **Видимость** — показать в **общем** списке тестов или **скрыть** (тест **не** удаляется, просто **не** светится в ленте). «Старые» **ссылки** у кого-то **могут** ещё открываться, если **переадресовали** вручную. |
||||||
|
- **Кому выдать** (если раздел есть) — **назначение** сотрудникам: **поиск, фильтры, галочки**; **«Выбрать всех»** — только в **текущем** отфильтрованном списке; затем **«Назначить выбранных»**. Это **про людей**, не про редактуру вопросов. |
||||||
|
|
||||||
|
 |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
**В одном движении:** написали **вопросы** → **«Сохранить»** → при **необходимости** **показали** в списке и/или **кому-то** **выдали** тест. Остальное — **по ситуации**. |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
# Рекомендации UX по экранам редактирования теста |
||||||
|
|
||||||
|
*Основание: скриншоты в `docs/screens`, словарь `docs/Словарь UX-UI-IA терминов.md`. Дата фиксации: 29.04.2026.* |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Навигация и IA |
||||||
|
|
||||||
|
- **Хлебные крошки.** Сейчас только «← к списку». Имеет смысл добавить полную цепочку вроде «Тесты → Введение про LLM → Редактирование», чтобы снизить когнитивную нагрузку и отразить иерархию сущностей (Тест → Версия). |
||||||
|
- **Якоря по длинной странице.** Блоки «О тесте», «Вопросы», «История», «Показ в каталоге» образуют длинную вертикаль. Полезны боковое оглавление или «прыжки» по разделам / закреплённая поднавигация внутри страницы теста, чтобы не терять контекст при работе с нижними вопросами и назначением. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Состояния интерфейса и обратная связь |
||||||
|
|
||||||
|
- **ИИ-кнопки.** Для «Сгенерировать тест (ИИ)» и «Сгенерировать вопрос (ИИ)» нужны явные состояния: загрузка (спиннер, disabled), успех/ошибка, при необходимости — отмена длительной операции (видимость статуса системы). |
||||||
|
- **Черновик и риск потери данных.** Уже есть заметный «Сохранить черновик» и жёлтый баннер про новую версию — хорошо. Дополнительно: предупреждение при уходе со страницы с несохранёнными изменениями; для длинной формы — **закреплённая панель** с сохранением (или дублирование primary-действия после блока вопросов), чтобы не скроллить вниз каждый раз. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Редактор вопросов (UI и логика) |
||||||
|
|
||||||
|
- **Один vs несколько верных ответов.** При включённом «Несколько верных ответов» визуально должны быть **чекбоксы**, а не радиокнопки — соответствие метафоре, ожиданиям пользователя и доступности (скринридер, множественный выбор). |
||||||
|
- **Разделение действий.** «+ вариант» и «Удалить вопрос» сейчас визуально близки по весу — риск ошибочного клика. Деструктивное действие: вторичный стиль, отступ, по желанию подтверждение или «Удалить» в меню «⋯». |
||||||
|
- **Иерархия ИИ vs ручное редактирование.** Блок «Генерация сетки (ИИ)» логично оформить как сворачиваемый «продвинутый» блок или визуально отделить (заголовок, граница), чтобы отличать массовую генерацию от точечной «Сгенерировать вопрос» у карточки. |
||||||
|
- **Длинные варианты ответа.** Обрезка текста в однострочном поле мешает автору. Варианты: многострочное поле с авто-ростом по высоте или предпросмотр полной строки при фокусе/hover. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Локализация и терминология |
||||||
|
|
||||||
|
- В истории статус **`in_progress` на английском** при русском интерфейсе — заменить на «В процессе» или единый глоссарий статусов прохождения. |
||||||
|
- В шапке роль **`employee`** — унифицировать с русскими названиями ролей из словаря проекта (сотрудник, HR и т.д.). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## «Показ в каталоге» и список сотрудников |
||||||
|
|
||||||
|
- **Кнопка «Назначить выбранных».** Сейчас выглядит как вторичная; это главное действие сценария выдачи теста. Имеет смысл сделать её **заполненной primary** при наличии выбора и **disabled с подсказкой**, если никто не выбран. |
||||||
|
- **Повтор строки «нет учётки (создадим при назначении)».** На каждой строке создаётся шум. Лучше: один информационный блок над списком; в строке — компактный бейдж/иконка только где уместно. |
||||||
|
- **Крайний случай: много сотрудников.** При сотнях/тысячах записей — виртуализация, пагинация или «выбрать всех по фильтру» с явным числом «будет назначено N человек». |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## История и версии |
||||||
|
|
||||||
|
- При росте списка карточки версий и прохождений превращаются в длинную простыню — предусмотреть **свёрнутый список**, пагинацию или табы «Версии» / «Прохождения» с фильтром по версии и статусу. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Доступность и плотность |
||||||
|
|
||||||
|
- Мелкий серый текст в списке сотрудников — проверить контраст (WCAG). |
||||||
|
- Чекбоксы и переключатели: достаточная зона клика, связь подписи с полем, логичный порядок табуляции. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Пустые состояния |
||||||
|
|
||||||
|
- Пустая история, нет вопросов, поиск «никого не нашёл» — короткий текст **почему пусто** и **следующий шаг** («Добавьте вопрос», «Измените фильтр»). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Приоритизация внедрения |
||||||
|
|
||||||
|
1. **Высокий эффект / низкий риск:** локализация статусов и ролей; визуальное различие «Удалить вопрос» vs «+ вариант»; primary для «Назначить выбранных»; убрать повтор длинного текста про учётку в каждой строке. |
||||||
|
2. **Средний:** чекбоксы при нескольких верных ответах; многострочные варианты; состояния загрузки для ИИ; закреплённое сохранение. |
||||||
|
3. **Стратегический:** хлебные крошки и внутренняя навигация по разделу; масштабирование списка назначений; пустые состояния; продуктовая аналитика (например, доходят ли авторы до «Показ в каталоге»). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
*Документ можно дополнять по мере внедрения и новых скринов.* |
||||||
@ -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) |
||||||
@ -0,0 +1,298 @@ |
|||||||
|
# Словарь терминов проектирования |
||||||
|
|
||||||
|
**UX · UI · IA и смежные понятия** |
||||||
|
|
||||||
|
*Контекст: HR system / Платформа Цифровых Сервисов клиники им. Е. Н. Оленевой* |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
Короткий справочник, чтобы команда говорила на одном языке. Для каждого термина: русское название, английский эквивалент, короткое определение и пример из вашего продукта, чтобы было понятно, как термин применяется в реальной работе. |
||||||
|
|
||||||
|
Файл живой: добавляйте сюда термины, которые регулярно всплывают в обсуждениях. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1. Три основных слоя проектирования |
||||||
|
|
||||||
|
Три понятия, которые часто путают друг с другом. Это не синонимы и не одно и то же — это три разных профессиональных взгляда на один и тот же продукт. |
||||||
|
|
||||||
|
### UX (User Experience) — *опыт взаимодействия / пользовательский опыт* |
||||||
|
|
||||||
|
Совокупность ощущений пользователя от взаимодействия с продуктом: насколько просто понять, как достичь цели, насколько быстро это получается, насколько мало раздражения по дороге. UX — это про задачу пользователя, а не про конкретный экран. |
||||||
|
|
||||||
|
> *Пример из HR system:* HR-менеджер хочет назначить тест 50 сотрудникам отделения. Хороший UX — он делает это в три клика через фильтр по отделу. Плохой UX — он скроллит список из 147 человек и отмечает чекбоксами вручную. |
||||||
|
|
||||||
|
### UI (User Interface) — *пользовательский интерфейс* |
||||||
|
|
||||||
|
Видимая и кликабельная часть продукта: кнопки, поля, цвета, иконки, типографика, состояния (наведение, фокус, ошибка). UI — это про то, как продукт выглядит и как откликается на действия. |
||||||
|
|
||||||
|
> *Пример из HR system:* Кнопка «Сохранить черновик» на странице теста — её цвет, размер, скруглённые углы, текст внутри, реакция на наведение курсора — это всё UI. |
||||||
|
|
||||||
|
### IA (Information Architecture) — *информационная архитектура* |
||||||
|
|
||||||
|
Структура продукта на уровне «что где лежит и как связано»: какие есть разделы, какие сущности живут внутри, по какой логике пользователь переходит с одной страницы на другую. IA — это скелет, на который потом натягиваются UX и UI. |
||||||
|
|
||||||
|
> *Пример из HR system:* Решение «авторская работа над тестом, назначение и отчётность — это три разных раздела меню, а не один длинный аккордеон» — это IA-решение. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 2. Исследования и работа с пользователем |
||||||
|
|
||||||
|
### Целевая аудитория — *target audience* |
||||||
|
|
||||||
|
Группы людей, для которых проектируется продукт. У каждой группы своя задача и контекст использования. |
||||||
|
|
||||||
|
> *Пример из HR system:* В вашей системе четыре аудитории: сотрудник, руководитель подразделения, HR-менеджер, директор. У них разные потребности и разные роли в системе. |
||||||
|
|
||||||
|
### Персона — *persona* |
||||||
|
|
||||||
|
Собирательный образ типичного представителя аудитории: имя, должность, цели, ограничения, частые сценарии. Помогает команде договориться, для кого мы решаем задачу. |
||||||
|
|
||||||
|
> *Пример из HR system:* «Ольга, HR-менеджер, 35 лет. Раз в квартал назначает массовое обучение 200 сотрудникам. Не любит интерфейсы, где надо кликать каждого по отдельности. Открывает систему с рабочего ноутбука и иногда с телефона на ходу.» |
||||||
|
|
||||||
|
### Сценарий использования — *user scenario / use case* |
||||||
|
|
||||||
|
История: пользователь приходит с какой-то задачей и проходит шаги, чтобы её решить. Сценарий описывает, что он делает и какие ожидания у него есть. |
||||||
|
|
||||||
|
> *Пример из HR system:* «Руководитель отделения хочет, чтобы все его подчинённые прошли тест по пожарной безопасности до конца квартала. Он входит в систему, выбирает свой отдел, выбирает тест, ставит дедлайн, отправляет.» |
||||||
|
|
||||||
|
### Пользовательский путь / CJM — *Customer Journey Map* |
||||||
|
|
||||||
|
Развёрнутая визуализация пути пользователя: шаги, точки контакта, эмоции на каждом этапе, где возникают проблемы (pain points) и где можно улучшить. |
||||||
|
|
||||||
|
> *Пример из HR system:* CJM сотрудника: получил уведомление → открыл письмо → перешёл по ссылке → ввёл логин → увидел список назначенных тестов → выбрал → прошёл → получил результат. На каждом шаге — что ему легко, а что мешает. |
||||||
|
|
||||||
|
### JTBD (Jobs To Be Done) — *работы, которые нужно выполнить* |
||||||
|
|
||||||
|
Подход: люди не «пользуются продуктом», они «нанимают» его, чтобы сделать конкретную работу. Помогает увидеть истинную мотивацию, а не поверхностный запрос. |
||||||
|
|
||||||
|
> *Пример из HR system:* HR не «нанимает» вашу систему, чтобы кликать по чекбоксам. Он нанимает её, чтобы доказать аудиту, что 100% персонала прошли инструктаж в срок. |
||||||
|
|
||||||
|
### Pain point — *болевая точка* |
||||||
|
|
||||||
|
Конкретное место, где пользователю плохо: непонятно, медленно, страшно, обидно. Pain points — главные кандидаты на улучшение. |
||||||
|
|
||||||
|
> *Пример из HR system:* На странице теста пользователь не понимает, где кнопка «Сохранить», и боится потерять изменения — это pain point. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 3. Информационная архитектура и навигация |
||||||
|
|
||||||
|
### Карта сайта / структура продукта — *sitemap* |
||||||
|
|
||||||
|
Иерархическое описание всех экранов и разделов продукта. Показывает, что есть в продукте и в каких отношениях разделы стоят друг к другу. |
||||||
|
|
||||||
|
> *Пример из HR system:* Главная → Тесты → [страница теста] → Назначения → Отчёты → Сотрудники → Настройки. |
||||||
|
|
||||||
|
### Навигация — *navigation* |
||||||
|
|
||||||
|
Способ перемещаться по продукту: главное меню, хлебные крошки, ссылки, табы, кнопки «назад». Навигация бывает первичной (основной), вторичной и контекстной. |
||||||
|
|
||||||
|
> *Пример из HR system:* Шапка с логотипом «Тестирование», меню справа («Тесты», «Назначения», «Отчёты»), ссылка «← к списку» наверху страницы — всё это элементы навигации. |
||||||
|
|
||||||
|
### Хлебные крошки — *breadcrumbs* |
||||||
|
|
||||||
|
Цепочка ссылок, показывающая, где пользователь находится в иерархии и куда можно вернуться: «Тесты / Введение про LLM / Редактирование». |
||||||
|
|
||||||
|
> *Пример из HR system:* Сейчас на странице теста есть только «← к списку». Полные крошки помогли бы быстрее ориентироваться. |
||||||
|
|
||||||
|
### Таксономия — *taxonomy* |
||||||
|
|
||||||
|
Набор категорий и тегов, по которым классифицируются объекты. Хорошая таксономия позволяет быстро находить нужное и не плодит дубликаты. |
||||||
|
|
||||||
|
> *Пример из HR system:* Тест может иметь категории: «обязательные», «рекомендованные», «по специальности», «обучающие». Это таксономия. |
||||||
|
|
||||||
|
### Сущность / объект предметной области — *entity / domain object* |
||||||
|
|
||||||
|
Главные «существительные» вашей системы: Тест, Версия теста, Вопрос, Вариант, Сотрудник, Назначение, Прохождение, Отчёт. Дизайн начинается с понимания, какие сущности есть и как они связаны. |
||||||
|
|
||||||
|
> *Пример из HR system:* Связь «Тест → Версия → Прохождение» позволяет фиксировать результаты конкретной версии, даже если автор потом изменил вопросы. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 4. Проектирование интерфейса |
||||||
|
|
||||||
|
### Вайрфрейм — *wireframe* |
||||||
|
|
||||||
|
Скелетный набросок экрана без цвета и стилей: просто блоки, поля, кнопки, чтобы показать структуру и иерархию. Используется на ранних этапах для быстрого обсуждения. |
||||||
|
|
||||||
|
> *Пример из HR system:* Перед прорисовкой страницы создания теста — простой набросок: «слева 70% — форма, справа 30% — превью теста». |
||||||
|
|
||||||
|
### Макет — *mockup* |
||||||
|
|
||||||
|
Визуально проработанный вариант экрана: с реальными цветами, шрифтами, иконками, но обычно статичный (не кликается). |
||||||
|
|
||||||
|
> *Пример из HR system:* Готовый Figma-макет страницы теста, согласованный с вашим зелёным брендом и шрифтом. |
||||||
|
|
||||||
|
### Прототип — *prototype* |
||||||
|
|
||||||
|
Кликабельная модель продукта: можно жать на кнопки, переходить между экранами, увидеть переходы. Прототип бывает разной степени проработанности — от карандашных набросков до почти-настоящего продукта. |
||||||
|
|
||||||
|
> *Пример из HR system:* Кликабельный прототип в Figma, на котором можно «пройти» сценарий «создал тест → назначил отделению → получил уведомление о результате». |
||||||
|
|
||||||
|
### Состояния интерфейса — *states* |
||||||
|
|
||||||
|
Один и тот же элемент или экран в разных ситуациях: пустой, загрузка, ошибка, успех, наведение, фокус, отключённый. Хорошие проекты прорисовывают все состояния, а не только «всё хорошо». |
||||||
|
|
||||||
|
> *Пример из HR system:* Кнопка «Назначить выбранных» имеет состояния: disabled (никто не выбран), normal, hover, loading (отправка идёт), success (готово). |
||||||
|
|
||||||
|
### Empty state — *пустое состояние* |
||||||
|
|
||||||
|
Что пользователь видит, когда данных нет: список пуст, поиск ничего не нашёл, ещё ничего не назначено. Хороший empty state объясняет, почему пусто, и предлагает следующий шаг. |
||||||
|
|
||||||
|
> *Пример из HR system:* Сотрудник заходит и видит пустой список «Мои тесты». Empty state: «Сейчас вам ничего не назначено. Когда руководитель добавит тест — он появится здесь.» |
||||||
|
|
||||||
|
### Edge case — *крайний случай* |
||||||
|
|
||||||
|
Редкая, но возможная ситуация: ноль элементов, тысяча элементов, очень длинный текст, обрыв сети. Игнорирование edge cases ломает интерфейс именно тогда, когда пользователь меньше всего этого ожидает. |
||||||
|
|
||||||
|
> *Пример из HR system:* Что если в клинике 5000 сотрудников, а не 147? Список «Кому выдать» сегодня этого не выдержит — это edge case, который нужно учесть. |
||||||
|
|
||||||
|
### Happy path — *счастливый сценарий* |
||||||
|
|
||||||
|
Идеальное прохождение сценария без ошибок и непредвиденных ситуаций. Полезно как стартовая точка, но проектирование только под happy path — частая ошибка. |
||||||
|
|
||||||
|
> *Пример из HR system:* «Автор создаёт тест, заполняет 7 вопросов, сохраняет, назначает отделу, все проходят» — это happy path. А что если у автора оборвался интернет на полпути? |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 5. Дизайн-система и компоненты |
||||||
|
|
||||||
|
### Дизайн-система — *design system* |
||||||
|
|
||||||
|
Набор готовых правил, компонентов и токенов (цветов, отступов, шрифтов), которыми пользуется вся команда. Цель — единообразие и скорость: не изобретать каждый раз кнопку с нуля. |
||||||
|
|
||||||
|
> *Пример из HR system:* Внутри Платформы Цифровых Сервисов клиники должна быть единая дизайн-система: HR system, регистратура, эндовидеоплатформа выглядят как продукты одной семьи. |
||||||
|
|
||||||
|
### UI-кит — *UI kit* |
||||||
|
|
||||||
|
Библиотека готовых интерфейсных элементов (кнопки, поля, модалки, таблицы) в Figma или коде, которой пользуются дизайнеры и разработчики. |
||||||
|
|
||||||
|
> *Пример из HR system:* Если у вас есть UI-кит, новая страница «Назначения» собирается из готовых компонентов за день, а не за неделю. |
||||||
|
|
||||||
|
### Компонент — *component* |
||||||
|
|
||||||
|
Самостоятельный кусочек интерфейса с понятным API: входные параметры, состояния, поведение. Кнопка, поле ввода, аккордеон, модалка — всё это компоненты. |
||||||
|
|
||||||
|
> *Пример из HR system:* Аккордеон «О тесте» / «Вопросы» / «История» / «Показ в каталоге» — четыре экземпляра одного и того же компонента «аккордеон». |
||||||
|
|
||||||
|
### Токен дизайна — *design token* |
||||||
|
|
||||||
|
Атомарная переменная стиля: цвет, отступ, размер шрифта, радиус скругления. Токены позволяют менять оформление всего продукта централизованно. |
||||||
|
|
||||||
|
> *Пример из HR system:* Цвет `primary-green = #2E7D5B` — токен. Если решите перейти на другой оттенок зелёного, меняете в одном месте, и все кнопки обновляются. |
||||||
|
|
||||||
|
### Паттерн — *pattern* |
||||||
|
|
||||||
|
Типовое решение типовой задачи: «как реализовать поиск с фильтрами», «как показать длинный список». Паттерны — это коллективная мудрость комьюнити. |
||||||
|
|
||||||
|
> *Пример из HR system:* Паттерн «master-detail»: слева список тестов, справа детали выбранного. Хорошо ложится на ваш будущий раздел «Назначения». |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 6. Качество и проверка дизайна |
||||||
|
|
||||||
|
### Юзабилити — *usability* |
||||||
|
|
||||||
|
Свойство интерфейса быть простым и эффективным в использовании. Измеряется через эффективность (получилось ли), скорость и количество ошибок. |
||||||
|
|
||||||
|
> *Пример из HR system:* Если сотрудник не может с первого раза найти, как пройти тест — у интерфейса проблема с юзабилити. |
||||||
|
|
||||||
|
### Доступность — *accessibility / a11y* |
||||||
|
|
||||||
|
Возможность использовать продукт людям с особенностями: слабовидящим, незрячим (через скринридеры), людям с моторными ограничениями (только клавиатура), дальтоникам. Стандарт — WCAG. |
||||||
|
|
||||||
|
> *Пример из HR system:* Радиокнопки выбора правильного варианта должны быть доступны с клавиатуры (Tab + Space) и понятны скринридеру («Вариант 1 из 3, выбран»). |
||||||
|
|
||||||
|
### Юзабилити-тестирование — *usability testing* |
||||||
|
|
||||||
|
Метод исследования: реальный пользователь выполняет задание, исследователь наблюдает, где он спотыкается. Дешёвый способ найти большую часть проблем. |
||||||
|
|
||||||
|
> *Пример из HR system:* Дать HR-менеджеру задание «назначь этот тест всему отделению хирургии до 1 мая» и записать, где он зависнет. |
||||||
|
|
||||||
|
### Эвристическая оценка — *heuristic evaluation* |
||||||
|
|
||||||
|
Эксперт сверяет интерфейс с набором эвристик (правил хорошего дизайна, например, эвристиками Нильсена) и фиксирует нарушения. Быстрее теста с пользователями, но менее точно. |
||||||
|
|
||||||
|
> *Пример из HR system:* Анализ страницы теста, который мы делаем сейчас — это, по сути, эвристическая оценка. |
||||||
|
|
||||||
|
### A/B-тест — *A/B testing* |
||||||
|
|
||||||
|
Сравнение двух вариантов интерфейса на реальной аудитории: половина видит вариант A, половина — B; измеряем, какой работает лучше. |
||||||
|
|
||||||
|
> *Пример из HR system:* Сравнить две формулировки кнопки: «Сохранить черновик» vs «Сохранить и назначить» — что чаще ведёт к завершению задачи. |
||||||
|
|
||||||
|
### Аналитика продукта — *product analytics* |
||||||
|
|
||||||
|
Сбор и анализ данных о том, как пользователи реально пользуются продуктом: где кликают, где бросают, сколько времени проводят. Подсказывает, где искать проблемы. |
||||||
|
|
||||||
|
> *Пример из HR system:* Если в аналитике видно, что 40% авторов не доходят до раздела «Показ в каталоге» — это сигнал, что его упускают. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 7. Технические понятия, нужные дизайнеру |
||||||
|
|
||||||
|
### Респонсив / адаптивность — *responsive design* |
||||||
|
|
||||||
|
Способность интерфейса корректно работать на разных размерах экрана: от телефона до большого монитора. Не путать с «мобильной версией». |
||||||
|
|
||||||
|
> *Пример из HR system:* Список «Кому выдать» должен оставаться удобным на 13-дюймовом ноутбуке руководителя и на телефоне HR-менеджера в дороге. |
||||||
|
|
||||||
|
### Брейкпойнт — *breakpoint* |
||||||
|
|
||||||
|
Ширина экрана, на которой меняется раскладка интерфейса. Типовые: 360, 768, 1024, 1440 px. |
||||||
|
|
||||||
|
> *Пример из HR system:* На брейкпойнте 768 px (планшет) две колонки на странице теста схлопываются в одну. |
||||||
|
|
||||||
|
### RBAC — *Role-Based Access Control / ролевая модель доступа* |
||||||
|
|
||||||
|
Правила, что какая роль видит и может делать в системе. Дизайн интерфейса должен учитывать роль: один и тот же экран показывается по-разному сотруднику, руководителю, HR и директору. |
||||||
|
|
||||||
|
> *Пример из HR system:* Сотрудник видит только «Мои тесты». Руководитель — ещё «Мой отдел». HR — все назначения. Директор — сводный отчёт. |
||||||
|
|
||||||
|
### Версионирование — *versioning* |
||||||
|
|
||||||
|
Подход, при котором у объекта (теста, документа) есть несколько версий, и история фиксируется. Полезно для аудита и неизменности результатов. |
||||||
|
|
||||||
|
> *Пример из HR system:* В вашей системе тест имеет версии v1, v2 и т.д. Прохождение всегда привязано к конкретной версии — изменения автора не «переписывают» прошлые результаты. |
||||||
|
|
||||||
|
### Состояние черновика — *draft state* |
||||||
|
|
||||||
|
Промежуточное состояние объекта: ещё не опубликован/не активирован, можно безопасно править. |
||||||
|
|
||||||
|
> *Пример из HR system:* Кнопка «Сохранить черновик» означает: тест сохранён, но пока не выдан сотрудникам. Можно ещё дорабатывать. |
||||||
|
|
||||||
|
### Уведомление — *notification* |
||||||
|
|
||||||
|
Сообщение системы пользователю: всплывающее (toast), баннер на странице, push, e-mail. Каждый канал имеет свои правила использования. |
||||||
|
|
||||||
|
> *Пример из HR system:* Тост «Тест сохранён» после нажатия кнопки. E-mail сотруднику с дедлайном по назначенному тесту. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 8. Терминология этого проекта |
||||||
|
|
||||||
|
Чтобы команда не путалась, фиксируем основные сущности HR system явно. |
||||||
|
|
||||||
|
- **Тест** — учебный материал, состоящий из вопросов с вариантами ответов. Один тест может иметь несколько версий. |
||||||
|
- **Версия теста** — снимок содержимого теста на момент сохранения. Прохождение всегда привязано к конкретной версии. |
||||||
|
- **Вопрос** — отдельный пункт теста с формулировкой и набором вариантов. Может допускать один или несколько верных ответов. |
||||||
|
- **Вариант ответа** — один из предложенных ответов на вопрос. Помечается как верный или нет. |
||||||
|
- **Назначение** — связь «тест × сотрудник × срок». Формирует у сотрудника обязательство пройти этот тест. |
||||||
|
- **Прохождение** — попытка сотрудника пройти конкретную версию теста. Имеет статус (в процессе, пройдено, не пройдено) и результат (X из Y). |
||||||
|
- **Порог зачёта** — процент правильных ответов, начиная с которого прохождение засчитывается. |
||||||
|
- **Каталог** — общий список тестов, видимый сотрудникам с правами. |
||||||
|
- **Роль** — профиль доступа: сотрудник, руководитель подразделения, HR-менеджер, директор. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Полезные ссылки и стандарты |
||||||
|
|
||||||
|
- **Эвристики Якоба Нильсена** — 10 базовых правил юзабилити: [nngroup.com](https://www.nngroup.com/articles/ten-usability-heuristics/) |
||||||
|
- **WCAG 2.2** — стандарт доступности: [w3.org/TR/WCAG22](https://www.w3.org/TR/WCAG22/) |
||||||
|
- **Material Design** — [m3.material.io](https://m3.material.io/) и **Apple HIG** — [developer.apple.com/design](https://developer.apple.com/design/human-interface-guidelines/) — два больших источника готовых паттернов и принципов. |
||||||
|
- **Refactoring UI** (Adam Wathan, Steve Schoger) — настольная книга по практическому UI-дизайну. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
*— Справочник можно дополнять по мере появления новых терминов —* |
||||||
@ -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). |
||||||