diff --git a/.DS_Store b/.DS_Store index 273274a..5d52eee 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/DOC/ШАГИ/Untitled b/DOC/ШАГИ/Untitled new file mode 100644 index 0000000..a0c976d --- /dev/null +++ b/DOC/ШАГИ/Untitled @@ -0,0 +1 @@ +тестирования \ No newline at end of file diff --git a/README.md b/README.md index 8d22bc7..7e995e7 100644 --- a/README.md +++ b/README.md @@ -1,202 +1,195 @@ # Система тестирования сотрудников клиники -Веб-приложение для проведения внутреннего тестирования сотрудников клиники. Руководители подразделений и HR-менеджеры создают тесты и назначают их сотрудникам. Система фиксирует все попытки и результаты. +Веб-приложение для проведения внутреннего тестирования сотрудников клиники. +Руководители подразделений и HR-менеджеры создают тесты и назначают их +сотрудникам. Все попытки и результаты сохраняются. -**Версия ТЗ:** 1.2 -**Дата:** 2026-03-21 -**Статус:** Согласовано - -**Актуальное состояние кода (не ТЗ, а «что уже есть»):** [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) · [инструкция для проверяющих на dev](docs/DEV_CONTOUR_USER_GUIDE.md) · [кабинет: коротко для врачей/кураторов](docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md). -**Спринты мобильного UI (чек-лист для разработки):** [docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). -**Унификация стека (текущий этап) и слияние с HR-кабинетом (на будущее):** план и журнал — [docs/migration-final.md](docs/migration-final.md). Этап 1 — Express → Flask + React → Jinja **внутри TestingWebApp** (БД остаётся `clinic_tests`). Этап 2 (на будущее) — слияние с `HR_TG_Bot/tgFlaskForm`: [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md) · [простым языком](docs/migration-to-tgflaskform-plain.md). Карта Express-функционала и справочный gap-analysis с уже существующим модулем HR-кабинета: [docs/migration-final-inventory.md](docs/migration-final-inventory.md). -**Заготовка `flask_app/`** (отдельный Flask) больше **не развивается** — выбран сценарий «модуль внутри `tgFlaskForm`». +- **Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** +- **Ветка разработки:** `dev` +- **ТЗ:** v1.2 от 2026-03-21 (статус — согласовано) --- -## Стек технологий - -### Этот репозиторий (TestingWebApp) - -| Слой | Технологии | -|------|------------| -| **Backend** | Node.js (ESM), **Express** 4, **pg**, миграции SQL; аутентификация — cookie + **JWT** (**jsonwebtoken**), пароли **bcryptjs**; опционально вход через HR (`HR_AUTH`, отдельное подключение к БД HR). | -| **Frontend** | **React** 18, **React Router** 6, сборка **Vite** 5; статика в проде через Nginx (см. `docker-compose.dev.yml`). | -| **Данные** | **PostgreSQL**, отдельная БД **`clinic_tests`**: UUID-ключи, таблицы `tests`, `test_versions`, `questions`, `answer_options`, назначения, попытки (см. `backend/src/db/migrations/`). | -| **Прочее** | Извлечение текста из PDF/DOCX (**pdf-parse**, **mammoth**), опционально LLM для черновиков тестов; **dotenv**, **cors**, **multer**. | - -### Целевой стек (Flask, как в кабинете / мини-приложении) +## Стек и состояние -Тот же класс технологий, что в **`HR_TG_Bot/tgFlaskForm`**: Python, Flask, шаблоны, Postgres. Сейчас допускается **отдельный деплой** нового контура из каталога [`flask_app/`](flask_app/README.md); позже — слияние с полным кабинетом при необходимости. +**Целевой и единственный рабочий стек** — Python 3.11 + Flask 3 + +Jinja2 + Tailwind CDN + SQLAlchemy / psycopg2, код в +[`flask_app/`](flask_app/). На нём работает и прод, и dev (`:3108`). -Эталон реализации модуля в монорепозитории HR — общий веб-кабинет **`HR_TG_Bot/tgFlaskForm`**: +Старые каталоги `backend/` (Node.js / Express) и `frontend/` +(React + Vite) — **архив**: не разворачиваются, в `docker-compose.dev.yml` +поднимается только сервис `testing-flask`, удаление папок запланировано +в спринте **E1.6**. Использовать их не надо, миграции и SQL-схема +сохранены в `backend/src/db/migrations/` исключительно как источник +структуры БД. -| Слой | Технологии | -|------|------------| -| **Приложение** | **Python 3**, **Flask** 3, шаблоны **Jinja2** + **PyPug**, blueprint `/cabinet/testing`; прод-сервер типично **waitress**. | -| **Данные** | **SQLAlchemy** 2, **psycopg2**, БД **`hr_bot_test`**: таблицы `testing_*`, связи с **`staff_members`**. | -| **Клиент** | HTML-шаблоны кабинета, JS в `webApp/templates/static/js/cabinet/` (без отдельного SPA в этом репозитории). | -| **Инфра** | Тот же кластер Postgres, что и у Postgres_TG_Bots / HR (см. раздел установки ниже). | +БД — **`clinic_tests`** (PostgreSQL, UUID-ключи). В Этапе 1 схема +не меняется. -Подробности переноса и миграции данных: [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md). -Скрипт ETL в монорепозитории HR: [`../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (`--dry-run` / `--apply`, переменные `CLINIC_TESTS_URL` и `HR_BOT_URL`). +**Этап 2** — слияние с общим HR-кабинетом `HR_TG_Bot/tgFlaskForm` — +запланирован на будущее, сейчас не делается. План: +[`docs/migration-to-tgflaskform.md`](docs/migration-to-tgflaskform.md) +([простыми словами](docs/migration-to-tgflaskform-plain.md)). --- -## Содержание - -- [Стек технологий](#стек-технологий) · [flask_app/ — новый контур](flask_app/README.md) -- [Состояние реализации (сводка)](#состояние-реализации-сводка) -- [Функциональные возможности](#функциональные-возможности) -- [Роли и права доступа](#роли-и-права-доступа) -- [Установка и запуск](#установка-и-запуск) -- [Данные, сотрудники, интеграция с HR](#данные-сотрудники-интеграция-с-hr) -- [Нефункциональные требования](#нефункциональные-требования) -- [Вне scope](#вне-scope-не-реализуется-в-данной-версии) +## Что уже работает на новом (Flask) контуре + +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//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` от legacy-сервисов. | +| **E1.7** — UX-полировка редактора | 4 аккордеона (Шапка / AI / Вопросы / Действия) и drag-n-drop из [Спринта 3](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). | --- -## Состояние реализации (сводка) - -Коротко и по-человечески: [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) (черновики и версии, разбор попыток, список тестов, dev-стенд). -Как пользоваться локальным **dev** без чтения кода: [docs/DEV_CONTOUR_USER_GUIDE.md](docs/DEV_CONTOUR_USER_GUIDE.md). - ---- - -## Функциональные возможности - -### Управление пользователями и подразделениями - -- Создание/редактирование/деактивация учётных записей сотрудников -- Каждый сотрудник принадлежит одному подразделению -- Создание/редактирование справочника подразделений -- Назначение роли сотруднику: HR-менеджер / Руководитель подразделения / Сотрудник - -### Создание и редактирование тестов - -**Тест содержит:** -- Название теста -- Описание (опционально) -- Список вопросов (минимум 7) -- Порог зачёта — минимальный % правильных ответов -- Таймер прохождения — лимит в минутах (опционально) - -**Вопрос содержит:** -- Текст вопроса -- Минимум 3 варианта ответа -- Один или несколько правильных ответов +## Установка и запуск -**Настройки теста:** -- Разрешить возврат к предыдущему вопросу: да / нет +### Предпосылка: общий Postgres -**Версионирование:** -- Автор может редактировать тест пока никто его не проходил -- Если тест уже проходили — создаётся новая версия (`version + 1`), старая сохраняется -- Все версии теста хранятся; результаты привязаны к конкретной версии -- Активная версия — та, которую видят сотрудники; автор может вручную переключить активную версию -- Тест можно деактивировать (скрыть из списка, не удалять) +Используется **тот же** 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;" -- Список получателей (отдел или конкретные сотрудники) -- Срок сдачи — дата дедлайна -- Допустимое количество попыток (1 или более) +# (один раз) внешняя сеть, если ещё не создана соседом +docker network create hr_postgres_dev_net || true +``` -### Прохождение теста +### Dev-стенд -- На главной странице сотрудник видит список назначенных тестов со статусами: - - `Не начат` — ещё не открывал - - `В процессе` — начал, не завершил - - `Завершён` — сдал/не сдал - - `Просрочен` — дедлайн прошёл, не сдан -- Если задан таймер — отображается обратный отсчёт, по истечении тест завершается автоматически -- Порядок вопросов **случайный** при каждом прохождении -- Возможность вернуться к предыдущему вопросу — определяется настройкой теста +Выбор интерфейса задаётся через env-переменную `COMPOSE_PROFILES`: -### Результаты после завершения теста +- `modern` — основной интерфейс на Flask/Jinja; +- `legacy` — legacy-раскладка интерфейса на том же Flask-стеке. -- Итоговый балл и процент правильных ответов -- Факт зачёта: **сдал / не сдал** -- Разбор ошибок: по каждому вопросу — его ответ и правильный ответ +```bash +# Новый стек (рекомендуется) +COMPOSE_PROFILES=modern docker compose -f docker-compose.dev.yml up -d --build -### Трекер попыток +# Legacy-раскладка (тот же Flask) +COMPOSE_PROFILES=legacy docker compose -f docker-compose.dev.yml up -d --build +``` -Единый интерфейс просмотра всех попыток прохождения тестов: -- Фильтрация по подразделению, сотруднику, тесту, статусу, результату -- Пагинация и сортировка +| Что | URL | +|---|---| +| Приложение (Flask modern) | | +| Health-check | | +| Приложение (Flask legacy) | | -### AI-помощник +`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` репозитория. -Интеграция с LLM для помощи при создании тестов: +### Локально без 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_TG_Bot / Postgres_TG_Bots) - -Используется **тот же** экземпляр PostgreSQL, что и в [Postgres_TG_Bots](../Postgres_TG_Bots) (`docker-compose.dev.yml`, контейнер `hr_postgres_dev`, учётка `hr_bot_user` / сеть `hr_postgres_dev_net` — см. [HR_TG_Bot docker-compose](../HR_TG_Bot/docker-compose.dev.yml)). - -Схема приложения (таблицы `users`, `tests`, `departments`, …) **не** совмещается с БД `hr_bot_test` — для TestingWebApp заведена отдельная база **`clinic_tests`**. - -1. Поднять Postgres из `Postgres_TG_Bots` (и при необходимости внешнюю сеть: `docker network create hr_postgres_dev_net` — как в compose этих репозиториев). -2. Один раз создать базу: - `psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;"` -3. Скопировать `backend/.env.example` в `backend/.env`, при необходимости поправить `DATABASE_URL` (внутри Docker кластера — хост `hr_postgres_dev`, порт `5432`). -4. Миграции: из каталога `backend/`: `npm run migrate`, затем `npm start` (и фронт из `frontend/` — `npm run dev`). - -**Docker (UI + API + общий Postgres):** поднять `Postgres_TG_Bots` (сеть `hr_postgres_dev_net`), создать БД `clinic_tests`, затем из корня `TestingWebApp`: -`docker compose -f docker-compose.dev.yml up --build` — интерфейс **http://localhost:3107** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3001** (см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). **Новый Flask-контур** (тот же стек, что кабинет HR): **http://localhost:3108** — сервис `testing-flask`, см. [flask_app/README.md](flask_app/README.md). Локальный `npm run dev` фронта (Vite) — тоже **:3107**, прокси `/api` на **:3001**. В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`). В `backend/.env` задайте `PORT=3001`, если поднимаете API отдельно от compose. +## Роли и права (по ТЗ) -`docker compose -f docker-compose.dev.yml down` — остановка. +| Роль | Кто | Создаёт тесты | Назначает | Видит результаты | +|---|---|:---:|:---:|:---:| +| **HR-менеджер** | Руководитель HR, директор | ✅ | Всем | Всех | +| **Руководитель подразделения** | Главврач, рук. отделения | ✅ | Только своему подразделению | Только своего подразделения | +| **Сотрудник** | Все остальные | ❌ | ❌ | Только свои | -**Без общего кластера** (только отладка): `docker compose --profile standalone up -d` в TestingWebApp — Postgres на **5433**, тогда в `.env` укажите `DATABASE_URL=...localhost:5433/clinic_tests` или `DB_PORT=5433` с `DB_NAME`/`DB_USER` из compose. - -**Если `npm run migrate` пишет `ECONNREFUSED ...:5433`:** в `backend/.env` нет (или кривой) `DATABASE_URL` на **5432**, и сработал старый `DB_PORT=5433`. Задайте `DATABASE_URL` как в `backend/.env.example` для общего Postgres. - -### Данные, сотрудники, интеграция с HR - -- **Две роли кластера Postgres:** в **`clinic_tests`** — только сущности модуля тестирования (тесты, версии, назначения, попытки, локальные технические учётки при необходимости). В **`hr_bot_test`** (Postgres_TG_Bots / hr_web_viewer) — штат, справочники, существующий **RBAC** и веб-логины. Так мы не смешиваем схемы и не дублируем «источник правды» по людям. -- **Сотрудник в процессах** (назначения, дашборды, доступ к результатам) — везде по **`staff_members.id`**. Ссылки в `clinic_tests` храним как **тот же идентификатор** (логическая связь с `staff_members` в `hr_bot_test`); **ФИО, отдел, роли** подтягиваем из HR при отображении или кэшируем по согласованной политике, а не ведём второй кадровый учёт. -- **`telegram_id`** в данных сотрудника **не участвует** в бизнес-логике модуля: ни вход, ни проверка прав, ни выбор сотрудника в сценариях, ни фильтрация — только **справочная** информация при необходимости (отображение, история). -- **RBAC в перспективе:** единая система разрешений — та, что уже в HR (роли, `staff_role_assignments`, permissions). Модуль тестирования **не** развивает отдельную полную копию матрицы; проверка действий в целевом виде — через **HR** (внутренний API / токен / согласованные запросы к БД). Пока договор и API не готовы — допустимы временные флаги в `clinic_tests`, явно помечаемые как MVP. - -Детализация задач и варианты A.x: [docs/revision_task/card1.md](docs/revision_task/card1.md). +> На текущем Flask-контуре (E1.0–E1.3, E1.8) проверяется только +> `@login_required`; разделение по ролям задействуется на E1.4–E1.5. --- ## Нефункциональные требования | Параметр | Значение | -|----------|----------| +|---|---| | Количество пользователей | 50–200 человек | -| Платформа | Веб-приложение, браузер (desktop-first) | +| Платформа | Веб, браузер; mobile-friendly | | Доступность | Внутренняя сеть клиники | | Язык интерфейса | Русский | | Время отклика | < 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) | Исходное ТЗ заказчика. | diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c4233ca..14614ea 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,73 +4,67 @@ # База clinic_tests: один раз # psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;" # -# Запуск: из каталога TestingWebApp -# docker compose -f docker-compose.dev.yml up --build -# UI (Node): http://localhost:3107 (Nginx: /api → backend:3001), API: http://localhost:3001 -# UI (Flask, новый контур): http://localhost:3108 +# Flask-only режим. Выбор варианта интерфейса через profile: +# COMPOSE_PROFILES=modern docker compose -f docker-compose.dev.yml up -d --build +# COMPOSE_PROFILES=legacy docker compose -f docker-compose.dev.yml up -d --build +# Оба варианта работают на одном Flask-стеке, отличаются только UI-раскладкой. +# UI (Flask modern): http://localhost:3108 +# UI (Flask legacy): http://localhost:3107 services: - testing-backend: + # Flask modern UI + testing-flask: + profiles: ["modern"] build: - context: ./backend + context: ./flask_app dockerfile: Dockerfile - container_name: testing_webapp_backend - # LLM и прочие секреты из хоста (не копируются в образ — см. .dockerignore) - env_file: - - ./backend/.env + container_name: testing_webapp_flask environment: - DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests - JWT_SECRET: ${JWT_SECRET:-testing_webapp_jwt_dev} - # development: httpOnly-cookie без Secure (иначе на http://localhost:3107 логин не сработает) - NODE_ENV: development - FRONTEND_URL: http://localhost:3107 - PORT: "3001" - # Вход теми же учётками, что в HR: проверка пароля в hr_bot_test + привязка сотрудника по web_login. - # Без HR_AUTH / HR_DATABASE_URL логин ищется только в clinic_tests.users (локальные dev-учётки). + PORT: "3108" + WEB_USE_WAITRESS: "1" + FLASK_DEBUG: "0" + SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me} + # БД (clinic_tests) в общей сети hr_postgres_dev_net. + # По умолчанию используем те же dev-учётки, что и в backend-сервисе. + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests} + # HR-аутентификация включена по умолчанию: + # пароль проверяется в hr_bot_test.users + staff по web_login. HR_AUTH: ${HR_AUTH:-1} - HR_DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test - # Прямой доступ к API с хоста (Vite proxy в dev: см. frontend/vite.config.js) + HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test} + UI_VARIANT: ${UI_VARIANT_MODERN:-modern} + # LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта. + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + LLM_BASE_URL: ${LLM_BASE_URL:-} + LLM_MODEL: ${LLM_MODEL:-} ports: - - "3001:3001" + - "3108:3108" networks: - app - postgres - testing-web: - build: - context: ./frontend - dockerfile: Dockerfile - container_name: testing_webapp_nginx - depends_on: - - testing-backend - ports: - - "3107:80" - networks: - - app - - # Новый контур: Flask (тот же стек, что кабинет HR), отдельный порт - testing-flask: + # Flask legacy UI (старое расположение элементов на новом стеке) + testing-flask-legacy: + profiles: ["legacy"] build: context: ./flask_app dockerfile: Dockerfile - container_name: testing_webapp_flask + container_name: testing_webapp_flask_legacy environment: - PORT: "3108" + PORT: "3107" WEB_USE_WAITRESS: "1" FLASK_DEBUG: "0" SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me} - # БД (clinic_tests). Хост postgres — в общей сети hr_postgres_dev_net. - DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://app:app@postgres:5432/clinic_tests} - # Опц. HR-кабинет (E1.1): включается флагом + URL базы hr_bot_test. - HR_AUTH: ${HR_AUTH:-0} - HR_DATABASE_URL: ${HR_DATABASE_URL:-} - # LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта. + 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} + UI_VARIANT: ${UI_VARIANT_LEGACY:-legacy} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_MODEL: ${LLM_MODEL:-} ports: - - "3108:3108" + - "3107:3107" networks: - app - postgres diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 54d9397..14216bf 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,78 +1,148 @@ -# Состояние проекта (человеческий обзор) +# Состояние проекта -**Репозиторий:** [TestingWebApp](https://git.pirogov.ai/l_konstantin/TestingWebApp) · ветка разработки: **`dev`** -**Дата среза:** 2026-04-24 +**Репозиторий:** [TestingWebApp](https://git.pirogov.ai/l_konstantin/TestingWebApp) · ветка разработки **`dev`** +**Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** +**Дата среза:** 2026-04-28 -Этот документ — не дублирование ТЗ, а **короткое объяснение**, что уже работает в коде и что логично делать дальше. Подробные задачи: [revision_task/card1.md](revision_task/card1.md), [revision_task/BACKLOG.md](revision_task/BACKLOG.md). +Не дубль ТЗ, а карта «что реально работает в коде, на каком контуре, +и что логично сделать дальше». --- -## Что уже сделано (как это устроено) +## 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). -- Сотрудник входит по **логину и паролю** (сессия через cookie + JWT). -- В шапке показываются **роль** и **Фамилия с инициалами** (например, *Иванов И. О.*), полное ФИО — во всплывающей подсказке. -- В **режиме разработки** (`NODE_ENV=development`) у удобного тестирования могут быть дополнительные кнопки (например, создание теста сотрудником — `devUi` в ответе `/api/auth/me`). +Главный трекер по спринтам — [`migration-final.md`](migration-final.md). -### «Цепочка» теста и черновики +--- -- У каждого теста есть **одна логическая цепочка** в базе: все правки вопросов относятся к ней, но **версия контента** (`v1`, `v2`, …) может расти. -- **Пока никто не проходил** этот тест — автор правит **на месте**: сохраняет черновик, и меняется текущая активная версия **без** лишнего дублирования строк в истории. -- **Как только по цепочке появилась хотя бы одна завершённая попытка** — каждое **содержательное** сохранение с изменениями создаёт **новую версию** (новый номер, старая остаётся в истории). Старые результаты остаются привязаны к **той** версии, с которой человек реально отвечал. -- **Активная версия** — та, с которой сейчас стартуют новые попытки. Автор может **вручную** переключить активную версию в таблице истории (с подтверждением), если бизнесу так нужно. -- **Публикация / видимость:** в кабинете (аккордеон **«Показ в каталоге»**, подсекция **«Видимость»**) тест можно **скрыть из общего списка** (цепочка остаётся в базе) или **снова показать**; **назначения** (подсекция **«Кому выдать»**) — при включённой фиче, см. раздел «Назначения» ниже. -- **Мобильный UI** кабинета (колонка списка на узком экране, фикс-футер, группировка разделов, копи): [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) · [РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) (тезисы для врачей/кураторов). -- **Унификация стека (Этап 1, текущий)**: Express → Flask + React → Jinja **внутри TestingWebApp** (`flask_app/`). БД остаётся `clinic_tests`, схема не меняется. План и журнал — [migration-final.md](migration-final.md). -- **Слияние с HR-кабинетом (Этап 2, на будущее, без сроков)**: перенос в `HR_TG_Bot/tgFlaskForm` как blueprint `cabinet/testing`, ETL `clinic_tests → hr_bot_test`. План — [migration-to-tgflaskform.md](migration-to-tgflaskform.md). Карта Express-функционала и справочный gap-analysis с уже существующим модулем HR-кабинета — [migration-final-inventory.md](migration-final-inventory.md). +## Что уже работает на новом контуре (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//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()`. +- Ключ на клиента не уходит и в БД не пишется. -### Список тестов и доступ +--- -- В каталоге **«Тесты»** видны цепочки, где вы **автор**, и тесты, **назначенные вам** (через назначение на пользователя; в dev назначения обычно **включены**). -- Под названием показывается **«Автор: Вы»** для своих тестов и **«Автор: Фамилия И. О.»** для чужих (назначенных). -- **Пройти** тест — кнопка **справа** в строке; **карточка** теста — клик по названию **слева** (попытка с карточки не стартует сама). +## Чего на Flask пока нет -### Прохождение и результат +Эти сценарии будут реализованы в E1.4–E1.5. До этого в приложении они +просто отсутствуют (старый Express-контур не используется и не +поднимается): -- Открывается экран вопросов (один или несколько верных вариантов); после **«Завершить тест»** — итог: сколько верно, процент, **зачёт** по порогу. -- **Разбор:** после сдачи показывается, по **каждому вопросу**, что выбрал пользователь и какие варианты верны. Отдельная страница разбора доступна по ссылке; **автор** в аккордеоне **«История»** (подсекция **«Прохождения»**) видит **завершённые** попытки и кнопку **«Разбор»** (раньше секция называлась **«Прогоны и разбор»**). +- **Назначение теста сотруднику** — поиск по справочнику, «Выбрать + всех», фильтры по подразделениям. +- **Прохождение** — экран вопросов, таймер, сохранение попытки. +- **Результат и разбор ошибок** — отдельная страница с ответами + пользователя и правильными вариантами. +- **Трекер попыток** — единый список завершённых попыток с фильтрами + (подразделение / сотрудник / тест / статус / результат). -### Импорт и ИИ (MVP) +--- -- Можно загрузить **файл** (PDF, DOCX, текст): сервер **извлекает текст** и при настроенном ключе **LLM** (например, `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` в окружении) предлагает **черновик** вопросов. В UI: подсекция **«Документ в вопросы»** внутри **«Вопросы»** (раньше — отдельный блок «Импорт из файла»). Дальше тот же поток, что и при ручном редактировании: правки → **сохранить черновик** (с учётом правил версий выше). -- **Полный** набор сценариев из ТЗ (отдельная страница настроек ключа, «проверить тест целиком», модалки с чекбоксами и т.д.) — в [sprint-02](revision_task/sprint-02.md); часть уже заложена в сервисах, UI доводится. +## Что в работе и в планах -### Назначения (MVP) +### Этап 1 — паритет внутри TestingWebApp -- **Автор** в **«Показ в каталоге»** → **«Кому выдать»** может **назначить** сотрудников из справочника (поиск, фильтры, **«Выбрать всех»** в текущем списке; в dev — при включённой фиче в `docker-compose` / `.env`). Назначение **не** перепривязывается автоматически к каждой новой версии контента: **старт попытки** всегда берёт **текущую активную** версию на момент нажатия **«Пройти»**. +| Спринт | Содержание | Статус | +|---|---|---| +| 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). | ✅ | -### Интеграция с HR (в зачатке) +Подробности — [`migration-final.md`](migration-final.md). -- Поддержан сценарий **входа через учётки HR** (`HR_AUTH` + `HR_DATABASE_URL`) для проверок на одном кластере Postgres с экосистемой `Postgres_TG_Bots` (см. [README — установка](../README.md)). -- Целевой **RBAC** из HR-таблиц — [card1, часть A](revision_task/card1.md#часть-a--авторизация-по-паролю-бд-postgres_tg_bots); сейчас — упрощённое сопоставление ролей. +### Этап 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)). -## Что в планах (логичный следующий слой) +### Долгий бэклог | Направление | Суть | -|-------------|------| -| **AI по ТЗ §4.2** | Ключ в настройках (не на клиенте), кнопки «сгенерировать/проверить/улучшить» с превью и подтверждением, регресс с версиями. | -| **Дашборды (ТЗ этап 2)** | Единая картина по отделу / клинике, фильтры, история. | -| **MAX / мини-приложение** | Встраивание в общий HR-контур клиники. | -| **Таймер, подсказки, медиа в вопросах** | Режимы прохождения и вложения — отдельные этапы ТЗ. | -| **E2E и интеграционные тесты** | Расширение `V.9`, стабильный CI. | -| **Назначения** | Сроки, лимит попыток, назначения «по отделу» (частично в бэклоге [BACKLOG_IDEAS](revision_task/BACKLOG_IDEAS.md)). | - -Журнал приёмок и чек-листы: [TESTING_JOURNAL.md](revision_task/TESTING_JOURNAL.md). +|---|---| +| Дашборды (ТЗ этап 2) | Единая картина по отделу / клинике, фильтры, история. | +| MAX / мини-приложение | Встраивание в общий HR-контур клиники. | +| Таймер, подсказки, медиа в вопросах | Режимы прохождения и вложения — отдельные этапы ТЗ. | +| E2E и интеграционные тесты | Расширение `V.9`, стабильный CI. | +| Назначения по отделу | Сроки, лимит попыток, групповые назначения. | --- -## Связанные файлы - -- [Руководство пользователя dev-контура](DEV_CONTOUR_USER_GUIDE.md) -- [Руководство кабинета (простыми словами)](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) -- [Спринты: мобильный UI](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) -- [Предложение по дизайну (ист. + актуализация)](ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md) -- [README с установкой](../README.md) -- [Карта задач card1](revision_task/card1.md) +## Связанные документы + +- [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) diff --git a/docs/UX_аудит_и_новая_IA_—_страница_теста.md b/docs/UX_аудит_и_новая_IA_—_страница_теста.md new file mode 100644 index 0000000..d59e45a --- /dev/null +++ b/docs/UX_аудит_и_новая_IA_—_страница_теста.md @@ -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. Шапка, заголовок и баннер версионирования + +![Шапка и баннер](screens/01_header_intro.jpg) + +Что мы видим: глобальная шапка «Тестирование», подпись пользователя `Разорвин А. М. · employee`, кнопка «Выйти». Ниже — хлебная крошка «← к списку», название теста, автор, дата обновления, **жёлтый баннер «При сохранении будет создана новая версия теста.»** и схлопнутый аккордеон «О тесте». + +Замечания: + +- **C-1 [critical] Баннер о новой версии показывается ВСЕГДА**, независимо от того, изменил ли пользователь хоть что-то. Это сбивает с толку: автор открывает существующий тест, ничего не трогает — и думает, что версия уже создана. Должен показываться только при наличии несохранённых изменений (dirty state). +- **m-2 [minor] Роль `employee` написана по-английски** в шапке. В русском интерфейсе должно быть «сотрудник» / «автор» / другое (см. ролевую модель в Части 2). +- **m-3 [minor] Опечатка** в `` вкладки: «Система тестирования» (нет «и»). Влияет на закладки, историю, поиск. + +--- + +## 1.2. Секция «О тесте» + +![О тесте раскрытый](screens/02_about_test.jpg) + +Замечания: + +- **M-1 [major] Аккордеон по умолчанию схлопнут.** Чтобы начать редактировать главный объект страницы (вопросы), нужно сделать лишний клик. На странице редактирования теста раздел «Вопросы» (а возможно, и «О тесте») должен быть открыт по умолчанию. +- **M-2 [major] Поле «Порог зачёта, %» не имеет валидации min/max.** Что произойдёт при вводе 0, 100, 150, –5, 70.5, или вообще буквы? Минимум: подсказка «от 1 до 100», атрибуты `min/max/step` на input, инлайн-ошибка при некорректном вводе. + +--- + +## 1.3. Раздел «Вопросы» — генерация и Вопрос 1 + +![Генерация и Вопрос 1](screens/03_questions_top.jpg) + +Что мы видим: блок **«Генерация сетки вопросов (ИИ)»** с полями «Тема», «Вопросов: 7», «Вариантов: 3» и кнопкой «Сгенерировать тест (ИИ)». Ниже — Вопрос 1 с собственной кнопкой «Сгенерировать вопрос (ИИ)» в правом верхнем углу, чекбоксом «Несколько верных ответов», тремя вариантами с радиокнопками и крестиками «удалить». + +Замечания: + +- **C-2 [critical] ИИ-генерация без подтверждения и без отображения хода работы.** Кнопка «Сгенерировать тест (ИИ)» одной нажатием перезаписывает существующие вопросы — а они уже могут быть наполовину написаны вручную. То же касается кнопки «Сгенерировать вопрос (ИИ)» рядом с уже заполненным вопросом. + - Нужно: confirm-диалог «Заменить текущие N вопросов?», индикатор прогресса генерации, возможность откатить (undo) последний результат генерации. +- **M-3 [major] Чекбокс «Несколько верных ответов» меняет семантику варианта без явного намёка.** Когда выкл — радиокнопки (один верный), когда вкл — должны стать чекбоксами (несколько). Лучше переписать подпись в зависимости от состояния: «один верный» / «несколько верных», и/или показать рядом подсказку, как изменится контрол. + +--- + +## 1.4. Вопросы 3–5 + +![Вопросы 3-5](screens/04_questions_mid.jpg) + +Замечания: + +- **M-4 [major] Нет нумерации/перетаскивания вопросов.** «Вопрос 1, 2, 3…» — порядок фиксирован тем, в каком порядке добавляли. Для длинных тестов нужен drag-handle или хотя бы стрелки «вверх / вниз». +- **M-5 [major] «Удалить вопрос» без подтверждения.** Случайный клик уничтожит написанный вопрос. Минимум — confirm-диалог; лучше — undo-toast «Вопрос удалён · Отменить». +- **m-4 [minor] Маленькая видимая «вода» между вопросами.** Карточки вопросов мало отделены друг от друга визуально, при пролистывании они сливаются в стену форм. Стоит увеличить вертикальный отступ между карточками или добавить разделитель. + +--- + +## 1.5. Вопрос 7 — обрыв длинного варианта + +![Q7 с обрезанным вариантом + загрузка файла](screens/05_questions_bottom.jpg) + +Это один из самых наглядных багов: + +- **M-6 [major] Длинный текст варианта обрезается.** В Q7 первый вариант отображается как «Максимальное количество токенов, которое модель может о…» — текст уходит за правый край однострочного `<input>`. Для содержательных тестов (особенно медицинских) ответы часто длинные. Нужно: либо `<textarea>` с автовысотой, либо горизонтальный скролл с tooltip всего текста на ховере. +- **m-5 [minor] Загрузка файла «Документ в вопросы» — без drag-and-drop, без ограничений по размеру/формату на UI, без обратной связи.** Подсказка «PDF, Word или текст — вставьте в черновик вопросов» — хорошая по-человечески, но не объясняет, что произойдёт после загрузки: заменит ли существующие вопросы, добавит ли в конец, есть ли превью результата. + +--- + +## 1.6. Кнопка «Сохранить черновик» в середине + История + +![Сохранить + История](screens/06_save_history.jpg) + +Здесь главная архитектурная проблема страницы: + +- **C-3 [critical] Кнопка «Сохранить черновик» расположена в середине страницы.** Сразу после неё ниже идут ещё две большие секции — «История» и «Показ в каталоге». Пользователь, открывший «Показ в каталоге» и поменявший там аудиторию, психологически ищет «Сохранить» внизу страницы — но там его нет. Очень высокий риск потерять изменения. + - Решения, любое или все: (а) sticky-панель сохранения внизу страницы; (б) дубль кнопки после последней секции; (в) автосохранение черновика; (г) предупреждение перед уходом со страницы при наличии несохранённых изменений. +- **M-7 [major] Раздел «Прохождения» показывает сырые ENUM-значения.** Видно `v1 · in_progress` — это техническое значение, а не пользовательский текст. Должно быть «в процессе» / «пройдено» / «не пройдено», лучше с цветной плашкой-индикатором. +- **M-8 [major] Дубль кнопки «К списку».** Хлебная крошка «← к списку» наверху + кнопка «К списку» рядом с «Сохранить черновик» — две точки выхода с разным визуальным весом. Кнопка справа от primary-кнопки создаёт ложное ощущение симметричности с действием. Оставить либо крошку, либо превратить вторую кнопку в текстовую ссылку «Отмена». + +--- + +## 1.7. «Показ в каталоге» — Видимость и фильтры + +![Видимость](screens/07_catalog_visibility.jpg) + +- **M-9 [major] Контрол «Видимость» неясен по текущему состоянию.** Кнопка «Скрыть из списка» — это сейчас действие или текущее состояние? Если тест уже скрыт — кнопка должна называться «Показать в списке». Лучше — переключатель (toggle/switch) с подписью «Тест виден в каталоге», чтобы текущее состояние читалось без действий. +- **m-6 [minor] Поле поиска и два селекта** «Все отделы» / «Все» расположены без подписей — что делает второй селект, без раскрытия не понятно. Нужны явные label или persistent placeholder. + +--- + +## 1.8. Список «Кому выдать» — 147 сотрудников + +![Список сотрудников](screens/08_catalog_employees.jpg) + +Этот блок — корень главной 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 и сразу снимает большую часть пользовательской боли. + +--- + +*Если по какому-то пункту нужно больше деталей или хочется, чтобы я подготовил визард-сценарий «Назначить» в виде последовательных макетов — скажите.* diff --git a/docs/screens/01_header_intro.jpg b/docs/screens/01_header_intro.jpg new file mode 100644 index 0000000..b0c0238 Binary files /dev/null and b/docs/screens/01_header_intro.jpg differ diff --git a/docs/screens/02_about_test.jpg b/docs/screens/02_about_test.jpg new file mode 100644 index 0000000..9011ac4 Binary files /dev/null and b/docs/screens/02_about_test.jpg differ diff --git a/docs/screens/03_questions_top.jpg b/docs/screens/03_questions_top.jpg new file mode 100644 index 0000000..f4b09ca Binary files /dev/null and b/docs/screens/03_questions_top.jpg differ diff --git a/docs/screens/04_questions_mid.jpg b/docs/screens/04_questions_mid.jpg new file mode 100644 index 0000000..244db73 Binary files /dev/null and b/docs/screens/04_questions_mid.jpg differ diff --git a/docs/screens/05_questions_bottom.jpg b/docs/screens/05_questions_bottom.jpg new file mode 100644 index 0000000..ab166f6 Binary files /dev/null and b/docs/screens/05_questions_bottom.jpg differ diff --git a/docs/screens/06_save_history.jpg b/docs/screens/06_save_history.jpg new file mode 100644 index 0000000..189184d Binary files /dev/null and b/docs/screens/06_save_history.jpg differ diff --git a/docs/screens/07_catalog_visibility.jpg b/docs/screens/07_catalog_visibility.jpg new file mode 100644 index 0000000..b3c09eb Binary files /dev/null and b/docs/screens/07_catalog_visibility.jpg differ diff --git a/docs/screens/08_catalog_employees.jpg b/docs/screens/08_catalog_employees.jpg new file mode 100644 index 0000000..be9a543 Binary files /dev/null and b/docs/screens/08_catalog_employees.jpg differ diff --git a/docs/Рекомендации UX по экранам теста.md b/docs/Рекомендации UX по экранам теста.md new file mode 100644 index 0000000..dae418e --- /dev/null +++ b/docs/Рекомендации UX по экранам теста.md @@ -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. **Стратегический:** хлебные крошки и внутренняя навигация по разделу; масштабирование списка назначений; пустые состояния; продуктовая аналитика (например, доходят ли авторы до «Показ в каталоге»). + +--- + +*Документ можно дополнять по мере внедрения и новых скринов.* diff --git a/docs/Словарь UX-UI-IA терминов.md b/docs/Словарь UX-UI-IA терминов.md new file mode 100644 index 0000000..e9839ad --- /dev/null +++ b/docs/Словарь UX-UI-IA терминов.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-дизайну. + +--- + +*— Справочник можно дополнять по мере появления новых терминов —* diff --git a/flask_app/README.md b/flask_app/README.md index 8508fcd..1b85514 100644 --- a/flask_app/README.md +++ b/flask_app/README.md @@ -1,17 +1,24 @@ -# Flask-контур тестирования (тот же стек, отдельный деплой) +# Flask-контур тестирования (целевой стек) -Здесь — **новое** приложение на **Python / Flask** в духе `HR_TG_Bot/tgFlaskForm` (шаблоны + серверный рендер, без React). Старый стек (`backend/` + `frontend/`) пока не удаляется: оба контура могут существовать параллельно, пока не зафиксирована политика «один источник записи» и cutover. +Приложение на **Python 3.11 / Flask 3 / Jinja2** в духе +`HR_TG_Bot/tgFlaskForm` (серверный рендер, без React). На этом контуре +работает прод **[edullm.pirogov.ai](https://edullm.pirogov.ai/)**. + +Старый стек (`backend/` + `frontend/`) пока не удаляется: оба контура +существуют параллельно до закрытия паритета (спринты **E1.4–E1.6** в +[`docs/migration-final.md`](../docs/migration-final.md)). ## Запуск в Docker (рекомендуется) -Из **корня** репозитория TestingWebApp. Сервис **не** зависит от `testing-backend` и **не** требует внешней сети Postgres для старта (только внутренняя сеть compose). +Из **корня** репозитория TestingWebApp. `docker-compose.dev.yml` уже +подключён к сети `postgres` (`hr_postgres_dev_net`) и пробрасывает +`DATABASE_URL`, `HR_AUTH`, `HR_DATABASE_URL`, `DEEPSEEK_API_KEY` / +`OPENAI_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` из корневого `.env`. ```bash docker compose -f docker-compose.dev.yml up -d --build testing-flask ``` -Когда подключите БД из контейнера к `hr_postgres_dev` / `clinic_tests`, в `docker-compose.dev.yml` у сервиса `testing-flask` добавьте сеть `postgres` (как у `testing-backend`). - - **URL:** http://localhost:3108 - **Проверка:** http://localhost:3108/health @@ -42,16 +49,28 @@ python run.py - Один **стек** с кабинетом / мини-приложением — проще переносить экраны и запросы из `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`. - **Отдельный** процесс и порт — без риска сломать текущий `docker-compose.dev.yml` с Node до готовности. -## Дальнейшие шаги (код) - -Этот каталог — место разработки **Этапа 1** ([migration-final.md](../docs/migration-final.md)). - -1. Подключить БД **`clinic_tests`** (схема не меняется), psycopg2-пул в стиле `tgFlaskForm/db/`. -2. Перенести 22 эндпоинта Express из `backend/` в blueprint'ы Flask, ориентируясь на чек-лист в [migration-final-inventory.md](../docs/migration-final-inventory.md). -3. Перенести экраны React (`frontend/src/pages/*`) в Jinja-шаблоны `app/templates/`, повторяя мобильный UX [Спринта 3](../docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). -4. Когда паритет закрыт — `docker-compose.dev.yml` указывает на этот сервис как основной, `backend/` и `frontend/` уходят. - -ETL `clinic_tests → hr_bot_test` и слияние с `tgFlaskForm` — это **Этап 2**, на будущее ([migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md)). +## Состояние спринтов + +Этот каталог — место разработки **Этапа 1** +([migration-final.md](../docs/migration-final.md)). + +| Спринт | Что входит | Статус | +|---|---|---| +| 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 / check / improve). | ✅ | + +Чек-лист эндпоинтов и gap-analysis с `tgFlaskForm` — +[`migration-final-inventory.md`](../docs/migration-final-inventory.md). +ETL `clinic_tests → hr_bot_test` и слияние с `tgFlaskForm` — это +**Этап 2**, на будущее +([migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md)). ## Связанные документы diff --git a/flask_app/app/__init__.py b/flask_app/app/__init__.py index e2678b8..e7af15b 100644 --- a/flask_app/app/__init__.py +++ b/flask_app/app/__init__.py @@ -13,6 +13,31 @@ from datetime import timedelta from flask import Flask, jsonify, render_template, request +_ROLE_LABELS = { + 'employee': 'Сотрудник', + 'manager': 'Руководитель', + 'hr': 'HR', +} + + +def _format_role(role: str | None) -> str: + return _ROLE_LABELS.get((role or '').strip().lower(), '') + + +def _format_surname_with_initials(full_name: str | None, fallback: str | None = None) -> str: + name = (full_name or '').strip() + if not name: + return (fallback or '—').strip() or '—' + parts = [p for p in name.replace('\xa0', ' ').split(' ') if p] + if len(parts) < 2: + return name + surname = parts[0] + initials = [] + for p in parts[1:3]: + initials.append(f'{p[0].upper()}.') + return f"{surname} {' '.join(initials)}".strip() + + def create_app() -> Flask: app = Flask( __name__, @@ -49,11 +74,15 @@ def create_app() -> Flask: @app.context_processor def _inject_globals(): + ui_variant = (os.environ.get('UI_VARIANT') or 'modern').strip().lower() or 'modern' return { 'current_user': _current_user(), 'hr_auth_enabled': is_hr_auth_enabled(), 'dev_ui': is_dev_ui(), 'assignment_ui': is_assignment_feature_enabled(), + 'ui_variant': ui_variant, + 'format_name_short': _format_surname_with_initials, + 'format_role': _format_role, } @app.errorhandler(404) diff --git a/flask_app/app/services/editor_content.py b/flask_app/app/services/editor_content.py index c1339a8..c1cef83 100644 --- a/flask_app/app/services/editor_content.py +++ b/flask_app/app/services/editor_content.py @@ -10,6 +10,7 @@ from sqlalchemy import text from ..db import get_engine from ..messages import RU from .test_access import is_test_author +from .test_chain import has_any_attempt_for_test class HttpError(Exception): @@ -81,7 +82,13 @@ def get_editor_content(user_id: str, test_id: str) -> dict: if not tv: raise HttpError(400, 'Нет активной версии теста.') version_id = tv['id'] + version_count_row = conn.execute( + text('SELECT COUNT(*) AS n FROM test_versions WHERE test_id = :id'), + {'id': test_id}, + ).mappings().first() + version_count = int(version_count_row['n'] or 0) questions = load_questions_for_version(conn, version_id, include_correct=True) + has_attempts = has_any_attempt_for_test(conn, test_id) return { 'test': { @@ -89,6 +96,9 @@ def get_editor_content(user_id: str, test_id: str) -> dict: 'title': tr['title'], 'description': tr['description'], 'passingThreshold': tr['passing_threshold'], + 'hasAttempts': bool(has_attempts), + 'versionCount': version_count, + 'hasForkRisk': bool(has_attempts) or version_count > 1, }, 'activeVersionId': str(version_id), 'questions': questions, diff --git a/flask_app/app/services/test_attempt.py b/flask_app/app/services/test_attempt.py new file mode 100644 index 0000000..ac85ffe --- /dev/null +++ b/flask_app/app/services/test_attempt.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +from sqlalchemy import text + +from ..services.test_access import is_test_author, user_has_test_access + + +class HttpError(Exception): + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + self.message = message + + +def _sort_uuid_strings(items) -> list[str]: + return sorted({str(x) for x in (items or []) if x is not None}) + + +def _same_selection(selected, correct_ids) -> bool: + a = _sort_uuid_strings(selected) + b = _sort_uuid_strings(correct_ids) + return a == b + + +def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]: + qrows = conn.execute( + text( + 'SELECT id, text, question_order, has_multiple_answers ' + 'FROM questions WHERE test_version_id = :v ORDER BY question_order' + ), + {'v': test_version_id}, + ).mappings().all() + out = [] + for q in qrows: + orows = conn.execute( + text( + 'SELECT id, text, is_correct, option_order ' + 'FROM answer_options WHERE question_id = :q ORDER BY option_order' + ), + {'q': q['id']}, + ).mappings().all() + opts = [] + for o in orows: + base = { + 'id': str(o['id']), + 'text': o['text'], + 'optionOrder': o['option_order'], + } + if include_correct: + base['isCorrect'] = bool(o['is_correct']) + opts.append(base) + out.append( + { + 'id': str(q['id']), + 'text': q['text'], + 'questionOrder': q['question_order'], + 'hasMultipleAnswers': bool(q['has_multiple_answers']), + 'options': opts, + } + ) + return out + + +def start_attempt(eng, user_id: str, test_id: str) -> dict: + acc = user_has_test_access(user_id, test_id) + if not acc.ok: + raise HttpError(404, 'Тест не найден.') + with eng.begin() as conn: + tv = conn.execute( + text( + 'SELECT id AS test_version_id FROM test_versions ' + 'WHERE test_id = :id AND is_active = true LIMIT 1' + ), + {'id': test_id}, + ).mappings().first() + if not tv: + raise HttpError(404, 'Нет активной версии теста.') + version_id = tv['test_version_id'] + mx = conn.execute( + text( + 'SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts ' + 'WHERE test_version_id = :v AND user_id = :u' + ), + {'v': version_id, 'u': user_id}, + ).mappings().first() + next_n = int(mx['n'] or 0) + 1 + a = conn.execute( + text( + "INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status) " + "VALUES (:v, :u, :n, 'in_progress') " + 'RETURNING id, test_version_id, user_id, attempt_number, status, started_at' + ), + {'v': version_id, 'u': user_id, 'n': next_n}, + ).mappings().first() + return {'attempt': dict(a)} + + +def get_play_content(eng, user_id: str, test_id: str, attempt_id: str) -> dict: + with eng.connect() as conn: + a = conn.execute( + text( + '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 = :a' + ), + {'a': attempt_id}, + ).mappings().first() + if not a: + raise HttpError(404, 'Попытка не найдена.') + if str(a['test_id']) != str(test_id): + raise HttpError(404, 'Попытка не найдена.') + if str(a['user_id']) != str(user_id): + raise HttpError(403, 'Доступ запрещён.') + if a['status'] != 'in_progress': + raise HttpError(400, 'Попытка уже завершена.') + qs = load_questions_for_version(conn, a['test_version_id'], include_correct=False) + return { + 'testTitle': a['title'], + 'passingThreshold': a['passing_threshold'], + 'attemptId': str(a['id']), + 'questions': qs, + } + + +def submit_attempt(eng, user_id: str, test_id: str, attempt_id: str, raw_answers: dict | None) -> dict: + answers = raw_answers if isinstance(raw_answers, dict) else {} + with eng.begin() as conn: + a = conn.execute( + text( + 'SELECT id, user_id, status, test_version_id ' + 'FROM test_attempts WHERE id = :a FOR UPDATE' + ), + {'a': attempt_id}, + ).mappings().first() + if not a: + raise HttpError(404, 'Попытка не найдена.') + link = conn.execute( + text( + 'SELECT t.passing_threshold, tv.test_id ' + 'FROM test_versions tv ' + 'INNER JOIN tests t ON t.id = tv.test_id ' + 'WHERE tv.id = :v' + ), + {'v': a['test_version_id']}, + ).mappings().first() + if not link: + raise HttpError(404, 'Тест не найден.') + if str(link['test_id']) != str(test_id): + raise HttpError(404, 'Попытка не найдена.') + if str(a['user_id']) != str(user_id): + raise HttpError(403, 'Доступ запрещён.') + if a['status'] != 'in_progress': + raise HttpError(400, 'Попытка уже завершена.') + + qrows = conn.execute( + text('SELECT id FROM questions WHERE test_version_id = :v'), + {'v': a['test_version_id']}, + ).mappings().all() + if not qrows: + raise HttpError(400, 'В тесте нет вопросов.') + + opts = conn.execute( + text( + '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 = :v' + ), + {'v': a['test_version_id']}, + ).mappings().all() + + by_q = {} + for o in opts: + qid = str(o['question_id']) + if qid not in by_q: + by_q[qid] = {'all': set(), 'correct': []} + by_q[qid]['all'].add(str(o['id'])) + if o['is_correct']: + by_q[qid]['correct'].append(str(o['id'])) + + correct_count = 0 + for q in qrows: + qid = str(q['id']) + selected = answers.get(qid, []) + if not isinstance(selected, list): + selected = [str(selected)] + selected = [str(x) for x in selected] + g = by_q.get(qid, {'all': set(), 'correct': []}) + for sid in selected: + if sid not in g['all']: + raise HttpError(400, 'Некорректный вариант ответа.') + if _same_selection(selected, g['correct']): + correct_count += 1 + + total = len(qrows) + percent = (correct_count / total) * 100 if total else 0 + threshold = int(link['passing_threshold'] or 0) + passed = percent + 1e-9 >= threshold + + conn.execute(text('DELETE FROM user_answers WHERE attempt_id = :a'), {'a': attempt_id}) + for q in qrows: + qid = str(q['id']) + selected = answers.get(qid, []) + if not isinstance(selected, list): + selected = [str(selected)] + selected = [str(x) for x in selected] + conn.execute( + text( + 'INSERT INTO user_answers (attempt_id, question_id, selected_options) ' + 'VALUES (:a, :q, :s::uuid[])' + ), + {'a': attempt_id, 'q': q['id'], 's': selected}, + ) + conn.execute( + text( + "UPDATE test_attempts SET status = 'completed', completed_at = CURRENT_TIMESTAMP, " + 'correct_count = :c, total_questions = :t, passed = :p WHERE id = :a' + ), + {'a': attempt_id, 'c': correct_count, 't': total, 'p': passed}, + ) + + review = build_review_from_db(eng, attempt_id) + return { + 'attemptId': attempt_id, + 'correctCount': correct_count, + 'totalQuestions': total, + 'percent': round(percent, 1), + 'passed': passed, + 'passingThreshold': threshold, + 'review': review, + } + + +def build_review_from_db(eng, attempt_id: str) -> dict: + with eng.connect() as conn: + a = conn.execute( + text( + '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 = :a' + ), + {'a': attempt_id}, + ).mappings().first() + if not a: + raise HttpError(404, 'Попытка не найдена.') + if a['status'] != 'completed': + raise HttpError(400, 'Попытка не завершена.') + questions = load_questions_for_version(conn, a['test_version_id'], include_correct=True) + uans = conn.execute( + text('SELECT question_id, selected_options FROM user_answers WHERE attempt_id = :a'), + {'a': attempt_id}, + ).mappings().all() + + sel_by_q = {str(r['question_id']): [str(x) for x in (r['selected_options'] or [])] for r in uans} + total = int(a['total_questions'] or len(questions)) + percent = round(((a['correct_count'] or 0) / total) * 100, 1) if total else 0 + + q_out = [] + for q in questions: + selected = _sort_uuid_strings(sel_by_q.get(str(q['id']), [])) + correct = _sort_uuid_strings([o['id'] for o in q['options'] if o.get('isCorrect')]) + selected_set = set(selected) + q_out.append( + { + 'id': q['id'], + 'text': q['text'], + 'hasMultipleAnswers': q['hasMultipleAnswers'], + 'isUserCorrect': _same_selection(selected, correct), + 'options': [ + { + 'id': o['id'], + 'text': o['text'], + 'isCorrect': o.get('isCorrect', False), + 'selected': o['id'] in selected_set, + } + for o in q['options'] + ], + } + ) + + return { + 'attemptId': str(a['id']), + 'testId': str(a['test_id']), + 'testTitle': a['title'], + 'passingThreshold': int(a['passing_threshold'] or 0), + 'correctCount': int(a['correct_count'] or 0), + 'totalQuestions': total, + 'percent': percent, + 'passed': bool(a['passed']), + 'startedAt': a['started_at'].isoformat() if a['started_at'] else None, + 'completedAt': a['completed_at'].isoformat() if a['completed_at'] else None, + 'attempterUserId': str(a['user_id']), + 'attempterName': a['attempter_name'], + 'attempterLogin': a['attempter_login'], + 'questions': q_out, + } + + +def get_attempt_review_for_user(eng, current_user_id: str, test_id: str, attempt_id: str) -> dict: + with eng.connect() as conn: + row = conn.execute( + text( + '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 = :a' + ), + {'a': attempt_id}, + ).mappings().first() + if not row: + raise HttpError(404, 'Попытка не найдена.') + if str(row['test_id']) != str(test_id): + raise HttpError(404, 'Попытка не найдена.') + is_owner = str(row['user_id']) == str(current_user_id) + is_author = is_test_author(row['created_by'], current_user_id) + if not is_owner and not is_author: + raise HttpError(403, 'Доступ запрещён.') + return build_review_from_db(eng, attempt_id) + + +def list_test_attempts_for_author(eng, author_id: str, test_id: str) -> list[dict]: + with eng.connect() as conn: + t = conn.execute( + text('SELECT id, created_by FROM tests WHERE id = :id'), + {'id': test_id}, + ).mappings().first() + if not t: + raise HttpError(404, 'Тест не найден.') + if not is_test_author(t['created_by'], author_id): + raise HttpError(403, 'Доступ запрещён.') + rows = conn.execute( + text( + '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 = :id ' + 'ORDER BY ta.started_at DESC NULLS LAST LIMIT 200' + ), + {'id': test_id}, + ).mappings().all() + return [dict(r) for r in rows] diff --git a/flask_app/app/static/css/app.css b/flask_app/app/static/css/app.css index 643152e..058891c 100644 --- a/flask_app/app/static/css/app.css +++ b/flask_app/app/static/css/app.css @@ -1,17 +1,668 @@ -/* Точечные стили поверх Tailwind CDN. - В E1.0 файл почти пустой — задаёт только сглаживание иконок и базовый focus-ring, - чтобы кнопки/ссылки были консистентны со стилем кабинета HR. */ +/* Базовые токены и точечные стили в духе webapp-nginx/cabinet-theme. */ + +:root { + --surface: #ffffff; + --surface-container-low: #f3f8f9; + --surface-container: #eaf3f5; + --on-surface: #0d1b1d; + --on-surface-variant: #3d5357; + --primary: #007168; + --primary-hover: #00645b; + --outline-variant: #b9bc94; + --shadow-card: 0 8px 40px rgba(0, 0, 0, 0.08); + --radius-card: 2rem; + --max-content: 42rem; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100dvh; + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background: var(--surface-container-low); + color: var(--on-surface); + -webkit-tap-highlight-color: transparent; + line-height: 1.45; +} + +h1, +h2, +h3 { + letter-spacing: -0.02em; +} .material-symbols-outlined { + font-family: 'Material Symbols Outlined', sans-serif; + font-weight: normal; + font-style: normal; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, - 'opsz' 20; + 'opsz' 24; + direction: ltr; + -webkit-font-feature-settings: 'liga'; + font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; } :focus-visible { - outline: 2px solid #6366f1; /* brand-500 */ + outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 6px; } + +/* Небольшой "cabinet" акцент карточек/кнопок без переписывания шаблонов. */ +.rounded-2xl.bg-white, +.rounded-xl.bg-white { + border-color: color-mix(in srgb, var(--outline-variant) 38%, transparent); +} + +.bg-brand-600 { + background-color: var(--primary) !important; +} + +.hover\:bg-brand-700:hover { + background-color: var(--primary-hover) !important; +} + +/* ------------------------------------------------------------------ */ +/* UI variants (оба режима на Flask, отличие только в компоновке UI). */ +/* ------------------------------------------------------------------ */ + +/* Modern: плотная колонка и акцент на карточный контент. */ +body.ui-modern .max-w-2xl { + max-width: 42rem !important; +} + +body.ui-modern main { + padding-top: 1.25rem; +} + +/* Legacy: идентичный cabinet layout. */ +body.ui-legacy .max-w-2xl { + max-width: 42rem !important; +} + +body.ui-legacy .cabinet-app { + min-height: 100dvh; + display: flex; + flex-direction: column; + background: var(--surface); +} + +body.ui-legacy .cabinet-header { + position: sticky; + top: 0; + z-index: 20; + background: color-mix(in srgb, var(--surface) 88%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); +} + +body.ui-legacy .cabinet-header__inner { + max-width: var(--max-content); + margin: 0 auto; + padding-top: max(0.75rem, env(safe-area-inset-top, 0px)); + padding-bottom: 0.75rem; + padding-left: max(1.25rem, env(safe-area-inset-left, 0px) + 0.5rem); + padding-right: max(1.25rem, env(safe-area-inset-right, 0px) + 0.5rem); + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +body.ui-legacy .cabinet-brand { + display: flex; + align-items: center; + gap: 0.65rem; + color: var(--on-surface); + text-decoration: none; + min-width: 0; +} + +body.ui-legacy .cabinet-brand:hover { + text-decoration: none; + color: var(--on-surface); +} + +body.ui-legacy .cabinet-brand__logo { + width: 2rem; + height: 2rem; + object-fit: contain; + display: block; +} +body.ui-legacy .login-logo__img { + width: 96px; + height: 96px; + object-fit: contain; + display: block; + margin: 0 auto 0.5rem; +} +body.ui-legacy .cabinet-brand__icon { + font-size: 1.75rem; + color: var(--primary); + background: var(--surface-container-low); + border-radius: 0.75rem; + padding: 0.35rem; + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + flex-shrink: 0; +} + +body.ui-legacy .cabinet-brand__title { + font-family: 'Manrope', 'Inter', sans-serif; + font-weight: 800; + font-size: 1rem; + line-height: 1.2; + letter-spacing: -0.02em; +} + +body.ui-legacy .cabinet-header__actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + +body.ui-legacy .cabinet-user { + font-size: 0.8rem; + color: var(--on-surface-variant); + text-align: right; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: none; +} + +@media (min-width: 480px) { + body.ui-legacy .cabinet-user { + display: inline; + } +} + +body.ui-legacy .cabinet-user__role { + color: var(--secondary, #506965); + font-weight: 500; +} + +body.ui-legacy .cabinet-main { + flex: 1; + max-width: var(--max-content); + width: 100%; + margin: 0 auto; + padding: 1.25rem 1.25rem calc(2.5rem + env(safe-area-inset-bottom, 0px)); +} + +body.ui-legacy main { + padding-top: 0; +} + +body.ui-legacy .rounded-2xl.bg-white, +body.ui-legacy .rounded-xl.bg-white { + border-radius: 0.85rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +/* Legacy catalog (портировано из старого webapp) */ +body.ui-legacy .legacy-list-shell { + max-width: 42rem; + margin: 0 auto; +} + +body.ui-legacy .legacy-list-title { + font-size: 1.5rem; + margin: 0 0 0.75rem; +} + +body.ui-legacy .legacy-list-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0 0 1rem; +} + +body.ui-legacy .legacy-list-subtitle { + font-size: 1.1rem; + margin: 1.5rem 0 0.5rem; +} + +body.ui-legacy .btn { + font-family: inherit; + font-size: 0.9375rem; + font-weight: 600; + padding: 0.55rem 1.1rem; + border-radius: 0.75rem; + border: 1.5px solid transparent; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s; + text-decoration: none; +} + +body.ui-legacy .btn-ghost { + background: transparent; + color: var(--primary); + border-color: color-mix(in srgb, var(--outline-variant) 70%, transparent); +} + +body.ui-legacy .btn-ghost:hover { + background: var(--surface-container); + border-color: var(--primary); + text-decoration: none; +} + +body.ui-legacy .text-muted { + color: var(--on-surface-variant); + font-size: 0.875rem; +} + +body.ui-legacy .list-stack { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +body.ui-legacy .list-row { + display: block; + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + border-radius: 1rem; + padding: 0.9rem 1rem; + background: var(--surface); + transition: border-color 0.15s, box-shadow 0.15s; +} + +body.ui-legacy .list-row--split { + display: flex; + flex-direction: row; + align-items: stretch; + padding: 0; + overflow: hidden; + gap: 0; +} + +body.ui-legacy .list-row__main { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +body.ui-legacy .list-row__link { + display: flex; + flex-direction: column; + flex: 1 1 auto; + padding: 0.9rem 1rem; + text-decoration: none; + color: inherit; +} + +body.ui-legacy .list-row__title { + display: block; + color: var(--on-surface); + font-weight: 600; +} + +body.ui-legacy .list-row__meta { + color: var(--on-surface-variant); + font-size: 0.8rem; + display: block; + margin-top: 0.25rem; +} + +body.ui-legacy .list-row__meta-tail { + white-space: nowrap; +} + +body.ui-legacy .list-row__side { + display: flex; + align-items: center; + padding: 0.5rem 0.9rem 0.5rem 0; + flex-shrink: 0; + border-left: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); +} + +body.ui-legacy .list-row--hidden { + border-style: dashed; + opacity: 0.95; +} + +body.ui-legacy .link-back { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.9rem; + font-weight: 500; + margin: 0 0 1rem; +} + +body.ui-legacy .callout { + border-radius: 1rem; + padding: 0.75rem 1rem; + font-size: 0.9rem; + font-weight: 500; + margin: 0 0 1rem; +} + +body.ui-legacy .callout--warning { + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; +} + +body.ui-legacy .muted, +body.ui-legacy .text-muted, +body.ui-legacy .text-secondary { + color: #506965; + font-size: 0.875rem; +} + +body.ui-legacy .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +body.ui-legacy .form-label { + display: block; + font-size: 0.9rem; + font-weight: 500; + color: var(--on-surface); + margin-bottom: 0.35rem; +} + +body.ui-legacy .form-input { + width: 100%; + padding: 11px 13px; + border: 1.5px solid var(--outline-variant); + border-radius: 0.75rem; + font-size: 15px; + font-family: inherit; + outline: none; + background: var(--surface-container-low); + color: var(--on-surface); + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; +} + +body.ui-legacy .form-input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(0, 113, 104, 0.12); + background: #fff; +} + +body.ui-legacy .surface-card { + background: var(--surface); + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + border-radius: 1rem; + padding: 1rem 1.1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +body.ui-legacy .cabinet-brick { + margin-bottom: 1.1rem; +} + +body.ui-legacy .cabinet-brick--hero { + padding: 0.1rem 0 0.6rem; + border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 45%, transparent); + margin-bottom: 1.25rem; +} + +.hero-brick__nav { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--ink-500, #6b7280); +} +.hero-brick__meta { + display: inline-flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: baseline; + color: var(--ink-500, #6b7280); +} +.hero-brick__sep { opacity: 0.55; } + +.hero-brick__title { + display: block; + width: 100%; + margin-top: 0.5rem; + border: 1px solid transparent; + background: transparent; + font-size: 1.65rem; + line-height: 1.2; + font-weight: 700; + padding: 0.3rem 0.4rem; + border-radius: 0.5rem; + outline: none; + resize: none; + overflow: hidden; + white-space: pre-wrap; + word-break: break-word; + font-family: inherit; + min-height: 2.4rem; +} +.hero-brick__title:hover { border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); } +.hero-brick__title:focus { border-color: var(--primary, #0d9488); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #0d9488) 18%, transparent); background: #fff; } + +.hero-brick__desc { + display: block; + width: 100%; + margin-top: 0.35rem; + border: 1px solid transparent; + background: transparent; + font-size: 0.95rem; + color: var(--ink-700, #374151); + padding: 0.3rem 0.4rem; + border-radius: 0.5rem; + resize: none; + overflow: hidden; + outline: none; + font-family: inherit; +} +.hero-brick__desc:hover { border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); } +.hero-brick__desc:focus { border-color: var(--primary, #0d9488); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #0d9488) 18%, transparent); background: #fff; } + +.hero-brick__chips { + margin-top: 0.65rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} +.hero-brick__chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.55rem; + background: color-mix(in srgb, var(--surface, #fff) 80%, var(--outline-variant, #e5e7eb)); + border: 1px solid color-mix(in srgb, var(--outline-variant, #e5e7eb) 70%, transparent); + border-radius: 999px; + font-size: 0.85rem; + color: var(--ink-700, #374151); + cursor: pointer; +} +.hero-brick__chip--readonly { cursor: default; } +.hero-brick__chip input[type="number"] { + width: 3.2rem; + border: none; + background: transparent; + text-align: right; + font: inherit; + outline: none; + padding: 0; +} +.hero-brick__chip input[type="checkbox"] { accent-color: var(--primary, #0d9488); } + +body.ui-legacy .cabinet-disclosure { + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + border-radius: 1rem; + background: var(--surface); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +body.ui-legacy .cabinet-disclosure__summary { + cursor: pointer; + list-style: none; + user-select: none; + padding: 0.85rem 1rem 0.75rem; + font-size: 1.05rem; + border-radius: 1rem 1rem 0 0; + min-height: 2.75rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +body.ui-legacy .cabinet-disclosure__summary::-webkit-details-marker { display: none; } + +body.ui-legacy .cabinet-disclosure__summary::after { + content: 'expand_more'; + font-family: 'Material Symbols Outlined', sans-serif; + margin-left: auto; + font-size: 1.25rem; + opacity: 0.55; + transition: transform 0.2s ease; +} + +body.ui-legacy .cabinet-disclosure[open] .cabinet-disclosure__summary::after { + transform: rotate(180deg); +} + +body.ui-legacy .cabinet-disclosure__summary-text { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + min-width: 0; +} + +body.ui-legacy .cabinet-disclosure__summary-title { + font-size: 1.05rem; + line-height: 1.25; +} + +body.ui-legacy .cabinet-disclosure__summary-sub { + display: block; + font-size: 0.8rem; + font-weight: 400; + line-height: 1.3; + color: #506965; +} + +body.ui-legacy .cabinet-disclosure__body { + padding: 0.7rem 1rem 1.05rem; + border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); +} + +body.ui-legacy .test-detail-subsection { + margin-top: 1.25rem; + padding-top: 1.15rem; + border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent); +} + +body.ui-legacy .test-detail-subsection--tight { + margin-top: 0; + padding-top: 0; + border-top: none; +} + +body.ui-legacy .test-detail-subsection__title { + margin: 0 0 0.35rem; + font-size: 0.95rem; + font-weight: 600; +} + +body.ui-legacy .test-detail-hint { + margin: 0 0 0.6rem; + font-size: 0.8rem; + line-height: 1.4; + color: #506965; +} + +body.ui-legacy .test-detail-ai-panel { + padding: 0.9rem 1rem; + margin-bottom: 1.15rem; + background: var(--surface-container-low); + border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent); + border-radius: 0.85rem; + box-shadow: none; +} + +body.ui-legacy .assign-toolbar { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.65rem; +} + +@media (min-width: 520px) { + body.ui-legacy .assign-toolbar { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + } +} + +body.ui-legacy .assign-toolbar__search { + flex: 1 1 200px; +} + +body.ui-legacy .assign-list { + max-height: min(40vh, 18rem); + overflow: auto; + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + border-radius: 0.75rem; + background: var(--surface-container-low); +} + +body.ui-legacy .assign-row { + display: flex; + gap: 0.5rem; + padding: 0.65rem 0.75rem; + border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 40%, transparent); + cursor: pointer; + align-items: center; +} + +body.ui-legacy .assign-row:last-child { border-bottom: none; } +body.ui-legacy .assign-row--selected, +body.ui-legacy .assign-row:hover { background: color-mix(in srgb, var(--primary) 8%, transparent); } + +body.ui-legacy .assign-row__text { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + flex: 1; +} + +body.ui-legacy .assign-row__fio { font-weight: 600; font-size: 0.95rem; } +body.ui-legacy .assign-row__login { font-size: 0.8rem; color: #506965; font-family: ui-monospace, Menlo, monospace; } +body.ui-legacy .assign-row__meta { font-size: 0.8rem; color: #506965; line-height: 1.35; } + +body.ui-legacy .version-card-list, +body.ui-legacy .attempts-card-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} diff --git a/flask_app/app/static/img/clinic-logo.png b/flask_app/app/static/img/clinic-logo.png new file mode 100644 index 0000000..18f0acc Binary files /dev/null and b/flask_app/app/static/img/clinic-logo.png differ diff --git a/flask_app/app/static/js/editor.js b/flask_app/app/static/js/editor.js index b535bb2..fd51a65 100644 --- a/flask_app/app/static/js/editor.js +++ b/flask_app/app/static/js/editor.js @@ -25,14 +25,74 @@ const saveStatusEl = $('#save-status'); const aiStatusEl = $('#ai-status'); const chainActiveEl = $('#chain-active'); + const aiTopicEl = $('#ai-topic'); + const aiQCountEl = $('#ai-q-count'); + const aiOCountEl = $('#ai-o-count'); + const introUpdatedEl = $('#intro-updated'); + const introForkBannerEl = $('#intro-fork-banner'); + const versionsListEl = $('#versions-list'); + const attemptsListEl = $('#attempts-list'); + const visibilityBtn = $('#btn-toggle-visibility'); + const assignSearchEl = $('#assign-search'); + const assignDeptEl = $('#assign-dept'); + const assignClinicEl = $('#assign-clinic'); + const assignListEl = $('#assign-list'); + const assignSelectAllBtn = $('#assign-select-all'); + const assignSubmitBtn = $('#assign-submit'); + const assignStatusEl = $('#assign-status'); const tplQ = $('#tpl-question'); const tplO = $('#tpl-option'); let chainActive = true; + let assignPeople = []; + let assignSelected = new Set(); + let hasAnyAttempts = false; + let hasForkRisk = Boolean(initial?.test?.hasForkRisk) + || (introForkBannerEl && introForkBannerEl.dataset.forkRisk === '1'); + let baselineDraftKey = ''; + let dirtyCheckQueued = false; + + function currentDraftKey() { + return JSON.stringify(collectPayload()); + } + + function isDirty() { + return baselineDraftKey !== '' && baselineDraftKey !== currentDraftKey(); + } + + function updateForkBanner() { + if (!introForkBannerEl) return; + introForkBannerEl.style.display = (hasForkRisk && isDirty()) ? '' : 'none'; + } + + function scheduleDirtyCheck() { + if (dirtyCheckQueued) return; + dirtyCheckQueued = true; + requestAnimationFrame(() => { + dirtyCheckQueued = false; + updateForkBanner(); + }); + } + + function resetBaselineDraft() { + baselineDraftKey = currentDraftKey(); + updateForkBanner(); + } // ─── render ───────────────────────────────────────────────────────── + function syncOptionInputTypes(qNode) { + const isMulti = $('.q-multi', qNode).checked; + const qName = `q-correct-${Math.random().toString(36).slice(2)}`; + $$('.opt-correct', qNode).forEach((input) => { + input.type = isMulti ? 'checkbox' : 'radio'; + if (isMulti) input.removeAttribute('name'); + else input.setAttribute('name', qName); + input.classList.add('question-option-row__mark'); + }); + } + function renderQuestion(q) { const node = tplQ.content.firstElementChild.cloneNode(true); node._q = { id: q.id || null }; @@ -43,6 +103,7 @@ (q.options || []).forEach((o) => optsEl.appendChild(renderOption(o))); bindQuestionEvents(node); + syncOptionInputTypes(node); return node; } @@ -61,41 +122,86 @@ if (!confirm('Удалить вопрос?')) return; node.remove(); renumber(); + scheduleDirtyCheck(); }); $('.q-up', node).addEventListener('click', () => { if (node.previousElementSibling) { node.parentNode.insertBefore(node, node.previousElementSibling); renumber(); + scheduleDirtyCheck(); } }); $('.q-down', node).addEventListener('click', () => { if (node.nextElementSibling) { node.parentNode.insertBefore(node.nextElementSibling, node); renumber(); + scheduleDirtyCheck(); } }); $('.q-add-option', node).addEventListener('click', () => { $('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false })); + syncOptionInputTypes(node); + scheduleDirtyCheck(); }); $('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node)); + $('.q-multi', node).addEventListener('change', () => { + syncOptionInputTypes(node); + scheduleDirtyCheck(); + }); } function renumber() { $$('#questions .q-item').forEach((li, i) => { $('.q-num', li).textContent = `Вопрос #${i + 1}`; }); - qCountEl.textContent = $$('#questions .q-item').length; + const n = $$('#questions .q-item').length; + if (qCountEl) qCountEl.textContent = n; + const mirror = document.getElementById('q-count-mirror'); + if (mirror) mirror.textContent = n; + } + + function autoResize(el) { + if (!el) return; + el.style.height = 'auto'; + el.style.height = el.scrollHeight + 'px'; } function loadInitial() { titleEl.value = initial.test.title || ''; descEl.value = initial.test.description || ''; + autoResize(titleEl); + autoResize(descEl); + if (titleEl && titleEl.tagName === 'TEXTAREA') { + titleEl.addEventListener('input', () => autoResize(titleEl)); + titleEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') e.preventDefault(); + }); + } + if (descEl) descEl.addEventListener('input', () => autoResize(descEl)); thresholdEl.value = initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold); questionsEl.innerHTML = ''; (initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); renumber(); + if (aiTopicEl && !aiTopicEl.value.trim()) { + aiTopicEl.value = initial.test.title || ''; + } + } + + function fmtDt(iso) { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }); + } catch { + return '—'; + } + } + + function escHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, '''); } // ─── collect ─────────────────────────────────────────────────────── @@ -144,6 +250,7 @@ }), ); renumber(); + scheduleDirtyCheck(); }); $('#save-draft').addEventListener('click', async () => { @@ -167,6 +274,7 @@ saveStatusEl.textContent = data.forked ? 'Сохранено (создана новая версия — есть попытки прохождения).' : 'Сохранено.'; + resetBaselineDraft(); setTimeout(() => (saveStatusEl.textContent = ''), 4000); } catch (e) { saveStatusEl.textContent = ''; @@ -175,18 +283,24 @@ }); $('#ai-generate-test').addEventListener('click', async () => { - const shape = collectShape(); - if (!shape.length) { - alert('Сначала добавьте хотя бы один вопрос (его настройки определяют сетку).'); + const topic = (aiTopicEl?.value || titleEl.value || '').trim(); + if (!topic) { + alert('Укажите тему.'); return; } + const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7)); + const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3)); + const shape = Array.from({ length: nQ }, () => ({ + optionsCount: nO, + hasMultipleAnswers: false, + })); aiStatusEl.textContent = 'Генерируем…'; try { const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - testTitle: titleEl.value, + testTitle: topic, testDescription: descEl.value, shape, }), @@ -194,11 +308,15 @@ const data = await r.json(); if (!r.ok) throw new Error(data.error || 'AI: ошибка.'); const draft = data.draft; - if (draft.title) titleEl.value = draft.title; + if (draft.title) { + titleEl.value = draft.title; + if (aiTopicEl) aiTopicEl.value = draft.title; + } if (draft.description) descEl.value = draft.description; questionsEl.innerHTML = ''; (draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); renumber(); + scheduleDirtyCheck(); aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`; setTimeout(() => (aiStatusEl.textContent = ''), 4000); } catch (e) { @@ -242,6 +360,7 @@ questionsEl.innerHTML = ''; (draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); renumber(); + scheduleDirtyCheck(); aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`; setTimeout(() => (aiStatusEl.textContent = ''), 4000); } catch (e) { @@ -263,12 +382,6 @@ alert(msg); } - function escHtml(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') - .replace(/"/g, '"').replace(/'/g, '''); - } - const modal = $('#ai-modal'); const modalTitle = $('#ai-modal-title'); const modalBody = $('#ai-modal-body'); @@ -290,7 +403,8 @@ modal.showModal(); } - $('#ai-generate-by-title').addEventListener('click', async () => { + const aiGenerateByTitleBtn = $('#ai-generate-by-title'); + if (aiGenerateByTitleBtn) aiGenerateByTitleBtn.addEventListener('click', async () => { const title = titleEl.value.trim(); if (!title) { alert('Сначала заполните название теста.'); @@ -334,6 +448,7 @@ questionsEl.innerHTML = ''; (draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); renumber(); + scheduleDirtyCheck(); aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`; setTimeout(() => (aiStatusEl.textContent = ''), 4000); } catch (e) { @@ -342,7 +457,8 @@ } }); - $('#ai-check').addEventListener('click', async () => { + const aiCheckBtn = $('#ai-check'); + if (aiCheckBtn) aiCheckBtn.addEventListener('click', async () => { const payload = collectPayload(); if (!payload.questions.length) { alert('В тесте нет вопросов — нечего проверять.'); @@ -394,7 +510,8 @@ } }); - $('#ai-improve').addEventListener('click', async () => { + const aiImproveBtn = $('#ai-improve'); + if (aiImproveBtn) aiImproveBtn.addEventListener('click', async () => { const payload = collectPayload(); if (!payload.questions.length) { alert('В тесте нет вопросов — нечего улучшать.'); @@ -480,6 +597,7 @@ it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o))); }); modal.close(); + scheduleDirtyCheck(); aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.'; setTimeout(() => (aiStatusEl.textContent = ''), 5000); }, @@ -517,6 +635,7 @@ data.options.forEach((o) => optsEl.appendChild(renderOption(o))); $('.q-multi', node).checked = !!data.hasMultipleAnswers; } + scheduleDirtyCheck(); aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.'; setTimeout(() => (aiStatusEl.textContent = ''), 4000); } catch (e) { @@ -542,5 +661,195 @@ chainActiveEl.checked = true; }); + function renderVersions(rows) { + if (!versionsListEl) return; + versionsListEl.innerHTML = ''; + (rows || []).forEach((r) => { + const li = document.createElement('li'); + li.className = 'surface-card version-card-list__item'; + li.innerHTML = ` + <div class="version-card-list__row"> + <div class="version-card-list__main"> + <div class="version-card-list__title-line"> + <span class="font-headline" style="font-size:1rem;">v${r.version}</span> + ${r.is_active ? '<span class="code-inline" style="font-size:0.7rem;">текущая</span>' : ''} + </div> + <p class="muted mono" style="margin:.4rem 0 0; font-size:.8rem;">${fmtDt(r.created_at)}</p> + <p class="muted" style="margin:.2rem 0 0; font-size:.8rem;">Активна: ${r.is_active ? 'да' : 'нет'}</p> + </div> + </div>`; + versionsListEl.appendChild(li); + }); + } + + function renderAttempts(rows) { + if (!attemptsListEl) return; + attemptsListEl.innerHTML = ''; + (rows || []).forEach((a) => { + const when = a.completedAt ? fmtDt(a.completedAt) : (a.startedAt ? fmtDt(a.startedAt) : '—'); + const result = a.status === 'completed' && a.totalQuestions != null + ? `${a.correctCount}/${a.totalQuestions}${a.passed ? ' · зачёт' : ' · незачёт'}` + : a.status; + const li = document.createElement('li'); + li.className = 'surface-card attempts-card-list__item'; + li.innerHTML = ` + <div class="attempts-card-list__row"> + <div class="attempts-card-list__main"> + <p class="muted mono" style="margin:0; font-size:.8rem;">${when}</p> + <p style="margin:.35rem 0 0; font-weight:600;">${escHtml(a.attempterName || '—')} + ${a.attempterLogin ? `<span class="code-inline" style="font-size:11px; margin-left:6px;">${escHtml(a.attempterLogin)}</span>` : ''} + </p> + <p class="muted" style="margin:.25rem 0 0; font-size:.85rem;">v${a.testVersion} · ${escHtml(result)}</p> + </div> + ${a.status === 'completed' + ? `<a class="btn btn-ghost btn--sm attempts-card-list__action" href="/tests/${TEST_ID}/attempts/${a.id}/review">Разбор</a>` + : ''} + </div>`; + attemptsListEl.appendChild(li); + }); + } + + function renderAssignList() { + if (!assignListEl) return; + assignListEl.innerHTML = ''; + assignPeople.forEach((p) => { + const row = document.createElement('label'); + row.className = `assign-row${assignSelected.has(String(p.staffId)) ? ' assign-row--selected' : ''}`; + row.innerHTML = ` + <input type="checkbox" ${assignSelected.has(String(p.staffId)) ? 'checked' : ''} /> + <span class="assign-row__text"> + <span class="assign-row__fio">${escHtml(p.fio || '—')}</span> + ${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''} + <span class="assign-row__meta">${escHtml(p.department || '—')}${p.clinicUserId ? ' · есть учётка' : ' · нет учётки (создадим при назначении)'}</span> + </span>`; + const cb = row.querySelector('input'); + cb.addEventListener('change', () => { + const k = String(p.staffId); + if (cb.checked) assignSelected.add(k); else assignSelected.delete(k); + row.classList.toggle('assign-row--selected', cb.checked); + }); + assignListEl.appendChild(row); + }); + if (!assignPeople.length) assignListEl.innerHTML = '<p class="muted" style="padding:.75rem;">Никого не найдено.</p>'; + } + + async function loadDirectory() { + if (!assignListEl) return; + assignStatusEl.textContent = 'Загружаем…'; + try { + const params = new URLSearchParams(); + if (assignSearchEl.value.trim()) params.set('q', assignSearchEl.value.trim()); + if (assignDeptEl.value && assignDeptEl.value !== '__all__') params.set('department', assignDeptEl.value); + params.set('clinic', assignClinicEl.value || 'all'); + const r = await fetch(`/api/auth/dev/assignment-directory?${params.toString()}`); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'Не удалось загрузить сотрудников'); + assignPeople = data.people || []; + const depts = data.departments || []; + if (assignDeptEl.options.length <= 1) { + depts.forEach((d) => { + const o = document.createElement('option'); + o.value = d; + o.textContent = d; + assignDeptEl.appendChild(o); + }); + } + assignSelected = new Set(); + renderAssignList(); + assignStatusEl.textContent = ''; + } catch (e) { + assignStatusEl.textContent = e.message || 'Ошибка загрузки'; + } + } + + if (assignSearchEl) { + let t = null; + assignSearchEl.addEventListener('input', () => { + clearTimeout(t); + t = setTimeout(loadDirectory, 350); + }); + assignDeptEl.addEventListener('change', loadDirectory); + assignClinicEl.addEventListener('change', loadDirectory); + assignSelectAllBtn.addEventListener('click', () => { + assignPeople.forEach((p) => assignSelected.add(String(p.staffId))); + renderAssignList(); + }); + assignSubmitBtn.addEventListener('click', async () => { + const selectedRows = assignPeople.filter((p) => assignSelected.has(String(p.staffId))); + const userIds = selectedRows.filter((p) => p.clinicUserId).map((p) => p.clinicUserId); + const staffIds = selectedRows.filter((p) => !p.clinicUserId && p.staffId != null).map((p) => p.staffId); + if (!userIds.length && !staffIds.length) return; + assignStatusEl.textContent = 'Назначаем…'; + try { + const r = await fetch(`/api/tests/${TEST_ID}/assign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userIds, staffIds }), + }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'Ошибка назначения'); + assignStatusEl.textContent = `Назначено: ${data.count ?? selectedRows.length}`; + } catch (e) { + assignStatusEl.textContent = e.message || 'Ошибка назначения'; + } + }); + loadDirectory(); + } + + if (visibilityBtn) { + visibilityBtn.addEventListener('click', async () => { + const next = !chainActiveEl.checked; + try { + const r = await fetch(`/api/tests/${TEST_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chainActive: next }), + }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'Ошибка изменения видимости'); + chainActiveEl.checked = !!next; + chainActive = !!next; + visibilityBtn.textContent = chainActive ? 'Скрыть из списка' : 'Снова показать в списке'; + } catch (e) { + alert(e.message || 'Ошибка изменения видимости'); + } + }); + } + + Promise.all([ + fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null), + fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null), + fetch(`/api/tests/${TEST_ID}/summary`).then((r) => r.json()).catch(() => null), + ]).then(([v, a, s]) => { + if (v && Array.isArray(v.versions)) { + renderVersions(v.versions); + hasForkRisk = hasForkRisk || (v.versions.length > 1); + if (typeof v.hasAttempts === 'boolean') { + hasAnyAttempts = hasAnyAttempts || v.hasAttempts; + hasForkRisk = hasForkRisk || v.hasAttempts; + } + } + if (a && Array.isArray(a.attempts)) { + renderAttempts(a.attempts); + hasAnyAttempts = a.attempts.length > 0; + } + if (s && s.test) { + if (introUpdatedEl) introUpdatedEl.textContent = fmtDt(s.test.updated_at || s.test.updatedAt); + const versionEl = document.getElementById('intro-version'); + if (versionEl && s.test.version != null) versionEl.textContent = s.test.version; + if (typeof s.test.hasAttempts === 'boolean') { + hasAnyAttempts = hasAnyAttempts || s.test.hasAttempts; + hasForkRisk = hasForkRisk || s.test.hasAttempts; + } + if (typeof s.test.versionCount === 'number') { + hasForkRisk = hasForkRisk || s.test.versionCount > 1; + } + } + updateForkBanner(); + }); + loadInitial(); + resetBaselineDraft(); + root.addEventListener('input', scheduleDirtyCheck); + root.addEventListener('change', scheduleDirtyCheck); })(); diff --git a/flask_app/app/templates/auth/login.html b/flask_app/app/templates/auth/login.html index 01763ca..a74c465 100644 --- a/flask_app/app/templates/auth/login.html +++ b/flask_app/app/templates/auth/login.html @@ -2,57 +2,99 @@ {% block title %}Вход — Тестирование{% endblock %} {% block content %} -<section class="mx-auto max-w-md mt-8"> - <div class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6"> - <div class="flex items-center gap-2"> - <span class="material-symbols-outlined text-brand-600">login</span> - <h1 class="text-xl font-semibold">Вход в систему</h1> +{% if ui_variant == 'legacy' %} + <div class="login-page"> + <div class="login-shell"> + <div class="login-logo"> + <img src="{{ url_for('static', filename='img/clinic-logo.png') }}" + alt="Логотип клиники" class="login-logo__img" /> + <h1 class="font-headline">Тестирование</h1> + </div> + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + <div class="callout callout--error" style="margin-bottom: 1rem;"> + {% for category, msg in messages %} + {% if category == 'error' %}{{ msg }}{% endif %} + {% endfor %} + </div> + {% endif %} + {% endwith %} + + <div class="login-card"> + <form method="post" action="{{ url_for('auth.login_submit') }}" novalidate> + <input type="hidden" name="next" value="{{ next or '/' }}"> + + <div class="form-field"> + <label class="form-label" for="login-username">Логин</label> + <input id="login-username" class="form-input" type="text" name="login" + value="{{ login or '' }}" required autofocus autocomplete="username" /> + </div> + + <div class="form-field"> + <label class="form-label" for="login-password">Пароль</label> + <input id="login-password" class="form-input" type="password" name="password" + required autocomplete="current-password" /> + </div> + + <button type="submit" class="btn btn-primary">Войти</button> + </form> + </div> </div> - <p class="mt-1 text-sm text-ink-500"> - Используйте логин и пароль. - {% if hr_auth_enabled %} - Учётка кадровой системы (HR). - {% endif %} - </p> - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - <div class="mt-4 space-y-2"> - {% for category, msg in messages %} - <div class="px-3 py-2 rounded-lg text-sm - {% if category == 'error' %}bg-red-50 text-red-700 border border-red-200 - {% else %}bg-brand-50 text-brand-700 border border-brand-100{% endif %}"> - {{ msg }} - </div> - {% endfor %} - </div> - {% endif %} - {% endwith %} - - <form method="post" action="{{ url_for('auth.login_submit') }}" class="mt-5 space-y-4" novalidate> - <input type="hidden" name="next" value="{{ next or '/' }}"> - - <label class="block"> - <span class="text-sm font-medium text-ink-700">Логин</span> - <input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username" - class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900 - focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> - </label> - - <label class="block"> - <span class="text-sm font-medium text-ink-700">Пароль</span> - <input type="password" name="password" required autocomplete="current-password" - class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900 - focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> - </label> - - <button type="submit" - class="w-full inline-flex items-center justify-center gap-2 rounded-lg - bg-brand-600 hover:bg-brand-700 text-white font-medium px-4 py-2 transition"> - <span class="material-symbols-outlined text-base">login</span> - Войти - </button> - </form> </div> -</section> +{% else %} + <section class="mx-auto max-w-md mt-8"> + <div class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6"> + <div class="flex items-center gap-2"> + <span class="material-symbols-outlined text-brand-600">login</span> + <h1 class="text-xl font-semibold">Вход в систему</h1> + </div> + <p class="mt-1 text-sm text-ink-500"> + Используйте логин и пароль. + {% if hr_auth_enabled %} + Учётка кадровой системы (HR). + {% endif %} + </p> + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + <div class="mt-4 space-y-2"> + {% for category, msg in messages %} + <div class="px-3 py-2 rounded-lg text-sm + {% if category == 'error' %}bg-red-50 text-red-700 border border-red-200 + {% else %}bg-brand-50 text-brand-700 border border-brand-100{% endif %}"> + {{ msg }} + </div> + {% endfor %} + </div> + {% endif %} + {% endwith %} + + <form method="post" action="{{ url_for('auth.login_submit') }}" class="mt-5 space-y-4" novalidate> + <input type="hidden" name="next" value="{{ next or '/' }}"> + + <label class="block"> + <span class="text-sm font-medium text-ink-700">Логин</span> + <input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username" + class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900 + focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> + </label> + + <label class="block"> + <span class="text-sm font-medium text-ink-700">Пароль</span> + <input type="password" name="password" required autocomplete="current-password" + class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900 + focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> + </label> + + <button type="submit" + class="w-full inline-flex items-center justify-center gap-2 rounded-lg + bg-brand-600 hover:bg-brand-700 text-white font-medium px-4 py-2 transition"> + <span class="material-symbols-outlined text-base">login</span> + Войти + </button> + </form> + </div> + </section> +{% endif %} {% endblock %} diff --git a/flask_app/app/templates/base.html b/flask_app/app/templates/base.html index 483e698..c6837aa 100644 --- a/flask_app/app/templates/base.html +++ b/flask_app/app/templates/base.html @@ -8,7 +8,7 @@ {# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #} <script src="https://cdn.tailwindcss.com"></script> <script> - // Минимальная палитра в стиле кабинета HR. Без зависимостей от HR-репо. + // Палитра/типографика в стиле webapp-nginx (cabinet-theme). tailwind.config = { theme: { extend: { @@ -17,18 +17,19 @@ }, colors: { brand: { - 50: '#eef2ff', - 100: '#e0e7ff', - 500: '#6366f1', - 600: '#4f46e5', - 700: '#4338ca', + 50: '#ecf7f6', + 100: '#d9efec', + 300: '#9bd7d0', + 500: '#007168', + 600: '#00645b', + 700: '#00574f', }, ink: { - 900: '#0f172a', - 700: '#334155', - 500: '#64748b', - 300: '#cbd5e1', - 100: '#f1f5f9', + 900: '#0d1b1d', + 700: '#3d5357', + 500: '#506965', + 300: '#b9bc94', + 100: '#f3f8f9', }, }, }, @@ -39,7 +40,7 @@ <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link - href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" + href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet" /> <link @@ -50,65 +51,97 @@ <link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" /> {% block head %}{% endblock %} </head> -<body class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased"> - <header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/60"> - <div class="mx-auto max-w-6xl px-4 h-14 flex items-center justify-between"> - <a href="{{ url_for('main.index') }}" class="flex items-center gap-2 font-semibold text-ink-900"> - <span class="material-symbols-outlined text-brand-600">quiz</span> - <span>Тестирование</span> - </a> - <nav class="flex items-center gap-1 sm:gap-2 text-sm"> - {% if current_user %} - <a href="{{ url_for('tests.tests_list_page') }}" - class="inline-flex items-center justify-center gap-1 - min-w-10 min-h-10 px-2 sm:px-3 rounded-lg - text-ink-700 hover:bg-ink-100" - title="Каталог тестов" aria-label="Каталог тестов"> - <span class="material-symbols-outlined text-base">list_alt</span> - <span class="hidden sm:inline">Тесты</span> +<body data-ui-variant="{{ ui_variant }}" + class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased ui-{{ ui_variant }}"> + {% if ui_variant == 'legacy' %} + <div class="cabinet-app"> + <header class="cabinet-header"> + <div class="cabinet-header__inner"> + <a href="{{ url_for('tests.tests_list_page') }}" class="cabinet-brand"> + <img src="{{ url_for('static', filename='img/clinic-logo.png') }}" + alt="Логотип клиники" class="cabinet-brand__logo" /> + <div> + <div class="cabinet-brand__title">Тестирование</div> + </div> </a> - <a href="{{ url_for('settings.settings_page') }}" - class="inline-flex items-center justify-center gap-1 - min-w-10 min-h-10 px-2 sm:px-3 rounded-lg - text-ink-700 hover:bg-ink-100" - title="Настройки" aria-label="Настройки"> - <span class="material-symbols-outlined text-base">settings</span> - <span class="hidden sm:inline">Настройки</span> - </a> - <span class="hidden md:inline text-ink-500"> - {{ current_user.full_name or current_user.login }} - <span class="text-ink-300">·</span> - <span class="text-brand-700">{{ current_user.role }}</span> - </span> - <form method="post" action="{{ url_for('auth.logout') }}" class="inline"> - <button type="submit" - class="inline-flex items-center justify-center gap-1 - min-w-10 min-h-10 px-2 sm:px-3 rounded-lg - text-ink-700 hover:bg-ink-100 transition" - title="Выйти" aria-label="Выйти"> - <span class="material-symbols-outlined text-base">logout</span> - <span class="hidden sm:inline">Выйти</span> - </button> - </form> - {% else %} - <a href="{{ url_for('auth.login_page') }}" - class="inline-flex items-center gap-1 px-3 py-2 rounded-lg - text-brand-700 hover:bg-brand-50 transition min-h-10"> - <span class="material-symbols-outlined text-base">login</span> - Войти - </a> - {% endif %} - </nav> + <div class="cabinet-header__actions"> + {% if current_user %} + <span class="cabinet-user" title="{{ (current_user.full_name or current_user.login) ~ (' · ' ~ format_role(current_user.role) if format_role(current_user.role) else '') }}"> + {{ format_name_short(current_user.full_name, current_user.login) }} + {% if format_role(current_user.role) %}<span class="cabinet-user__role"> · {{ format_role(current_user.role) }}</span>{% endif %} + </span> + <form method="post" action="{{ url_for('auth.logout') }}" class="inline"> + <button type="submit" class="btn btn-ghost">Выйти</button> + </form> + {% else %} + <a href="{{ url_for('auth.login_page') }}" class="btn btn-ghost">Войти</a> + {% endif %} + </div> + </div> + </header> + <main class="cabinet-main"> + {% block content scoped %}{% endblock %} + </main> </div> - </header> - - <main class="mx-auto max-w-6xl px-4 py-6"> - {% block content %}{% endblock %} - </main> - - <footer class="mx-auto max-w-6xl px-4 py-8 text-xs text-ink-500"> - {% block footer %}testing-flask-app · Этап 1{% endblock %} - </footer> + {% else %} + <header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/50"> + <div class="mx-auto max-w-2xl px-4 h-14 flex items-center justify-between"> + <a href="{{ url_for('main.index') }}" class="flex items-center gap-2 font-semibold text-ink-900"> + <img src="{{ url_for('static', filename='img/clinic-logo.png') }}" + alt="Логотип клиники" class="h-7 w-7 object-contain" /> + <span>Тестирование</span> + </a> + <nav class="flex items-center gap-1 sm:gap-2 text-sm"> + {% if current_user %} + <a href="{{ url_for('tests.tests_list_page') }}" + class="inline-flex items-center justify-center gap-1 + min-w-10 min-h-10 px-2 sm:px-3 rounded-lg + text-ink-700 hover:bg-ink-100" + title="Каталог тестов" aria-label="Каталог тестов"> + <span class="material-symbols-outlined text-base">list_alt</span> + <span class="hidden sm:inline">Тесты</span> + </a> + <a href="{{ url_for('settings.settings_page') }}" + class="inline-flex items-center justify-center gap-1 + min-w-10 min-h-10 px-2 sm:px-3 rounded-lg + text-ink-700 hover:bg-ink-100" + title="Настройки" aria-label="Настройки"> + <span class="material-symbols-outlined text-base">settings</span> + <span class="hidden sm:inline">Настройки</span> + </a> + <span class="hidden md:inline text-ink-500"> + {{ current_user.full_name or current_user.login }} + <span class="text-ink-300">·</span> + <span class="text-brand-700">{{ format_role(current_user.role) }}</span> + </span> + <form method="post" action="{{ url_for('auth.logout') }}" class="inline"> + <button type="submit" + class="inline-flex items-center justify-center gap-1 + min-w-10 min-h-10 px-2 sm:px-3 rounded-lg + text-ink-700 hover:bg-ink-100 transition" + title="Выйти" aria-label="Выйти"> + <span class="material-symbols-outlined text-base">logout</span> + <span class="hidden sm:inline">Выйти</span> + </button> + </form> + {% else %} + <a href="{{ url_for('auth.login_page') }}" + class="inline-flex items-center gap-1 px-3 py-2 rounded-lg + text-brand-700 hover:bg-brand-50 transition min-h-10"> + <span class="material-symbols-outlined text-base">login</span> + Войти + </a> + {% endif %} + </nav> + </div> + </header> + <main class="mx-auto max-w-2xl px-4 py-6"> + {{ self.content() }} + </main> + <footer class="mx-auto max-w-2xl px-4 py-8 text-xs text-ink-500"> + {% block footer %}testing-flask-app · Этап 1{% endblock %} + </footer> + {% endif %} {% block scripts %}{% endblock %} </body> diff --git a/flask_app/app/templates/settings.html b/flask_app/app/templates/settings.html index 39cc89d..43444cf 100644 --- a/flask_app/app/templates/settings.html +++ b/flask_app/app/templates/settings.html @@ -2,13 +2,13 @@ {% block title %}Настройки — LLM{% endblock %} {% block content %} -<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6 max-w-2xl"> +<section class="{% if ui_variant == 'legacy' %}surface-card{% else %}rounded-2xl bg-white shadow-sm border border-ink-300/60{% endif %} p-6 max-w-2xl"> <div class="flex items-center gap-2"> <span class="material-symbols-outlined text-brand-600">settings</span> <h1 class="text-2xl font-semibold">Настройки</h1> </div> - <h2 class="mt-5 font-semibold">Подключение к LLM</h2> + <h2 class="mt-5 font-semibold {% if ui_variant == 'legacy' %}font-headline{% endif %}">Подключение к LLM</h2> <p class="mt-1 text-sm text-ink-500"> Ключ задаётся в <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> сервера (общий, не на пользователя). Поддерживаются DeepSeek и OpenAI-совместимые API. @@ -53,8 +53,7 @@ OPENAI_API_KEY=sk-... <div class="mt-5 flex items-center gap-3"> <button id="btn-ping" - class="inline-flex items-center gap-2 px-4 py-2 rounded-lg - bg-brand-600 hover:bg-brand-700 text-white text-sm"> + class="{% if ui_variant == 'legacy' %}btn btn-primary{% else %}inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm{% endif %}"> <span class="material-symbols-outlined text-base">cable</span> Проверить подключение </button> diff --git a/flask_app/app/templates/tests/attempt.html b/flask_app/app/templates/tests/attempt.html new file mode 100644 index 0000000..34c8f1f --- /dev/null +++ b/flask_app/app/templates/tests/attempt.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} +{% block title %}Прохождение теста{% endblock %} + +{% block content %} +<div class="test-detail-page" id="attempt-root" data-test-id="{{ test_id }}" data-attempt-id="{{ attempt_id }}"> + <p class="link-back"><a href="/tests">← к списку тестов</a></p> + <h1 class="font-headline" style="font-size:1.35rem;margin-top:0;" id="attempt-title">Загрузка…</h1> + <p class="text-muted" style="margin-top:0;" id="attempt-subtitle"></p> + <p class="error-text" id="attempt-error" style="display:none;"></p> + + <ol id="questions-list" style="padding-left:1.25rem;"></ol> + + <div class="inline-actions" style="margin-top:1rem;"> + <button type="button" class="btn btn-primary" id="submit-attempt-btn">Завершить тест</button> + </div> + + <div id="attempt-result" class="surface-card" style="display:none;margin-top:1rem;padding:1rem;"></div> +</div> + +<script> +(() => { + const root = document.getElementById('attempt-root'); + const testId = root.dataset.testId; + const attemptId = root.dataset.attemptId; + const titleEl = document.getElementById('attempt-title'); + const subEl = document.getElementById('attempt-subtitle'); + const errEl = document.getElementById('attempt-error'); + const listEl = document.getElementById('questions-list'); + const resultEl = document.getElementById('attempt-result'); + const submitBtn = document.getElementById('submit-attempt-btn'); + let playData = null; + const selections = {}; + + function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, (m) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); + } + function setErr(msg) { + errEl.textContent = msg || 'Ошибка.'; + errEl.style.display = ''; + } + function isSelected(qid, oid) { + return (selections[String(qid)] || []).includes(String(oid)); + } + function toggle(qid, oid, multi) { + const k = String(qid); + const cur = selections[k] || []; + const id = String(oid); + if (multi) { + selections[k] = cur.includes(id) ? cur.filter(x => x !== id) : [...cur, id]; + return; + } + selections[k] = [id]; + } + function renderQuestions() { + listEl.innerHTML = ''; + for (const q of (playData.questions || [])) { + const li = document.createElement('li'); + li.style.marginBottom = '1.5rem'; + li.innerHTML = '<p style="margin-top:0;margin-bottom:0.5rem;">' + esc(q.text) + '</p>'; + const ul = document.createElement('ul'); + ul.style.listStyle = 'none'; + ul.style.padding = '0'; + ul.style.margin = '0'; + for (const o of (q.options || [])) { + const row = document.createElement('li'); + row.style.marginBottom = '6px'; + const type = q.hasMultipleAnswers ? 'checkbox' : 'radio'; + const name = 'q-' + q.id; + row.innerHTML = + '<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">' + + '<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' + (isSelected(q.id, o.id) ? 'checked' : '') + ' />' + + '<span>' + esc(o.text) + '</span>' + + '</label>'; + const input = row.querySelector('input'); + input.addEventListener('change', () => { + toggle(q.id, o.id, q.hasMultipleAnswers); + renderQuestions(); + }); + ul.appendChild(row); + } + li.appendChild(ul); + listEl.appendChild(li); + } + } + + async function load() { + try { + const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/play'); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.'); + playData = data; + titleEl.textContent = data.testTitle || 'Прохождение теста'; + subEl.textContent = 'Порог зачёта: ' + (data.passingThreshold ?? 0) + '%.'; + if (!Array.isArray(data.questions) || !data.questions.length) { + setErr('В активной версии нет вопросов.'); + submitBtn.disabled = true; + return; + } + renderQuestions(); + } catch (e) { + setErr(e.message); + submitBtn.disabled = true; + } + } + + async function submit() { + submitBtn.disabled = true; + submitBtn.textContent = 'Отправка…'; + try { + const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answers: selections }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.error || 'Не удалось завершить попытку.'); + resultEl.style.display = ''; + resultEl.innerHTML = + '<h3 style="margin-top:0;">Результат</h3>' + + '<p>Правильно: <strong>' + data.correctCount + '</strong> из ' + data.totalQuestions + + ' (' + data.percent + '%). Порог: ' + data.passingThreshold + '%.</p>' + + '<p class="' + (data.passed ? 'text-muted' : 'error-text') + '">' + (data.passed ? 'Зачёт.' : 'Незачёт.') + '</p>' + + '<p><a href="/tests/' + testId + '/attempts/' + data.attemptId + '/review">Разбор попытки</a></p>'; + submitBtn.style.display = 'none'; + } catch (e) { + setErr(e.message); + submitBtn.disabled = false; + submitBtn.textContent = 'Завершить тест'; + } + } + + submitBtn.addEventListener('click', submit); + load(); +})(); +</script> +{% endblock %} diff --git a/flask_app/app/templates/tests/attempt_review.html b/flask_app/app/templates/tests/attempt_review.html new file mode 100644 index 0000000..18a55f0 --- /dev/null +++ b/flask_app/app/templates/tests/attempt_review.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Разбор попытки{% endblock %} + +{% block content %} +<div class="test-detail-page"> + <p class="link-back"><a href="/tests">← к списку тестов</a></p> + <h1 class="font-headline" style="font-size:1.35rem;margin-top:0;">Разбор: {{ review.testTitle }}</h1> + <p> + Правильно: <strong>{{ review.correctCount }}</strong> из {{ review.totalQuestions }} + ({{ review.percent }}%). Порог: {{ review.passingThreshold }}%. + {% if review.passed %} + <span class="text-muted">Зачёт.</span> + {% else %} + <span class="error-text">Незачёт.</span> + {% endif %} + </p> + + <div class="attempts-card-list"> + {% for q in review.questions %} + <article class="attempt-card"> + <div class="attempt-card__meta"> + <span>{{ 'Верно' if q.isUserCorrect else 'Ошибка' }}</span> + </div> + <p style="margin-top:.25rem;"><strong>{{ loop.index }}.</strong> {{ q.text }}</p> + <ul style="list-style:none;padding-left:0;margin:0;"> + {% for o in q.options %} + <li style="margin:.25rem 0;"> + <span> + {% if o.selected %}☑{% else %}☐{% endif %} + {{ o.text }} + {% if o.isCorrect %}<strong> (правильный)</strong>{% endif %} + </span> + </li> + {% endfor %} + </ul> + </article> + {% endfor %} + </div> +</div> +{% endblock %} diff --git a/flask_app/app/templates/tests/editor.html b/flask_app/app/templates/tests/editor.html index ce84228..264d57c 100644 --- a/flask_app/app/templates/tests/editor.html +++ b/flask_app/app/templates/tests/editor.html @@ -3,88 +3,86 @@ {% block content %} <div id="editor-root" - class="space-y-4 sm:space-y-5 pb-24" + class="space-y-4 sm:space-y-5 pb-24 {% if ui_variant == 'legacy' %}test-detail-page test-detail-page--with-fixed-actions{% endif %}" data-test-id="{{ test_id }}" data-initial='{{ content | tojson | safe }}'> - {# ── 1. Шапка теста ─────────────────────────────────────────── #} - <section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-5"> - <h2 class="text-xs font-medium text-ink-500 uppercase tracking-wide">Тест</h2> - - <label class="mt-2 block"> - <span class="sr-only">Название</span> - <input id="test-title" type="text" maxlength="200" placeholder="Название теста" - class="w-full rounded-lg border border-ink-300 px-3 py-3 text-lg font-semibold - focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> - </label> + <section class="cabinet-brick cabinet-brick--hero hero-brick"> + <div class="hero-brick__nav"> + <a href="{{ url_for('tests.tests_list_page') }}" class="link-back">← к списку</a> + <span class="hero-brick__meta"> + <span>Автор: <b id="intro-author">Вы</b></span> + <span class="hero-brick__sep">·</span> + <span>Обновлён: <span id="intro-updated">—</span></span> + <span class="hero-brick__sep">·</span> + <span>Версия <span id="intro-version">—</span></span> + </span> + </div> - <label class="mt-3 block"> - <span class="text-xs font-medium text-ink-500">Описание</span> - <textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)" - class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 - focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea> - </label> + <textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста" + class="hero-brick__title font-headline"></textarea> - <label class="mt-3 flex items-center justify-between gap-3"> - <span class="text-xs font-medium text-ink-500">Проходной балл, %</span> - <input id="test-threshold" type="number" min="0" max="100" step="1" - inputmode="numeric" - class="w-24 text-right rounded-lg border border-ink-300 px-3 py-2 - focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> - </label> - </section> + <textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)" + class="hero-brick__desc"></textarea> - {# ── 2. AI-помощник ─────────────────────────────────────────── #} - <section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5"> - <div class="flex items-center gap-2"> - <span class="material-symbols-outlined text-brand-600">auto_awesome</span> - <h2 class="font-semibold text-brand-700">AI-помощник</h2> + <div class="hero-brick__chips"> + <label class="hero-brick__chip"> + <span>Порог зачёта</span> + <input id="test-threshold" type="number" min="0" max="100" step="1" inputmode="numeric" /> + <span>%</span> + </label> + <span class="hero-brick__chip hero-brick__chip--readonly"> + Вопросов: <b id="q-count">0</b> + </span> + <label class="hero-brick__chip"> + <input id="chain-active" type="checkbox" /> + <span>Активна в каталоге</span> + </label> </div> - {# Группа A — генерация. Главные действия. На sm+ — в одну строку. #} - <div class="mt-3"> - <p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Создать вопросы</p> - <div class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2"> - <button id="ai-generate-by-title" - class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg - bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium min-h-11"> - <span class="material-symbols-outlined text-base">edit_note</span> - По названию - </button> - <button id="ai-generate-test" - class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg - bg-white border border-brand-300/60 text-brand-700 hover:bg-brand-50 - text-sm font-medium min-h-11"> - <span class="material-symbols-outlined text-base">stars</span> - По текущей сетке - </button> - </div> + <div id="intro-fork-banner" class="callout callout--warning" data-fork-risk="{{ '1' if content.test.hasForkRisk else '0' }}" style="margin-top:0.85rem; display:none;"> + При сохранении будет создана новая версия теста. </div> + </section> - {# Группа B — анализ существующего. #} - <div class="mt-4"> - <p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Улучшить существующее</p> - <div class="mt-2 grid grid-cols-2 gap-2"> - <button id="ai-check" - class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg - bg-white border border-ink-300/60 hover:border-brand-300 - text-sm min-h-11"> - <span class="material-symbols-outlined text-base">fact_check</span> - Проверить - </button> - <button id="ai-improve" - class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg - bg-white border border-ink-300/60 hover:border-brand-300 - text-sm min-h-11"> - <span class="material-symbols-outlined text-base">tune</span> - Улучшить + <details class="cabinet-disclosure cabinet-brick" open> + <summary class="cabinet-disclosure__summary"> + <span class="cabinet-disclosure__summary-text"> + <span class="cabinet-disclosure__summary-title font-headline">Вопросы</span> + <span class="cabinet-disclosure__summary-sub">Тексты, варианты и при необходимости загрузка из файла</span> + </span> + </summary> + <div class="cabinet-disclosure__body"> + <section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5 test-detail-ai-panel"> + <div class="question-editor-block question-editor-block--first"> + <h3 class="test-detail-subsection__title" style="margin-top:0;">Генерация сетки вопросов (ИИ)</h3> + <label class="block"> + <span class="form-label">Тема</span> + <input id="ai-topic" type="text" class="form-input" placeholder="Например: Введение про LLM" /> + </label> + <div class="mt-3 flex flex-wrap items-end gap-3"> + <label class="block"> + <span class="form-label">Вопросов</span> + <input id="ai-q-count" type="number" min="1" max="30" step="1" value="7" + class="form-input" style="width:90px;" /> + </label> + <label class="block"> + <span class="form-label">Вариантов</span> + <input id="ai-o-count" type="number" min="2" max="8" step="1" value="3" + class="form-input" style="width:90px;" /> + </label> + <button id="ai-generate-test" + class="btn btn-ghost" type="button" style="min-height:43px;"> + Сгенерировать тест (ИИ) </button> </div> </div> - {# Группа C — импорт. #} - <div class="mt-4"> - <p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Импортировать</p> + <div class="question-editor-block test-detail-subsection test-detail-subsection--import"> + <h3 class="test-detail-subsection__title">Документ в вопросы</h3> + <p class="muted test-detail-hint" style="margin-top:0;"> + PDF, Word или текст — вставьте в черновик вопросов. + </p> <label class="mt-2 inline-flex w-full items-center justify-center gap-2 px-3 py-3 rounded-lg bg-white border border-ink-300/60 hover:border-brand-300 text-sm cursor-pointer min-h-11"> @@ -103,10 +101,11 @@ {# ── 3. Вопросы ─────────────────────────────────────────────── #} <section> <div class="flex items-center justify-between gap-2 px-1"> - <h2 class="font-semibold">Вопросы (<span id="q-count">0</span>)</h2> + <h2 class="font-semibold">Вопросы (<span id="q-count-mirror">0</span>)</h2> <button id="add-question" class="inline-flex items-center gap-1 px-3 py-2 rounded-lg - bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10"> + bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10 + btn btn-ghost btn--sm question-editor__add-question"> <span class="material-symbols-outlined text-base">add</span> <span class="hidden sm:inline">Добавить вопрос</span> <span class="sm:hidden">Добавить</span> @@ -114,37 +113,91 @@ </div> <ol id="questions" class="mt-3 space-y-3"></ol> </section> + </div> + </details> + + <details class="cabinet-disclosure cabinet-brick" open> + <summary class="cabinet-disclosure__summary"> + <span class="cabinet-disclosure__summary-text"> + <span class="cabinet-disclosure__summary-title font-headline">История</span> + <span class="cabinet-disclosure__summary-sub">Версии теста и кто проходил</span> + </span> + </summary> + <div class="cabinet-disclosure__body"> + <div class="test-detail-subsection test-detail-subsection--tight"> + <h3 class="test-detail-subsection__title">Версии</h3> + <ul id="versions-list" class="version-card-list"></ul> + </div> + <div class="test-detail-subsection"> + <h3 class="test-detail-subsection__title">Прохождения</h3> + <ul id="attempts-list" class="attempts-card-list"></ul> + </div> + </div> + </details> + + <details class="cabinet-disclosure cabinet-brick" open> + <summary class="cabinet-disclosure__summary"> + <span class="cabinet-disclosure__summary-text"> + <span class="cabinet-disclosure__summary-title font-headline">Показ в каталоге</span> + <span class="cabinet-disclosure__summary-sub">Видимость в списке и выдача сотрудникам</span> + </span> + </summary> + <div class="cabinet-disclosure__body"> + <div class="test-detail-subsection test-detail-subsection--tight"> + <h3 class="test-detail-subsection__title">Видимость</h3> + <p class="test-detail-hint">Скрытые тесты в общем списке не показываются; ссылку на тест по-прежнему можно открыть.</p> + <div class="publication-visibility__actions"> + <button id="btn-toggle-visibility" class="btn btn-ghost" type="button">Скрыть из списка</button> + </div> + </div> + <div class="test-detail-subsection"> + <h3 class="test-detail-subsection__title">Кому выдать</h3> + <p class="test-detail-hint">Список с учётом поиска и фильтров; можно отметить всех на экране.</p> + <div class="assign-toolbar"> + <input id="assign-search" class="form-input assign-toolbar__search" type="text" placeholder="Поиск: ФИО, логин" /> + <select id="assign-dept" class="form-input"><option value="__all__">Все отделы</option></select> + <select id="assign-clinic" class="form-input"> + <option value="all">Все</option> + <option value="with">С учёткой в модуле</option> + <option value="without">Без учётки (создадим при назначении)</option> + </select> + <button id="assign-select-all" class="btn btn-ghost btn--sm" type="button">Выбрать всех</button> + </div> + <div id="assign-list" class="assign-list"></div> + <div class="inline-actions" style="margin-top:0.75rem;"> + <button id="assign-submit" class="btn btn-primary" type="button">Назначить выбранных</button> + <span id="assign-status" class="muted"></span> + </div> + </div> + </div> + </details> </div> {# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #} <div class="fixed bottom-0 inset-x-0 z-30 bg-white/95 backdrop-blur border-t border-ink-300/60 pb-[env(safe-area-inset-bottom)]"> - <div class="mx-auto max-w-6xl px-4 py-3 + <div class="mx-auto {% if ui_variant == 'legacy' %}max-w-2xl{% else %}max-w-6xl{% endif %} px-4 py-3 flex items-center justify-between gap-3"> - <label class="inline-flex items-center gap-2 text-sm min-w-0"> - <input id="chain-active" type="checkbox" - class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> - <span class="truncate">Цепочка активна</span> - </label> + <span class="text-xs text-ink-500 truncate">Активность цепочки и поля теста — в шапке.</span> <div class="flex items-center gap-2 shrink-0"> <a href="{{ url_for('tests.tests_list_page') }}" - class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm"> + class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost"> К каталогу </a> <button id="save-draft" class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg - bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11"> + bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11 btn btn-primary"> <span class="material-symbols-outlined text-base">save</span> Сохранить </button> </div> </div> - <p id="save-status" class="mx-auto max-w-6xl px-4 pb-2 text-xs text-ink-500"></p> + <p id="save-status" class="mx-auto {% if ui_variant == 'legacy' %}max-w-2xl{% else %}max-w-6xl{% endif %} px-4 pb-2 text-xs text-ink-500"></p> </div> {# ── Шаблон вопроса ─────────────────────────────────────────────── #} <template id="tpl-question"> - <li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item"> + <li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item question-editor-block"> {# Шапка карточки вопроса: номер слева, кнопки справа. #} <div class="flex items-center justify-between gap-2"> <span class="inline-flex items-center px-2 py-0.5 rounded-md @@ -165,27 +218,28 @@ </div> </div> - <textarea class="q-text mt-3 w-full rounded-lg border border-ink-300 px-3 py-2 + <div class="question-editor-block__header"> + <h4 class="question-editor-block__title q-num">Вопрос #</h4> + <button class="q-ai btn btn-ghost btn--sm question-editor-block__ai-btn"> + Сгенерировать вопрос (ИИ) + </button> + </div> + + <textarea class="q-text mt-2 w-full rounded-lg border border-ink-300 px-3 py-2 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" rows="2" placeholder="Формулировка вопроса"></textarea> - {# Тип ответа + AI — две полные строки на мобиле, в строку на sm+. #} <div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm"> <label class="inline-flex items-center gap-2 min-h-9"> <input type="checkbox" class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> <span>Несколько правильных ответов</span> </label> - <button class="q-ai inline-flex items-center justify-center gap-1 px-2.5 py-2 rounded-lg - bg-brand-50 hover:bg-brand-100 text-brand-700 text-sm min-h-10"> - <span class="material-symbols-outlined text-base">auto_awesome</span> - AI: вопрос/переформулировать - </button> </div> <ul class="q-options mt-3 space-y-2"></ul> <button class="q-add-option mt-2 inline-flex items-center gap-1 px-2 py-2 rounded - text-sm text-brand-700 hover:bg-brand-50 min-h-10"> + text-sm text-brand-700 hover:bg-brand-50 min-h-10 btn btn-ghost btn--sm"> <span class="material-symbols-outlined text-base">add</span> Добавить вариант </button> @@ -194,19 +248,19 @@ {# ── Шаблон варианта ────────────────────────────────────────────── #} <template id="tpl-option"> - <li class="flex items-center gap-2 opt-item"> + <li class="flex items-center gap-2 opt-item question-option-row"> {# Чекбокс «Правильный» — обёрнут в большой tap-target. #} <label class="inline-flex items-center justify-center w-10 h-10 shrink-0 cursor-pointer rounded hover:bg-ink-100" title="Правильный ответ"> <input type="checkbox" - class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> + class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500 question-option-row__mark" /> </label> <input type="text" - class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2 + class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2 question-option-row__text focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" placeholder="Вариант ответа" /> <button class="opt-delete shrink-0 w-10 h-10 inline-flex items-center justify-center - rounded hover:bg-red-50 text-red-600" + rounded hover:bg-red-50 text-red-600 question-option-remove" title="Удалить" aria-label="Удалить вариант"> <span class="material-symbols-outlined text-base">close</span> </button> diff --git a/flask_app/app/templates/tests/list.html b/flask_app/app/templates/tests/list.html index 1c4d124..5e453dd 100644 --- a/flask_app/app/templates/tests/list.html +++ b/flask_app/app/templates/tests/list.html @@ -2,66 +2,122 @@ {% block title %}Тесты — каталог{% endblock %} {% block content %} -<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-6"> - <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> - <div> - <h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1> - <p class="mt-1 text-sm text-ink-500">Активные тесты, к которым у вас есть доступ.</p> +{% if ui_variant == 'legacy' %} + <section class="legacy-list-shell"> + <h1 class="font-headline legacy-list-title">Тесты</h1> + <div class="legacy-list-toolbar"> + <button id="btn-create-test" class="btn btn-ghost" type="button"> + Создать + </button> </div> - <button id="btn-create-test" - class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg - bg-brand-600 hover:bg-brand-700 text-white font-medium transition - min-h-11 w-full sm:w-auto"> - <span class="material-symbols-outlined text-base">add</span> - Создать тест - </button> - </div> - {% if visible %} - <ul class="mt-5 grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> - {% for t in visible %} - <li class="rounded-xl border border-ink-300/60 hover:border-brand-300 hover:shadow-sm transition bg-white"> - <a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" - class="block p-4 active:bg-ink-100/40"> - <div class="flex items-start justify-between gap-2"> - <h3 class="font-semibold text-ink-900 line-clamp-2 min-w-0">{{ t.title }}</h3> - <span class="text-xs text-ink-500 shrink-0 mt-0.5">v{{ t.version }}</span> + {% if visible %} + <ul class="list-stack" aria-label="Тесты в общем списке"> + {% for t in visible %} + <li class="list-row list-row--split"> + <div class="list-row__main"> + <a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link"> + <span class="list-row__title">{{ t.title }}</span> + <span class="list-row__meta"> + {{ t.author_full_name or '—' }} + <span class="list-row__meta-tail"> · v{{ t.version }}</span> + </span> + </a> </div> - {% if t.description %} - <p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p> - {% endif %} - <div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500"> - <span class="truncate">Автор: {{ t.author_full_name or '—' }}</span> - <span class="inline-flex items-center gap-1 text-brand-700"> - <span class="material-symbols-outlined text-sm">edit_note</span> - Открыть - </span> + <div class="list-row__side"> + <button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}">Пройти</button> </div> - </a> - </li> - {% endfor %} - </ul> - {% else %} - <p class="mt-5 text-ink-500 text-sm">Доступных тестов пока нет.</p> - {% endif %} + </li> + {% endfor %} + </ul> + {% else %} + <p class="text-muted">Нет тестов</p> + {% endif %} - {% if hidden %} - <details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4"> - <summary class="cursor-pointer font-medium text-ink-700"> - Скрытые вами цепочки ({{ hidden|length }}) - </summary> - <ul class="mt-3 space-y-2"> + {% if hidden %} + <h2 class="font-headline legacy-list-subtitle">Скрытые вами из списка</h2> + <ul class="list-stack" aria-label="Скрытые тесты автора"> {% for t in hidden %} - <li class="flex items-center justify-between gap-2 text-sm"> - <span>{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span></span> + <li class="list-row list-row--split list-row--hidden"> + <div class="list-row__main"> + <a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link"> + <span class="list-row__title">{{ t.title }}</span> + <span class="list-row__meta"> + {{ t.author_full_name or '—' }} + <span class="list-row__meta-tail"> · v{{ t.version }} · скрыт</span> + </span> + </a> + </div> + <div class="list-row__side"> + <button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}">Пройти</button> + </div> + </li> + {% endfor %} + </ul> + {% endif %} + </section> +{% else %} + <section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-6"> + <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> + <div> + <h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1> + <p class="mt-1 text-sm text-ink-500">Активные тесты, к которым у вас есть доступ.</p> + </div> + <button id="btn-create-test" + class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg + bg-brand-600 hover:bg-brand-700 text-white font-medium transition + min-h-11 w-full sm:w-auto"> + <span class="material-symbols-outlined text-base">add</span> + Создать тест + </button> + </div> + + {% if visible %} + <ul class="mt-5 grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> + {% for t in visible %} + <li class="rounded-xl border border-ink-300/60 hover:border-brand-300 hover:shadow-sm transition bg-white"> <a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" - class="text-brand-700 hover:underline">Открыть</a> + class="block p-4 active:bg-ink-100/40"> + <div class="flex items-start justify-between gap-2"> + <h3 class="font-semibold text-ink-900 line-clamp-2 min-w-0">{{ t.title }}</h3> + <span class="text-xs text-ink-500 shrink-0 mt-0.5">v{{ t.version }}</span> + </div> + {% if t.description %} + <p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p> + {% endif %} + <div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500"> + <span class="truncate">Автор: {{ t.author_full_name or '—' }}</span> + <span class="inline-flex items-center gap-1 text-brand-700"> + <span class="material-symbols-outlined text-sm">edit_note</span> + Открыть + </span> + </div> + </a> </li> {% endfor %} </ul> - </details> - {% endif %} -</section> + {% else %} + <p class="mt-5 text-ink-500 text-sm">Доступных тестов пока нет.</p> + {% endif %} + + {% if hidden %} + <details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4"> + <summary class="cursor-pointer font-medium text-ink-700"> + Скрытые вами цепочки ({{ hidden|length }}) + </summary> + <ul class="mt-3 space-y-2"> + {% for t in hidden %} + <li class="flex items-center justify-between gap-2 text-sm"> + <span>{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span></span> + <a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" + class="text-brand-700 hover:underline">Открыть</a> + </li> + {% endfor %} + </ul> + </details> + {% endif %} + </section> +{% endif %} <dialog id="dlg-create" class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-md sm:w-full sm:m-auto @@ -136,6 +192,36 @@ alert(e.message || 'Не удалось создать тест.'); } }); + + const passButtons = Array.from(document.querySelectorAll('.btn-start-pass')); + passButtons.forEach((btn) => { + btn.addEventListener('click', async () => { + const testId = btn.dataset.testId; + if (!testId) return; + btn.disabled = true; + const oldText = btn.textContent; + btn.textContent = '…'; + try { + const r = await fetch(`/api/tests/${testId}/attempts/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + let data = {}; + try { data = await r.json(); } catch (_) {} + if (!r.ok || !data.attempt || !data.attempt.id) { + // В Flask legacy контуре пока может отсутствовать отдельная UI-страница попытки. + // Тогда ведём в карточку теста, чтобы пользователь не попадал на not_found. + window.location.href = `/tests/${testId}/edit`; + return; + } + window.location.href = `/tests/${testId}/attempt/${data.attempt.id}`; + } catch (e) { + window.location.href = `/tests/${testId}/edit`; + return; + } + }); + }); })(); </script> {% endblock %} diff --git a/flask_app/app/tests/routes.py b/flask_app/app/tests/routes.py index f319cea..0823089 100644 --- a/flask_app/app/tests/routes.py +++ b/flask_app/app/tests/routes.py @@ -42,6 +42,14 @@ from ..services.document_extract import ( from ..services.document_gen import generation_for_import_document from ..services.draft_validator import LlmError from ..services.editor_content import HttpError as EditorHttpError, get_editor_content +from ..services.test_attempt import ( + HttpError as AttemptHttpError, + get_attempt_review_for_user, + get_play_content, + list_test_attempts_for_author, + start_attempt, + submit_attempt, +) from ..services.test_access import is_test_author, list_hidden_by_author, list_visible_tests from ..services.test_chain import has_any_attempt_for_test from ..services.test_draft import ( @@ -150,6 +158,10 @@ def api_test_summary(test_id): if not acc.ok: return jsonify(error=RU['notFound']), 404 + has_attempts = False + with eng.connect() as conn: + has_attempts = has_any_attempt_for_test(conn, test_id) + return jsonify( test={ 'id': str(row['id']), @@ -163,6 +175,7 @@ def api_test_summary(test_id): 'updatedAt': row['updated_at'].isoformat() if row['updated_at'] else None, 'createdBy': str(row['created_by']) if row['created_by'] else None, 'authorFullName': row['author_full_name'], + 'hasAttempts': bool(has_attempts), }, isAuthor=is_author, hasActiveVersion=row['active_version_id'] is not None, @@ -293,6 +306,85 @@ def api_patch_test(test_id): return jsonify(id=test_id, chainActive=chain) +@tests_bp.route('/api/tests/<test_id>/attempts/start', methods=['POST']) +@login_required +def api_start_attempt(test_id): + user = current_user() + eng = get_engine() + try: + out = start_attempt(eng, user.id, test_id) + except AttemptHttpError as e: + return jsonify(error=e.message), e.status + return jsonify(out), 201 + + +@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/play', methods=['GET']) +@login_required +def api_attempt_play(test_id, attempt_id): + user = current_user() + eng = get_engine() + try: + out = get_play_content(eng, user.id, test_id, attempt_id) + except AttemptHttpError as e: + return jsonify(error=e.message), e.status + return jsonify(out) + + +@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/submit', methods=['POST']) +@login_required +def api_attempt_submit(test_id, attempt_id): + user = current_user() + eng = get_engine() + body = request.get_json(silent=True) or {} + try: + out = submit_attempt(eng, user.id, test_id, attempt_id, body.get('answers')) + except AttemptHttpError as e: + return jsonify(error=e.message), e.status + return jsonify(out) + + +@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/review', methods=['GET']) +@login_required +def api_attempt_review(test_id, attempt_id): + user = current_user() + eng = get_engine() + try: + out = get_attempt_review_for_user(eng, user.id, test_id, attempt_id) + except AttemptHttpError as e: + return jsonify(error=e.message), e.status + return jsonify(out) + + +@tests_bp.route('/api/tests/<test_id>/attempts', methods=['GET']) +@login_required +def api_attempts_list(test_id): + user = current_user() + eng = get_engine() + try: + rows = list_test_attempts_for_author(eng, user.id, test_id) + except AttemptHttpError as e: + return jsonify(error=e.message), e.status + return jsonify( + attempts=[ + { + 'id': str(r['id']), + 'userId': str(r['user_id']), + 'status': r['status'], + 'attemptNumber': r['attempt_number'], + 'startedAt': r['started_at'].isoformat() if r['started_at'] else None, + 'completedAt': r['completed_at'].isoformat() if r['completed_at'] else None, + 'correctCount': r['correct_count'], + 'totalQuestions': r['total_questions'], + 'passed': r['passed'], + 'testVersion': r['test_version'], + 'attempterName': r['attempter_name'], + 'attempterLogin': r['attempter_login'], + } + for r in rows + ] + ) + + # ─── AI ────────────────────────────────────────────────────────────── @tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST']) @@ -463,3 +555,23 @@ def tests_editor_page(test_id): return ('Доступ запрещён.', 403) return render_template('500.html'), 500 return render_template('tests/editor.html', content=content, test_id=test_id) + + +@tests_bp.route('/tests/<test_id>/attempt/<attempt_id>', methods=['GET']) +@login_required +def tests_attempt_page(test_id, attempt_id): + return render_template('tests/attempt.html', test_id=test_id, attempt_id=attempt_id) + + +@tests_bp.route('/tests/<test_id>/attempts/<attempt_id>/review', methods=['GET']) +@login_required +def tests_attempt_review_page(test_id, attempt_id): + user = current_user() + eng = get_engine() + try: + review = get_attempt_review_for_user(eng, user.id, test_id, attempt_id) + except AttemptHttpError as e: + if e.status == 404: + return render_template('404.html'), 404 + return (e.message, e.status) + return render_template('tests/attempt_review.html', test_id=test_id, review=review) diff --git a/frontend/index.html b/frontend/index.html index 42ef58e..022b5da 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,7 +15,7 @@ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet" /> - <title>Система тестрования + Система тестирования
diff --git a/frontend/src/img/clinic-logo.png b/frontend/src/img/clinic-logo.png new file mode 100644 index 0000000..18f0acc Binary files /dev/null and b/frontend/src/img/clinic-logo.png differ diff --git a/frontend/src/pages/TestDetail.jsx b/frontend/src/pages/TestDetail.jsx index 27036c2..6c33ab9 100644 --- a/frontend/src/pages/TestDetail.jsx +++ b/frontend/src/pages/TestDetail.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams, Link, useOutletContext } from 'react-router-dom'; import { api } from '../api'; import { formatTestAuthorLabel } from '../utils/formatUserName'; @@ -70,6 +70,41 @@ function mapEditorToDraftQuestions(ed) { })); } +function buildDraftSnapshot({ title, description, passing, questions }) { + return JSON.stringify({ + title: title ?? '', + description: description ?? '', + passing: String(passing ?? ''), + questions: (questions || []).map((q) => ({ + text: q.text ?? '', + hasMultipleAnswers: !!q.hasMultipleAnswers, + options: (q.options || []).map((o) => ({ + text: o.text ?? '', + isCorrect: !!o.isCorrect, + })), + })), + }); +} + +function postDebugLog({ runId, hypothesisId, location, message, data }) { + fetch('http://127.0.0.1:7419/ingest/a86fc408-7178-4abe-8dd9-f3e6bfb05d76', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Debug-Session-Id': '034e19', + }, + body: JSON.stringify({ + sessionId: '034e19', + runId, + hypothesisId, + location, + message, + data, + timestamp: Date.now(), + }), + }).catch(() => {}); +} + export default function TestDetail() { const { id } = useParams(); const nav = useNavigate(); @@ -83,6 +118,7 @@ export default function TestDetail() { const [draftDescription, setDraftDescription] = useState(''); const [draftPassing, setDraftPassing] = useState('70'); const [draftQuestions, setDraftQuestions] = useState(() => [createEmptyQuestion()]); + const [draftSnapshotOnLoad, setDraftSnapshotOnLoad] = useState(null); const [draftStatus, setDraftStatus] = useState(''); const [deactivateBusy, setDeactivateBusy] = useState(false); const [importPreview, setImportPreview] = useState(null); @@ -106,6 +142,16 @@ export default function TestDetail() { const [assignLoadBusy, setAssignLoadBusy] = useState(false); const [attemptsList, setAttemptsList] = useState(undefined); const [attemptsErr, setAttemptsErr] = useState(null); + const debugRunId = 'pre-fix'; + // #region agent log + postDebugLog({ + runId: debugRunId, + hypothesisId: 'H1', + location: 'frontend/src/pages/TestDetail.jsx:component-start', + message: 'TestDetail render start', + data: { hasData: !!data, hasTaker: !!taker, hasErr: !!err, testId: id != null }, + }); + // #endregion async function load() { setErr(null); @@ -127,14 +173,26 @@ export default function TestDetail() { setData(v); setChain(c); if (ed?.test) { - setDraftTitle(ed.test.title || ''); - setAiGenTopic((ed.test.title || '').trim()); - setDraftDescription(ed.test.description || ''); + const loadedTitle = ed.test.title || ''; + const loadedDescription = ed.test.description || ''; const th = ed.test.passingThreshold; - setDraftPassing( - th !== undefined && th !== null && String(th) !== '' ? String(th) : '70' + const loadedPassing = + th !== undefined && th !== null && String(th) !== '' ? String(th) : '70'; + const loadedQuestions = mapEditorToDraftQuestions(ed); + + setDraftSnapshotOnLoad( + buildDraftSnapshot({ + title: loadedTitle, + description: loadedDescription, + passing: loadedPassing, + questions: loadedQuestions, + }) ); - setDraftQuestions(mapEditorToDraftQuestions(ed)); + setDraftTitle(loadedTitle); + setAiGenTopic(loadedTitle.trim()); + setDraftDescription(loadedDescription); + setDraftPassing(loadedPassing); + setDraftQuestions(loadedQuestions); } } catch (e) { if (e.status === 401) { @@ -590,13 +648,40 @@ export default function TestDetail() { } if (err) { + // #region agent log + postDebugLog({ + runId: debugRunId, + hypothesisId: 'H3', + location: 'frontend/src/pages/TestDetail.jsx:return-err', + message: 'Returning error branch before memo hook', + data: { hasErr: true }, + }); + // #endregion return

{err}

; } if (!data && !taker) { + // #region agent log + postDebugLog({ + runId: debugRunId, + hypothesisId: 'H1', + location: 'frontend/src/pages/TestDetail.jsx:return-loading', + message: 'Returning loading branch before memo hook', + data: { hasData: false, hasTaker: false }, + }); + // #endregion return

Загрузка…

; } if (taker) { + // #region agent log + postDebugLog({ + runId: debugRunId, + hypothesisId: 'H1', + location: 'frontend/src/pages/TestDetail.jsx:return-taker', + message: 'Returning taker branch before memo hook', + data: { hasTaker: true }, + }); + // #endregion const { test: t, hasActiveVersion } = taker.summary; const title = t?.title || 'Тест'; return ( @@ -635,6 +720,42 @@ export default function TestDetail() { const assignSelectedInList = assignPeople.filter((p) => assignSelected.has(assignPersonKey(p)) ); + // #region agent log + postDebugLog({ + runId: debugRunId, + hypothesisId: 'H1', + location: 'frontend/src/pages/TestDetail.jsx:before-useMemo', + message: 'Reached line right before hasDraftChanges useMemo', + data: { hasData: !!data, hasTaker: !!taker }, + }); + // #endregion + const hasDraftChanges = useMemo(() => { + // #region agent log + postDebugLog({ + runId: debugRunId, + hypothesisId: 'H2', + location: 'frontend/src/pages/TestDetail.jsx:inside-useMemo', + message: 'Computing hasDraftChanges', + data: { + hasDraftSnapshotOnLoad: !!draftSnapshotOnLoad, + titleLen: (draftTitle || '').length, + descriptionLen: (draftDescription || '').length, + passing: String(draftPassing || ''), + questionsCount: Array.isArray(draftQuestions) ? draftQuestions.length : -1, + }, + }); + // #endregion + if (!draftSnapshotOnLoad) { + return false; + } + const currentSnapshot = buildDraftSnapshot({ + title: draftTitle, + description: draftDescription, + passing: draftPassing, + questions: draftQuestions, + }); + return currentSnapshot !== draftSnapshotOnLoad; + }, [draftDescription, draftPassing, draftQuestions, draftSnapshotOnLoad, draftTitle]); return (
@@ -667,7 +788,7 @@ export default function TestDetail() {
)} - {chain?.hasAnyAttempt && ( + {chain?.hasAnyAttempt && hasDraftChanges && (
При сохранении будет создана новая версия теста.