docs: миграция на tgFlaskForm и производительность Flask; контур flask_app; UI без лишних описаний
Made-with: Cursor
This commit is contained in:
@@ -6,6 +6,9 @@ dist/
|
|||||||
.env.local
|
.env.local
|
||||||
backend/.env
|
backend/.env
|
||||||
frontend/.env
|
frontend/.env
|
||||||
|
flask_app/.env
|
||||||
|
flask_app/.venv/
|
||||||
|
flask_app/**/__pycache__/
|
||||||
__MACOSX/
|
__MACOSX/
|
||||||
|
|
||||||
# Thumbnails and Metadata
|
# Thumbnails and Metadata
|
||||||
|
|||||||
@@ -6,12 +6,43 @@
|
|||||||
**Дата:** 2026-03-21
|
**Дата:** 2026-03-21
|
||||||
**Статус:** Согласовано
|
**Статус:** Согласовано
|
||||||
|
|
||||||
**Актуальное состояние кода (не ТЗ, а «что уже есть»):** [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) · [инструкция для проверяющих на dev](docs/DEV_CONTOUR_USER_GUIDE.md).
|
**Актуальное состояние кода (не ТЗ, а «что уже есть»):** [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) · [инструкция для проверяющих на dev](docs/DEV_CONTOUR_USER_GUIDE.md).
|
||||||
|
**Перенос на стек кабинета / мини-приложения:** [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md) · [простым языком](docs/migration-to-tgflaskform-plain.md). Отдельный Flask-контур: [flask_app/README.md](flask_app/README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стек технологий
|
||||||
|
|
||||||
|
### Этот репозиторий (TestingWebApp)
|
||||||
|
|
||||||
|
| Слой | Технологии |
|
||||||
|
|------|------------|
|
||||||
|
| **Backend** | Node.js (ESM), **Express** 4, **pg**, миграции SQL; аутентификация — cookie + **JWT** (**jsonwebtoken**), пароли **bcryptjs**; опционально вход через HR (`HR_AUTH`, отдельное подключение к БД HR). |
|
||||||
|
| **Frontend** | **React** 18, **React Router** 6, сборка **Vite** 5; статика в проде через Nginx (см. `docker-compose.dev.yml`). |
|
||||||
|
| **Данные** | **PostgreSQL**, отдельная БД **`clinic_tests`**: UUID-ключи, таблицы `tests`, `test_versions`, `questions`, `answer_options`, назначения, попытки (см. `backend/src/db/migrations/`). |
|
||||||
|
| **Прочее** | Извлечение текста из PDF/DOCX (**pdf-parse**, **mammoth**), опционально LLM для черновиков тестов; **dotenv**, **cors**, **multer**. |
|
||||||
|
|
||||||
|
### Целевой стек (Flask, как в кабинете / мини-приложении)
|
||||||
|
|
||||||
|
Тот же класс технологий, что в **`HR_TG_Bot/tgFlaskForm`**: Python, Flask, шаблоны, Postgres. Сейчас допускается **отдельный деплой** нового контура из каталога [`flask_app/`](flask_app/README.md); позже — слияние с полным кабинетом при необходимости.
|
||||||
|
|
||||||
|
Эталон реализации модуля в монорепозитории HR — общий веб-кабинет **`HR_TG_Bot/tgFlaskForm`**:
|
||||||
|
|
||||||
|
| Слой | Технологии |
|
||||||
|
|------|------------|
|
||||||
|
| **Приложение** | **Python 3**, **Flask** 3, шаблоны **Jinja2** + **PyPug**, blueprint `/cabinet/testing`; прод-сервер типично **waitress**. |
|
||||||
|
| **Данные** | **SQLAlchemy** 2, **psycopg2**, БД **`hr_bot_test`**: таблицы `testing_*`, связи с **`staff_members`**. |
|
||||||
|
| **Клиент** | HTML-шаблоны кабинета, JS в `webApp/templates/static/js/cabinet/` (без отдельного SPA в этом репозитории). |
|
||||||
|
| **Инфра** | Тот же кластер Postgres, что и у Postgres_TG_Bots / HR (см. раздел установки ниже). |
|
||||||
|
|
||||||
|
Подробности переноса и миграции данных: [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md).
|
||||||
|
Скрипт ETL в монорепозитории HR: [`../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (`--dry-run` / `--apply`, переменные `CLINIC_TESTS_URL` и `HR_BOT_URL`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Содержание
|
## Содержание
|
||||||
|
|
||||||
|
- [Стек технологий](#стек-технологий) · [flask_app/ — новый контур](flask_app/README.md)
|
||||||
- [Состояние реализации (сводка)](#состояние-реализации-сводка)
|
- [Состояние реализации (сводка)](#состояние-реализации-сводка)
|
||||||
- [Функциональные возможности](#функциональные-возможности)
|
- [Функциональные возможности](#функциональные-возможности)
|
||||||
- [Роли и права доступа](#роли-и-права-доступа)
|
- [Роли и права доступа](#роли-и-права-доступа)
|
||||||
@@ -129,7 +160,7 @@
|
|||||||
4. Миграции: из каталога `backend/`: `npm run migrate`, затем `npm start` (и фронт из `frontend/` — `npm run dev`).
|
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 (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). Локальный `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 up --build` — интерфейс **http://localhost:3107** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3001** (см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). **Новый Flask-контур** (тот же стек, что кабинет HR): **http://localhost:3108** — сервис `testing-flask`, см. [flask_app/README.md](flask_app/README.md). Локальный `npm run dev` фронта (Vite) — тоже **:3107**, прокси `/api` на **:3001**. В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`). В `backend/.env` задайте `PORT=3001`, если поднимаете API отдельно от compose.
|
||||||
|
|
||||||
`docker compose -f docker-compose.dev.yml down` — остановка.
|
`docker compose -f docker-compose.dev.yml down` — остановка.
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -6,7 +6,8 @@
|
|||||||
#
|
#
|
||||||
# Запуск: из каталога TestingWebApp
|
# Запуск: из каталога TestingWebApp
|
||||||
# docker compose -f docker-compose.dev.yml up --build
|
# docker compose -f docker-compose.dev.yml up --build
|
||||||
# UI: http://localhost:3107 (Nginx: /api → backend:3001), API с хоста: http://localhost:3001
|
# UI (Node): http://localhost:3107 (Nginx: /api → backend:3001), API: http://localhost:3001
|
||||||
|
# UI (Flask, новый контур): http://localhost:3108
|
||||||
|
|
||||||
services:
|
services:
|
||||||
testing-backend:
|
testing-backend:
|
||||||
@@ -47,6 +48,23 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- app
|
- app
|
||||||
|
|
||||||
|
# Новый контур: Flask (тот же стек, что кабинет HR), отдельный порт
|
||||||
|
testing-flask:
|
||||||
|
build:
|
||||||
|
context: ./flask_app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: testing_webapp_flask
|
||||||
|
environment:
|
||||||
|
PORT: "3108"
|
||||||
|
WEB_USE_WAITRESS: "1"
|
||||||
|
FLASK_DEBUG: "0"
|
||||||
|
SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me}
|
||||||
|
ports:
|
||||||
|
- "3108:3108"
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
# когда понадобится БД из контейнера — добавьте сеть postgres (hr_postgres_dev_net)
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app:
|
app:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Перенос тестирования на кабинет HR — простым языком
|
||||||
|
|
||||||
|
Это **короткий проектный документ** для заказчика и команды: зачем две базы, как они «сходятся» по людям, что делаем по шагам. Технические детали, таблицы и спринты — в отдельном файле: [migration-to-tgflaskform.md](migration-to-tgflaskform.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. В чём суть
|
||||||
|
|
||||||
|
Сейчас модуль тестирования может жить так:
|
||||||
|
|
||||||
|
- **Старое приложение** (то, что уже есть): своя программа и своя база **`clinic_tests`**. В ней заведены «пользователи модуля» (логин, пароль и т.д.) и все тесты, попытки, ответы.
|
||||||
|
- **Целевое место** — общий HR-кабинет на Python (**`tgFlaskForm`**): там уже есть раздел тестирования, данные лежат в другой базе — **`hr_bot_test`**, и каждый человек привязан к **карточке сотрудника** в HR (`staff_members`).
|
||||||
|
|
||||||
|
**Перенос** — это не «скопировать файлы», а **аккуратно переложить смысл** из одной базы в другую так, чтобы в HR было понятно: *этот тест написал тот же Иванов, эту попытку прошла та же Петрова*, и чтобы баллы и история не потерялись.
|
||||||
|
|
||||||
|
На переходный период можно держать **новый экран на Flask отдельно** (папка `flask_app` в этом репозитории) — тот же подход, что в кабинете, но свой адрес в браузере, пока не готовы полностью перейти на один вход.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Две базы — зачем и как они связаны
|
||||||
|
|
||||||
|
| База | Простыми словами |
|
||||||
|
|------|------------------|
|
||||||
|
| **`clinic_tests`** | «Песочница» модуля тестирования: здесь живут тесты, версии, попытки в том виде, в каком их делало старое приложение. |
|
||||||
|
| **`hr_bot_test`** | «Общий дом» HR: сотрудники, отделы, права, и при переносе — **те же тесты**, но уже в таблицах вида `testing_*`, привязанные к сотрудникам. |
|
||||||
|
|
||||||
|
Обе базы обычно стоят **на одном сервере PostgreSQL**, но это **разные логические хранилища** (как два разных диска с разными папками). Скрипт переноса подключается к обеим и **переписывает данные** из одной в другую по правилам (сначала тесты и вопросы, потом попытки и ответы — чтобы ничего не «повисло» без ссылки).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Связка «пользователь модуля» ↔ «сотрудник в HR»
|
||||||
|
|
||||||
|
В **`clinic_tests`** человек заведён как запись в таблице **`users`** (логин, роль в модуле и т.д.).
|
||||||
|
|
||||||
|
В **`hr_bot_test`** человек — это **`staff_members`** (та самая карточка из кадрового контура).
|
||||||
|
|
||||||
|
Чтобы перенос сработал, для **каждого**, кто важен для истории (автор теста, кто проходил, кто назначал), нужно знать одно число: **идентификатор сотрудника в HR** — `staff_members.id`.
|
||||||
|
|
||||||
|
На практике это делается так:
|
||||||
|
|
||||||
|
1. В таблице **`users`** (в `clinic_tests`) есть поле **`staff_id`** — туда записывается как раз **`staff_members.id`** из HR. Тогда программа понимает: *логин `ivanov` в модуле тестов = сотрудник № 12345 в HR*.
|
||||||
|
2. Если **`staff_id` пустой** — автоматом не понять, кто это. Тогда до переноса нужно **вручную или полуавтоматом** составить соответствия: например таблица «логин / email / ФИО → номер сотрудника в HR», заполнить `staff_id` или отдать это скрипту миграции отдельным файлом.
|
||||||
|
|
||||||
|
**Имеется в виду не «настроить взаимодействие двух баз в реальном времени»** (как два приложения, которые постоянно синхронизируются), а **один раз правильно сопоставить людей**, чтобы при копировании данных в HR не оказалось «логин есть, а сотрудник не найден».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Что делаем по этапам (без жаргона)
|
||||||
|
|
||||||
|
**Подготовка**
|
||||||
|
|
||||||
|
- Решить: пока живём на **старой базе** в новом Flask или сразу пишем в **HR-базу** — и не вести параллельно «два источника правды» без правил.
|
||||||
|
- Список сценариев: создание теста, версии, назначение, прохождение, разбор, отчёты — и отметить, что уже есть в кабинете, чего не хватает.
|
||||||
|
|
||||||
|
**Данные**
|
||||||
|
|
||||||
|
- Проверить **`staff_id`** у нужных `users`.
|
||||||
|
- Сделать **резервную копию** обеих баз.
|
||||||
|
- Запустить скрипт в режиме **«только посмотреть»** (`--dry-run`): он ничего не пишет в HR, только показывает, сколько чего нашёл.
|
||||||
|
- На **копии** HR-базы один раз прогнать **настоящий перенос**, открыть несколько тестов и попыток глазами.
|
||||||
|
- В согласованное короткое окно (когда никто не правит тесты) — перенос на боевую HR-базу, проверка, смена ссылок для пользователей на кабинет.
|
||||||
|
|
||||||
|
**После переноса**
|
||||||
|
|
||||||
|
- Старое приложение можно оставить только для чтения или выключить, когда убедились, что в кабинете всё ок.
|
||||||
|
- Бэкап старой базы и журнал переноса хранить по правилам клиники.
|
||||||
|
|
||||||
|
Подробные шаги ETL, порядок таблиц и ограничения текущего скрипта — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md), раздел 4. Скрипт: в монорепозитории HR, файл `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Что может пойти не так
|
||||||
|
|
||||||
|
- **Не все люди сопоставлены с HR** — часть тестов или попыток не перенесётся или перенесётся с ошибками. Лечится заранее: отчёт по пустым `staff_id` и дозаполнение.
|
||||||
|
- **Два места, куда одновременно пишут** — данные разъедутся. Лечится правилом: в период перехода пишем только в одно место (или второе только для пилота).
|
||||||
|
- **Назначения «на весь отдел»** в старой базе — в HR их нужно либо развернуть в список конкретных сотрудников на дату переноса, либо доработать логику отдельно — это заранее обсуждается с заказчиком.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Куда смотреть дальше
|
||||||
|
|
||||||
|
| Нужно | Файл |
|
||||||
|
|--------|------|
|
||||||
|
| Технический план, спринты, таблицы | [migration-to-tgflaskform.md](migration-to-tgflaskform.md) |
|
||||||
|
| Состояние кода старого приложения | [PROJECT_STATUS.md](PROJECT_STATUS.md) |
|
||||||
|
| Запуск нового Flask-контура в Docker | [../flask_app/README.md](../flask_app/README.md) |
|
||||||
|
| Установка и базы в целом | [../README.md](../README.md) |
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Перенос TestingWebApp на стек HR_TG_Bot / tgFlaskForm
|
||||||
|
|
||||||
|
**Тот же план простым языком (две базы, люди, этапы):** [migration-to-tgflaskform-plain.md](migration-to-tgflaskform-plain.md).
|
||||||
|
|
||||||
|
**Назначение документа:** зафиксировать целевую архитектуру, **спринтовый план** доведения функциональности до паритета и **порядок миграции данных** из отдельного приложения (`Express` + `React` + БД `clinic_tests`) в кабинет **`tgFlaskForm`** (Flask, шаблоны, общая БД `hr_bot_test`, таблицы `testing_*`).
|
||||||
|
|
||||||
|
**Связанные материалы:** [PROJECT_STATUS.md](PROJECT_STATUS.md), [README.md](../README.md), [TEST_TABLES_ANALYSIS.md](TEST_TABLES_ANALYSIS.md), код модуля в репозитории HR: `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`, модели: `HR_TG_Bot/tgFlaskForm/db/models.py`.
|
||||||
|
**Каркас нового контура в этом репозитории:** [../flask_app/README.md](../flask_app/README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Стратегия переходного периода (отдельное приложение, тот же стек)
|
||||||
|
|
||||||
|
**Решение:** переписывание с Node/React на **тот же стек, что у мини-приложения и кабинета HR** — Python 3, **Flask**, шаблоны (Jinja2), статический JS, работа с PostgreSQL в духе `tgFlaskForm`. При этом сервис **пока живёт отдельно**: свой процесс, свой URL/порт, **не** обязан совпадать с деплоем полного `HR_TG_Bot/tgFlaskForm`.
|
||||||
|
|
||||||
|
**Зачем так:** быстрее выйти на паритет по UX и данным, **без** риска «большого взрыва» в едином кабинете; позже либо встраиваете модуль в кабинет (общий `webApp`), либо оставляете отдельный вход — стек уже совпадает.
|
||||||
|
|
||||||
|
**Обязательно зафиксировать продуктово:**
|
||||||
|
|
||||||
|
| Вопрос | Рекомендация |
|
||||||
|
|--------|----------------|
|
||||||
|
| Где **пишут** тесты и попытки, пока два контура? | Один «канонический» контур на запись; второй read-only или только пилот — иначе разъедутся данные. |
|
||||||
|
| База | Либо по-прежнему **`clinic_tests`** в новом Flask до ETL, либо сразу **`hr_bot_test`** + `testing_*` (как в кабинете) — одно из двух, не смешивать без миграции. |
|
||||||
|
| ETL | Скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`: бэкап → `--dry-run` → проверка на копии → короткое окно → `--apply`. |
|
||||||
|
|
||||||
|
**Технически:** в репозитории TestingWebApp заведён каталог **`flask_app/`** — минимальное приложение-заготовка; развитие переноса идёт там (или копированием готовых модулей из `HR_TG_Bot/tgFlaskForm`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Зачем переносить
|
||||||
|
|
||||||
|
| Аспект | Сейчас (TestingWebApp) | Цель (tgFlaskForm) |
|
||||||
|
|--------|------------------------|---------------------|
|
||||||
|
| Стек | Node.js (Express), React (Vite), отдельный деплой | Python 3, Flask, Jinja/PyPug, статический JS в шаблонах — **единый кабинет** с остальным HR |
|
||||||
|
| База | PostgreSQL, схема `clinic_tests`, UUID-ключи, локальные `users` | Та же инфраструктура Postgres, БД **`hr_bot_test`**, целочисленные `id`, связь с **`staff_members`** |
|
||||||
|
| Авторизация | Собственные логин/JWT + опция `HR_AUTH` | Сессии кабинета, RBAC через HR (`testing_head_positions`, флаги HR и т.д.) |
|
||||||
|
| Модуль тестирования | Полный цикл в одном репозитории | В **`tgFlaskForm` уже есть** blueprint `/cabinet/testing`, запросы в `db/queries/testing_queries.py` — задача переноса = **паритет фич + данные + вывод из эксплуатации** старого UI/API |
|
||||||
|
|
||||||
|
Итог после **полной** консолидации: один вход для сотрудника, одна БД «истины» по людям, меньше дублирования интеграций с HR. На переходном этапе допустим **отдельный** Flask-инстанс с тем же стеком (см. §0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Исходный и целевой стек (кратко)
|
||||||
|
|
||||||
|
**Исходный (TestingWebApp):**
|
||||||
|
|
||||||
|
- Backend: `express`, `pg`, миграции SQL в `backend/src/db/migrations/`.
|
||||||
|
- Frontend: `react`, `react-router-dom`, `vite`.
|
||||||
|
- Данные: цепочки `tests` → `test_versions` → `questions` → `answer_options`; назначения с `test_assignment_targets` (отдел/пользователь); попытки `test_attempts`, ответы `user_answers` (массив UUID вариантов).
|
||||||
|
|
||||||
|
**Целевой (`HR_TG_Bot/tgFlaskForm`) и отдельный контур в этом репозитории (`flask_app/`):**
|
||||||
|
|
||||||
|
- Приложение: `Flask`, точка входа `web_run.py`, фабрика/приложение `webApp/__init__.py`.
|
||||||
|
- Шаблоны: `webApp/templates/cabinet/testing/*.html`, клиентский JS в `templates/static/js/cabinet/testing_*`.
|
||||||
|
- ORM/запросы: SQLAlchemy-модели `TestingTest`, `TestingTestVersion`, `TestingQuestion`, `TestingAnswer`, `TestingAssignment`, `TestingAttempt`, `TestingAttemptAnswer`, `TestingSetting`, `TestingHeadPosition` в `db/models.py`; бизнес-запросы — `db/queries/testing_queries.py`.
|
||||||
|
- Сервер: dev `flask run`, prod типично `waitress` (см. `web_run.py`).
|
||||||
|
- **Отдельный деплой в TestingWebApp:** каталог `flask_app/` — `run.py`, шаблоны в `flask_app/app/templates/` (см. §0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Спринтовый план (переписывание = паритет + миграция + снятие стенда)
|
||||||
|
|
||||||
|
Длительность спринта ориентировочно **2 календарные недели**; границы можно сжимать/растягивать под состав команды. Нумерация условная: **Спринт 0** — подготовка, далее функциональные слои.
|
||||||
|
|
||||||
|
### Спринт 0 — Инвентаризация и критерии готовности
|
||||||
|
|
||||||
|
**Цель:** зафиксировать разрыв «TestingWebApp ↔ tgFlaskForm» и правила миграции.
|
||||||
|
|
||||||
|
- Составить **матрицу сценариев** по [ТЗ.md](ТЗ.md) и [PROJECT_STATUS.md](PROJECT_STATUS.md): редактор теста, версии, назначения, прохождение, разбор, трекер, настройки модуля, AI.
|
||||||
|
- Зафиксировать отличия схемы: UUID vs integer, модель назначений (цель: каждая строка `TestingAssignment` = один `staff_id`).
|
||||||
|
- Решение по **импорту из PDF/DOCX** (в Node-версии есть извлечение текста для черновика): либо перенос в Python (`tgFlaskForm`), либо явный scope «после миграции».
|
||||||
|
- **Критерий выхода:** подписанный чек-лист паритета + утверждённый порядок миграции (раздел 4 этого документа).
|
||||||
|
|
||||||
|
### Спринт 1 — Данные и идентификаторы
|
||||||
|
|
||||||
|
**Цель:** подготовить перенос без потери смысла связей.
|
||||||
|
|
||||||
|
- Убедиться, что у всех значимых пользователей `clinic_tests.users` есть сопоставление с **`staff_members.id`** (колонка `staff_id` и/или правила сопоставления по логину из HR).
|
||||||
|
- Спроектировать **таблицы соответствия** для одноразового ETL (например временные таблицы или JSON-маппинги: `old_test_uuid → testing_tests.id`, `old_version_uuid → testing_test_versions.id`, и т.д.).
|
||||||
|
- Реализовать **скрипт миграции** — в репозитории HR: [`HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (Python, `psycopg2`, два URL). Режимы: `--dry-run` (только отчёт) и `--apply` (одна транзакция `COMMIT` в `hr_bot_test`). Переменные или флаги: `CLINIC_TESTS_URL`, `HR_BOT_URL`; опция `--skip-missing-staff` пропускает цепочки, у автора нет `users.staff_id`.
|
||||||
|
- **Критерий выхода:** dry-run на копии прод-дампа `clinic_tests` + smoke-проверки количества строк (тесты, версии, вопросы, попытки).
|
||||||
|
|
||||||
|
### Спринт 2 — Паритет бизнес-логики в Flask
|
||||||
|
|
||||||
|
**Цель:** закрыть расхождения поведения, а не только UI.
|
||||||
|
|
||||||
|
- Версионирование: правила «первая правка без попыток / новая версия после попыток», активная версия — согласовать с уже реализованным в `testing_queries.py` и довести до полного соответствия ТЗ при необходимости.
|
||||||
|
- Назначения: если в `clinic_tests` остались назначения **на отдел**, описать стратегию **разворачивания** в N строк `TestingAssignment` (по списку `staff_id` отдела на дату миграции) или доработать модель в HR (отдельное решение продукт-оунера).
|
||||||
|
- Прохождение: таймер, лимит попыток, дедлайн, случайный порядок вопросов (`question_seed`) — сверка с ТЗ и доработка в Python при расхождении.
|
||||||
|
- **Критерий выхода:** автоматические тесты на критичные запросы (где их ещё нет) + ручной прогон чек-листа из спринта 0.
|
||||||
|
|
||||||
|
### Спринт 3 — UI/UX кабинета и интеграция в меню
|
||||||
|
|
||||||
|
**Цель:** пользователь не возвращается к старому хосту.
|
||||||
|
|
||||||
|
- Пункты меню кабинета, бейджи «назначенные тесты», единый стиль с `cabinet/base.html`.
|
||||||
|
- Довести страницы: список «мои тесты», редактор, назначение, прохождение, результат/разбор, трекер, настройки — по чек-листу.
|
||||||
|
- Импорт документов (если включён в scope спринта 0): эндпоинт + UI в шаблоне, ключи API только на сервере (`TestingSetting` / env).
|
||||||
|
- **Критерий выхода:** UX-приёмка на стенде, совпадающий с ТЗ сценарий для HR / руководителя / сотрудника.
|
||||||
|
|
||||||
|
### Спринт 4 — Миграция prod, cutover, архив TestingWebApp
|
||||||
|
|
||||||
|
**Цель:** переключить реальных пользователей и зафиксировать артефакты.
|
||||||
|
|
||||||
|
- Заморозка записи в TestingWebApp (режим только чтение или техническое окно).
|
||||||
|
- Прогон ETL на прод-копии → валидация → прогон на боевой БД в согласованное окно.
|
||||||
|
- Обновление ссылок (внутренние порталы, документация, docker-compose): вместо `:3107` / отдельного сервиса — URL кабинета HR с `/cabinet/testing/...`.
|
||||||
|
- Репозиторий TestingWebApp: ветка **`legacy/clinic-tests-node`**, в README — ссылка на этот документ и дата end-of-life API/UI.
|
||||||
|
- **Критерий выхода:** мониторинг ошибок (например Sentry уже в `webApp/__init__.py`), отсутствие P1 по тестам в первую неделю после cutover.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Как происходит миграция данных (пошагово)
|
||||||
|
|
||||||
|
### 4.1 Предпосылки
|
||||||
|
|
||||||
|
1. Доступ к **двум** базам с одной машины (или логическое копирование дампа): `clinic_tests` и `hr_bot_test`.
|
||||||
|
2. Маппинг **пользователь → сотрудник:** для каждой строки `users` в `clinic_tests` должен быть известен **`staff_members.id`**. Если `staff_id` пустой — заранее ручной/полуавтоматический справочник соответствий (логин, email, ФИО).
|
||||||
|
3. Зафиксированная **версия кода** `tgFlaskForm`, в которой пройдены регрессионные тесты модуля тестирования.
|
||||||
|
|
||||||
|
### 4.2 Порядок загрузки сущностей (чтобы не нарушить FK)
|
||||||
|
|
||||||
|
Рекомендуемый порядок транзакций/батчей:
|
||||||
|
|
||||||
|
1. **`testing_tests`** — из цепочек `tests`: `title`, `description`, `created_by` ← `users.staff_id`, `is_active`, `created_at` (по политике: локальное время vs UTC).
|
||||||
|
2. **`testing_test_versions`** — из `test_versions`: связь `test_id` через маппинг; `version_number` ← `version`; `passing_score_percent` ← порог из версии/цепочки (в старой схеме часть полей была на `tests` — нормализовать в версию как в SQLAlchemy-модели); `time_limit_minutes`, `allow_back_navigation`, `is_active_version`, флаг единственной активной версии на цепочку.
|
||||||
|
3. **`testing_questions`** — из `questions`: текст, тип (`single`/`multiple` из `has_multiple_answers`), `sort_order` ← `question_order`.
|
||||||
|
4. **`testing_answers`** — из `answer_options`: текст, `is_correct`, порядок.
|
||||||
|
5. **`testing_assignments`** — из `test_assignments` + `test_assignment_targets`:
|
||||||
|
- для целей типа **пользователь:** одна строка на пару (тест, `staff_id`);
|
||||||
|
- для целей **отдел:** развернуть в множество строк по сотрудникам отдела на момент миграции (с явным логом «создано из department_id=…»);
|
||||||
|
- `assigned_by` ← `staff_id` постановщика; `deadline`, `max_attempts`, `assigned_at`.
|
||||||
|
6. **`testing_attempts`** — из `test_attempts`: связь с новым `assignment_id` (если в старой модели попытка шла от `user_id` без отдельного assignment — потребуется **восстановление** или создание синтетических назначений; зафиксировать правило в спринте 0).
|
||||||
|
7. **`testing_attempt_answers`** — из `user_answers`: каждый выбранный UUID варианта → строка с новым `answer_id` (через маппинг `answer_options.id` → `testing_answers.id`).
|
||||||
|
|
||||||
|
Везде, где в старой БД использовались **UUID**, скрипт хранит таблицу **`public._clinic_tests_migration_map`** (`entity`, `old_uuid` → `new_id`) в `hr_bot_test` для идемпотентного повторного прогона.
|
||||||
|
|
||||||
|
**Замечание по назначениям:** в текущей версии скрипта строки `clinic_tests.test_assignments` / `test_assignment_targets` **не** переносятся пакетно; для каждой пары (тест HR, сотрудник) при переносе **попыток** создаётся или находится строка `testing_assignments` (синтетическое назначение, `max_attempts = 99`). Полный импорт истории назначений из clinic — отдельная доработка при необходимости.
|
||||||
|
|
||||||
|
### 4.3 Валидация после ETL
|
||||||
|
|
||||||
|
- Сравнение **агрегатов:** число тестов, версий, вопросов, назначений, завершённых попыток, строк ответов.
|
||||||
|
- Выборочная сверка: 5–10 последних попыток — ручной разбор «вопрос / выбранные варианты / балл» в старом и новом UI.
|
||||||
|
- Проверка уникальности «одна активная версия на тест» и отсутствия «висячих» FK.
|
||||||
|
|
||||||
|
### 4.4 Cutover (переключение)
|
||||||
|
|
||||||
|
1. Объявить **окно**: остановка записи в TestingWebApp.
|
||||||
|
2. Инкрементальный дамп изменений с последней реплики (если делали пробный перенос ранее) или финальный полный перенос.
|
||||||
|
3. Прогон ETL в транзакции (или по крупным батчам с чекпоинтами) → `VACUUM ANALYZE` при необходимости.
|
||||||
|
4. Включить пользователям ссылку на **кабинет**; проверить права `can_create_tests` / HR.
|
||||||
|
5. Сохранить **бэкап** `clinic_tests` и лог миграции минимум на срок, определённый политикой клиники (типично 30–90 дней).
|
||||||
|
|
||||||
|
### 4.5 Откат
|
||||||
|
|
||||||
|
- Если после cutover обнаружен блокирующий дефект: вернуть пользователей на временный старый стенд **только для чтения** при наличии бэкапа; новые данные в `hr_bot_test` после cutover при откате не синхронизируются автоматически — риск фиксируется заранее (короткое окно, «freeze» повторных действий).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Риски и как их снимать
|
||||||
|
|
||||||
|
| Риск | Мера |
|
||||||
|
|------|------|
|
||||||
|
| Неполное сопоставление `users` ↔ `staff_members` | Закрыть в спринте 1; не начинать ETL без процента покрытия, согласованного с заказчиком |
|
||||||
|
| Разная семантика назначений (отдел, версия) | Явные правила в спринте 0 + лог развёртки отделов |
|
||||||
|
| Потеря истории попыток из-за смены модели assignment | Моделирование на копии БД в спринте 1–2 |
|
||||||
|
| Дублирование разработки UI | Опираться на уже существующий модуль в `tgFlaskForm`, не переписывать с нуля параллельный SPA |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Итог
|
||||||
|
|
||||||
|
Переписывание в данном контексте — это не «ещё один greenfield на Flask», а **консолидация** уже начатого модуля в `tgFlaskForm` с **одноразовой миграцией** из `clinic_tests` и выводом из эксплуатации связки React + Express. Спринты 0–4 дают сквозной маршрут от анализа до cutover; детали ETL должны быть закреплены в коде скрипта и журнале прогона к концу **спринта 1**.
|
||||||
|
|
||||||
|
**См. также:** если пользователи жалуются на медленную загрузку страниц кабинета/Flask — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md).
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Производительность страниц Flask (кабинет / мини-приложение): рабочий документ
|
||||||
|
|
||||||
|
Документ написан так, чтобы **человек без контекста проекта** мог по нему понять: *что за система, где код, что именно оптимизировать, в каком порядке и как понять, что задача сделана*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Для кого и зачем этот файл
|
||||||
|
|
||||||
|
- **Аудитория:** ты сам через полгода, новый разработчик, DevOps, тимлид на планировании.
|
||||||
|
- **Проблема от пользователей:** «страницы мини-приложения на Flask грузятся долго».
|
||||||
|
- **Цель документа:** не угадать решение («перепишем на React»), а **зафиксировать процесс**: сначала измерить и локализовать узкое место, потом применить исправления с наибольшим эффектом при наименьшем риске.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Где живёт проект (карта репозитория)
|
||||||
|
|
||||||
|
Рабочая копия — монорепозиторий **`HR`** (корень: `ClinicProjects/HR` или аналог). Для задачи производительности важны в первую очередь два контура:
|
||||||
|
|
||||||
|
| Контур | Путь в репозитории | Назначение |
|
||||||
|
|--------|-------------------|------------|
|
||||||
|
| **Основной веб-кабинет HR** | `HR_TG_Bot/tgFlaskForm/` | Flask-приложение: авторизация, кабинет, разделы в т.ч. **тестирование сотрудников**. Именно сюда чаще всего относят жалобы «мини-приложение / кабинет на Flask». |
|
||||||
|
| **Отдельный Flask-скелет под тестирование** | `TestingWebApp/flask_app/` | Упрощённое приложение того же стека (переходный контур, Docker-сервис `testing-flask`, порт **3108** в `TestingWebApp/docker-compose.dev.yml`). Может быть медленным по тем же причинам (БД, шаблоны, отсутствие кэша статики), но **это не обязательно тот же инстанс**, что видят пользователи в проде. |
|
||||||
|
|
||||||
|
Связанные по смыслу документы (миграция данных, две БД):
|
||||||
|
|
||||||
|
- `TestingWebApp/docs/migration-to-tgflaskform.md` — технический план.
|
||||||
|
- `TestingWebApp/docs/migration-to-tgflaskform-plain.md` — коротко «для людей».
|
||||||
|
|
||||||
|
**Важно:** жалоба «долго грузится» может относиться к:
|
||||||
|
|
||||||
|
1. **Веб-кабинет в браузере** (`tgFlaskForm`, типичный порт локально **3104** в `web_run.py`).
|
||||||
|
2. **Встроенный WebView в мини-приложении** (Telegram MAX и т.п.) — тот же HTML с того же хоста, но **другая сеть, кэш, DNS, TLS**; воспроизведение обязательно на целевом клиенте.
|
||||||
|
3. **Переходный контур** `TestingWebApp` на **3108** — проверять отдельно, если пользователи реально ходят туда.
|
||||||
|
|
||||||
|
Перед оптимизацией **уточнить URL/контур** у тех, кто жалуется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Что такое «страница грузится долго» в технических терминах
|
||||||
|
|
||||||
|
Раздели время на части (это основа всей работы):
|
||||||
|
|
||||||
|
1. **Сеть до сервера** — DNS, TCP/TLS, RTT, прокси (nginx перед Flask).
|
||||||
|
2. **Время до первого байта (TTFB)** — всё, что происходит на сервере до начала ответа: middleware, сессия, запросы к БД, рендер Jinja2, формирование заголовков.
|
||||||
|
3. **Загрузка тела ответа** — размер HTML, сжатие (gzip/brotli).
|
||||||
|
4. **Параллельная загрузка подресурсов** — CSS, JS, шрифты, картинки: их число, размер, кэширование (`Cache-Control`), HTTP/2.
|
||||||
|
5. **Выполнение JS на клиенте** — если на странице тяжёлый скрипт; для классического SSR-кабинета часто вторично по сравнению с TTFB.
|
||||||
|
|
||||||
|
**Твоя первая задача** — для 2–3 типичных страниц (логин после редиректа, дашборд тестирования, список тестов, прохождение теста) записать: **TTFB**, **DOMContentLoaded**, **полный LCP** (или хотя бы «визуально готово»). Без этого нельзя честно выбрать между «чиним SQL» и «чиним статику».
|
||||||
|
|
||||||
|
Инструменты:
|
||||||
|
|
||||||
|
- Chrome DevTools → **Network** (колонка Time, размер, waterfall), **Performance**.
|
||||||
|
- На сервере: логирование длительности запроса (middleware или reverse proxy `request_time` в nginx).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Как устроена загрузка страницы в `tgFlaskForm` (ментальная модель)
|
||||||
|
|
||||||
|
Упрощённая цепочка для защищённого маршрута, например дашборда тестирования:
|
||||||
|
|
||||||
|
1. Браузер запрашивает URL вида **`/cabinet/testing/`** (blueprint в `webApp/interfaces/testing/__init__.py`, префикс `/cabinet/testing`).
|
||||||
|
2. Срабатывают глобальные хуки Flask (в т.ч. **cabinet access gate** в `webApp/auth.py`: `register_cabinet_access_gate` — проверка пути, сессии, статуса «Работает»).
|
||||||
|
3. Декоратор **`@login_required`** на view: редирект на `/login` или вызов функции.
|
||||||
|
4. View (например `routes_dashboard.py`) вызывает функции из **`db/queries/testing_queries.py`** и др., собирает контекст и вызывает **`render_template(...)`**.
|
||||||
|
5. Jinja2 собирает HTML из шаблонов в `webApp/templates/` (часто с `extends` / `include` — чем больше вложенность и данных в контексте, тем дольше CPU на рендер).
|
||||||
|
6. Ответ уходит клиенту; дальше грузятся статические файлы с `/static/...`.
|
||||||
|
|
||||||
|
Узкое место может быть на **любом** шаге; чаще всего в таких приложениях — **шаг 4 (БД + много мелких запросов)** и **шаг 5 (большой шаблон)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Гипотезы, специфичные для этого кода (куда смотреть первым делом)
|
||||||
|
|
||||||
|
Ниже — не обвинение кода, а **чек-лист для проверки** после замеров.
|
||||||
|
|
||||||
|
### 5.1. Создание движка БД на каждый вызов сессии
|
||||||
|
|
||||||
|
Файл: `HR_TG_Bot/tgFlaskForm/db/session.py`.
|
||||||
|
|
||||||
|
Раньше `get_engine()` на каждом вызове делал `create_engine(...)` — новый пул и большие накладные расходы при десятках `get_session()` из `db/queries/*.py` (в т.ч. **`testing_queries.py`**).
|
||||||
|
|
||||||
|
**Сделано (код):** в `db/session.py` один **потокобезопасный** engine на процесс и один переиспользуемый `sessionmaker`; `get_session()` по-прежнему возвращает новую ORM-сессию, но поверх общего пула.
|
||||||
|
|
||||||
|
**Дальше:** при необходимости сокращать число **отдельных** сессий на один HTTP-запрос (§5.2) — это отдельная оптимизация.
|
||||||
|
|
||||||
|
### 5.2. Много открытий/закрытий сессий и запросов на одну страницу
|
||||||
|
|
||||||
|
Паттерн в `testing_queries.py`: почти каждая функция делает `s = get_session()`, `try/finally: s.close()`. Одна страница может дернуть **несколько** таких функций подряд → несколько раундов к БД.
|
||||||
|
|
||||||
|
**Что сделать:** для «тяжёлых» страниц — либо **одна сессия на request** и передача её вниз, либо **один агрегирующий запрос** вместо N мелких (устранение N+1). Конкретные места — смотреть по trace конкретного URL.
|
||||||
|
|
||||||
|
### 5.3. Декораторы и before_request
|
||||||
|
|
||||||
|
`login_required` и `cabinet_employment_ok_from_session()` в основном опираются на **сессию**, но gate и другие хуки могут добавлять логику. Если туда когда-нибудь добавят тяжёлые проверки в БД на **каждый** запрос — это сразу ударит по TTFB.
|
||||||
|
|
||||||
|
**Что сделать:** убедиться, что на горячем пути нет лишних запросов к БД без необходимости.
|
||||||
|
|
||||||
|
### 5.4. Шаблоны и статика
|
||||||
|
|
||||||
|
- Большие базовые layout’ы, много `include`, тяжёлые циклы в Jinja — растёт CPU на рендер.
|
||||||
|
- Статика без длинного кэша — каждый переход визуально «тормозит».
|
||||||
|
|
||||||
|
**Что сделать:** Network → сколько запросов к `/static`, какие размеры; для продакшена — заголовки кэша и сжатие на nginx (если nginx есть в цепочке — см. `HR_TG_Bot/docker-compose*.yml` и свою прод-конфигурацию).
|
||||||
|
|
||||||
|
### 5.5. Окружение
|
||||||
|
|
||||||
|
- `web_run.py`: в non-production используется встроенный сервер Flask; для нагрузочного теста ближе к прод — **waitress** / gunicorn (как в `TestingWebApp/flask_app/run.py` через `WEB_USE_WAITRESS`).
|
||||||
|
- Сравнение «локально быстро, у пользователей медленно» — почти всегда **сеть, БД на другом хосте, холодный пул, отсутствие индексов на прод-данных**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. План работы (что делать по шагам)
|
||||||
|
|
||||||
|
### Фаза 0 — уточнение (полдня максимум)
|
||||||
|
|
||||||
|
- [ ] Точный **URL/продукт** (кабинет HR vs TestingWebApp:3108 vs мини-app WebView).
|
||||||
|
- [ ] **Роль пользователя** и сценарий (первый заход, каждый клик, только раздел тестирования).
|
||||||
|
- [ ] Есть ли **nginx / CDN** перед приложением.
|
||||||
|
|
||||||
|
### Фаза 1 — измерение (обязательно)
|
||||||
|
|
||||||
|
- [ ] Зафиксировать 3–5 URL и для каждого: TTFB, размер HTML, число запросов, суммарный вес.
|
||||||
|
- [ ] На сервере: время обработки запроса (middleware: `before_request` timestamp vs `after_request`).
|
||||||
|
- [ ] Для самого медленного URL: **список вызовов к БД** (SQLAlchemy events, логирование, или APM, если есть).
|
||||||
|
|
||||||
|
**Выход фазы:** одно предложение вида: «узкое место — TTFB из-за БД» или «узкое месте — 40 запросов к статике без кэша».
|
||||||
|
|
||||||
|
### Фаза 2 — правки по приоритету (типичный порядок)
|
||||||
|
|
||||||
|
1. **Инфраструктура БД:** один engine на процесс; пул; при необходимости индексы (после анализа `EXPLAIN` самых тяжёлых запросов).
|
||||||
|
2. **Сократить число round-trips к БД** на страницу: объединение запросов, eager loading где уместно, кэш редко меняющихся справочников (с инвалидацией или коротким TTL).
|
||||||
|
3. **Шаблоны:** убрать лишние данные из контекста; упростить самые тяжёлые `include`.
|
||||||
|
4. **Статика:** fingerprint + `Cache-Control: immutable` для бандлов; минификация; не тянуть огромные библиотеки на каждую страницу без нужды.
|
||||||
|
5. **Прод-сервер приложений:** waitress/gunicorn, адекватное число воркеров за reverse proxy.
|
||||||
|
|
||||||
|
### Фаза 3 — если «всё ещё медленно именно при переходах между страницами»
|
||||||
|
|
||||||
|
Это уже про **полную перезагрузку HTML**, а не про «Flask медленный»:
|
||||||
|
|
||||||
|
- Вариант **A:** [HTMX](https://htmx.org/) / **Turbo** — сервер по-прежнему отдаёт HTML, обновляются фрагменты; стек остаётся Python + Jinja.
|
||||||
|
- Вариант **B:** точечный **React/Vite** только для тяжёлого экрана (остальной кабинет не трогать) — выше стоимость сопровождения.
|
||||||
|
|
||||||
|
Выбор между A и B — после фазы 1: если TTFB уже низкий, а больно от полной перезагрузки — имеет смысл A/B; если узкое место всё ещё сервер — сначала дожать фазу 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Критерии готовности (Definition of Done)
|
||||||
|
|
||||||
|
Задачу по производительности можно закрыть, когда:
|
||||||
|
|
||||||
|
1. Есть **замеры до/после** по тем же URL и тем же окружению (или согласованная методика).
|
||||||
|
2. Задокументировано **узкое место** и **что изменено** (1–2 абзаца в changelog или в этом файле внизу секция «Итог»).
|
||||||
|
3. Для пользовательского сценария выполняется согласованный **SLO** (например: TTFB p95 < X ms, полная загрузка ключевой страницы < Y s на 4G) — пороги задаёт продукт/команда, не этот документ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Риски и что не делать
|
||||||
|
|
||||||
|
- **Не** менять стек на SPA «с нуля» без измерений — высокий риск и долгий срок при том, что проблема может быть в пуле БД или кэше статики.
|
||||||
|
- **Не** оптимизировать только локально на пустой БД — планы запросов на прод-объёме другие.
|
||||||
|
- **Не** кэшировать персональные страницы на CDN без понимания заголовков и кук — риск утечки данных между пользователями.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Быстрый указатель файлов
|
||||||
|
|
||||||
|
| Тема | Путь |
|
||||||
|
|------|------|
|
||||||
|
| Точка входа веба | `HR_TG_Bot/tgFlaskForm/web_run.py` |
|
||||||
|
| Регистрация приложения / blueprints | `HR_TG_Bot/tgFlaskForm/webApp/__init__.py` (и связанные модули) |
|
||||||
|
| Модуль тестирования (маршруты) | `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/routes_*.py` |
|
||||||
|
| Запросы к БД тестирования | `HR_TG_Bot/tgFlaskForm/db/queries/testing_queries.py` |
|
||||||
|
| Сессия и engine | `HR_TG_Bot/tgFlaskForm/db/session.py` |
|
||||||
|
| Авторизация и gate | `HR_TG_Bot/tgFlaskForm/webApp/auth.py` |
|
||||||
|
| Шаблоны | `HR_TG_Bot/tgFlaskForm/webApp/templates/` |
|
||||||
|
| Переходный Flask + waitress | `TestingWebApp/flask_app/run.py`, `TestingWebApp/flask_app/app/` |
|
||||||
|
| Docker dev (пример порта 3108) | `TestingWebApp/docker-compose.dev.yml` |
|
||||||
|
| Docker dev кабинета | `HR_TG_Bot/docker-compose.dev.yml` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Секция «Итог» (заполнять по мере работы)
|
||||||
|
|
||||||
|
| Дата | Контур | Узкое место | Что сделано | Метрика до → после |
|
||||||
|
|------|--------|-------------|-------------|-------------------|
|
||||||
|
| 2026-04-27 | `tgFlaskForm` | Новый SQLAlchemy engine на каждый `get_session()` | Singleton `get_engine()` + кэш `sessionmaker` в `db/session.py` | _замерить на стенде_ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Документ создан как рабочая инструкция по задаче «медленная загрузка страниц Flask». Обновляй таблицу в §10 и при необходимости добавляй ссылки на PR/коммиты.*
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Порт HTTP (не пересекать с :3107 текущего compose)
|
||||||
|
PORT=3108
|
||||||
|
FLASK_DEBUG=1
|
||||||
|
SECRET_KEY=change-me-in-dev-only
|
||||||
|
|
||||||
|
# В Docker задайте WEB_USE_WAITRESS=1 (см. docker-compose.dev.yml)
|
||||||
|
# WEB_USE_WAITRESS=1
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Новый Flask-контур (отдельный от Node/React). Dev: см. docker-compose.dev.yml
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PORT=3108 \
|
||||||
|
WEB_USE_WAITRESS=1 \
|
||||||
|
FLASK_DEBUG=0
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY run.py .
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 3108
|
||||||
|
|
||||||
|
CMD ["python", "run.py"]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Flask-контур тестирования (тот же стек, отдельный деплой)
|
||||||
|
|
||||||
|
Здесь — **новое** приложение на **Python / Flask** в духе `HR_TG_Bot/tgFlaskForm` (шаблоны + серверный рендер, без React). Старый стек (`backend/` + `frontend/`) пока не удаляется: оба контура могут существовать параллельно, пока не зафиксирована политика «один источник записи» и cutover.
|
||||||
|
|
||||||
|
## Запуск в Docker (рекомендуется)
|
||||||
|
|
||||||
|
Из **корня** репозитория TestingWebApp. Сервис **не** зависит от `testing-backend` и **не** требует внешней сети Postgres для старта (только внутренняя сеть compose).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d --build testing-flask
|
||||||
|
```
|
||||||
|
|
||||||
|
Когда подключите БД из контейнера к `hr_postgres_dev` / `clinic_tests`, в `docker-compose.dev.yml` у сервиса `testing-flask` добавьте сеть `postgres` (как у `testing-backend`).
|
||||||
|
|
||||||
|
- **URL:** http://localhost:3108
|
||||||
|
- **Проверка:** http://localhost:3108/health
|
||||||
|
|
||||||
|
Вместе со старым UI и API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Тогда Node-интерфейс остаётся на **http://localhost:3107**, Flask — на **3108**.
|
||||||
|
|
||||||
|
## Запуск локально (без Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd flask_app
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # при необходимости поправьте PORT
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
По умолчанию: **http://127.0.0.1:3108** , проверка: **http://127.0.0.1:3108/health**
|
||||||
|
Для режима как в Docker (waitress): в `.env` задайте `WEB_USE_WAITRESS=1`.
|
||||||
|
|
||||||
|
## Зачем отдельная папка
|
||||||
|
|
||||||
|
- Один **стек** с кабинетом / мини-приложением — проще переносить экраны и запросы из `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`.
|
||||||
|
- **Отдельный** процесс и порт — без риска сломать текущий `docker-compose.dev.yml` с Node до готовности.
|
||||||
|
|
||||||
|
## Дальнейшие шаги (код)
|
||||||
|
|
||||||
|
1. Подключить БД (`clinic_tests` **или** `hr_bot_test` + `testing_*` — одно из двух, см. [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md) §0).
|
||||||
|
2. Переносить маршруты и шаблоны по образцу `tgFlaskForm` (blueprint `testing`, `db/queries/testing_queries.py`, шаблоны `cabinet/testing/`).
|
||||||
|
3. ETL при переходе на HR-БД: `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`.
|
||||||
|
|
||||||
|
## Связанные документы
|
||||||
|
|
||||||
|
- [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md)
|
||||||
|
- [README корня репозитория](../README.md)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Flask:
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
instance_relative_config=True,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static',
|
||||||
|
static_url_path='/static',
|
||||||
|
)
|
||||||
|
sk = (os.environ.get('SECRET_KEY') or '').strip()
|
||||||
|
app.config['SECRET_KEY'] = sk or secrets.token_hex(32)
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health():
|
||||||
|
return jsonify(status='ok', service='testing-flask-app')
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
from flask import render_template
|
||||||
|
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Тестирование</title>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Flask>=3.0.0,<4
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
waitress>=3.0.0
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Точка входа: dev — встроенный сервер Flask, prod — waitress."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(__file__), '.env'))
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
def _use_waitress() -> bool:
|
||||||
|
if os.environ.get('FLASK_ENV') == 'production':
|
||||||
|
return True
|
||||||
|
v = (os.environ.get('WEB_USE_WAITRESS') or '').strip().lower()
|
||||||
|
return v in ('1', 'true', 'yes', 'on')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
port = int(os.environ.get('PORT', '3108'))
|
||||||
|
if _use_waitress():
|
||||||
|
from waitress import serve
|
||||||
|
|
||||||
|
serve(app, host='0.0.0.0', port=port)
|
||||||
|
else:
|
||||||
|
app.run(host='0.0.0.0', port=port, debug=os.environ.get('FLASK_DEBUG') == '1')
|
||||||
@@ -74,8 +74,7 @@ export default function CabinetLayout() {
|
|||||||
school
|
school
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="cabinet-brand__title">Система тестрования</div>
|
<div className="cabinet-brand__title">Тестирование</div>
|
||||||
<div className="cabinet-brand__subtitle">Портал</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="cabinet-header__actions">
|
<div className="cabinet-header__actions">
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ export default function Login() {
|
|||||||
<div className="login-logo__frame" aria-hidden>
|
<div className="login-logo__frame" aria-hidden>
|
||||||
<span className="material-symbols-outlined">school</span>
|
<span className="material-symbols-outlined">school</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-headline">Система тестрования</h1>
|
<h1 className="font-headline">Тестирование</h1>
|
||||||
<p className="login-subtitle">Войдите в систему</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{err && (
|
{err && (
|
||||||
@@ -70,10 +69,6 @@ export default function Login() {
|
|||||||
Войти
|
Войти
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="text-muted" style={{ marginTop: '1.25rem', marginBottom: 0 }}>
|
|
||||||
Локальный пользователь в <code className="code-inline">clinic_tests</code> (если
|
|
||||||
отключён вход через персонал HR).
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,9 +107,7 @@ export default function TestAttempt() {
|
|||||||
{result.percent}%). Порог: {result.passingThreshold}%.
|
{result.percent}%). Порог: {result.passingThreshold}%.
|
||||||
</p>
|
</p>
|
||||||
<p className={result.passed ? 'text-muted' : 'error-text'}>
|
<p className={result.passed ? 'text-muted' : 'error-text'}>
|
||||||
{result.passed
|
{result.passed ? 'Зачёт.' : 'Незачёт.'}
|
||||||
? 'Тест пройден по порогу.'
|
|
||||||
: 'Порог не достигнут — при необходимости начните новую попытку на карточке теста.'}
|
|
||||||
</p>
|
</p>
|
||||||
{result.review && (
|
{result.review && (
|
||||||
<>
|
<>
|
||||||
@@ -121,17 +119,15 @@ export default function TestAttempt() {
|
|||||||
</h2>
|
</h2>
|
||||||
<AttemptReviewBlock review={result.review} showAttempter={false} />
|
<AttemptReviewBlock review={result.review} showAttempter={false} />
|
||||||
{result.attemptId && (
|
{result.attemptId && (
|
||||||
<p className="text-muted" style={{ fontSize: 14, marginTop: '0.75rem' }}>
|
<p style={{ fontSize: 14, marginTop: '0.75rem' }}>
|
||||||
<Link to={`/tests/${testId}/attempts/${result.attemptId}/review`}>
|
<Link to={`/tests/${testId}/attempts/${result.attemptId}/review`}>Разбор</Link>
|
||||||
Полная страница разбора
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="inline-actions" style={{ marginTop: '1rem' }}>
|
<div className="inline-actions" style={{ marginTop: '1rem' }}>
|
||||||
<Link to={`/tests/${testId}`} className="btn btn-ghost">
|
<Link to={`/tests/${testId}`} className="btn btn-ghost">
|
||||||
К настройкам теста
|
К тесту
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +152,7 @@ export default function TestAttempt() {
|
|||||||
{play.testTitle}
|
{play.testTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted" style={{ marginTop: 0 }}>
|
<p className="text-muted" style={{ marginTop: 0 }}>
|
||||||
Отметьте ответы и нажмите «Завершить». Порог для зачёта: {play.passingThreshold}%.
|
Порог зачёта: {play.passingThreshold}%.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ol style={{ paddingLeft: '1.25rem', maxWidth: 640 }}>
|
<ol style={{ paddingLeft: '1.25rem', maxWidth: 640 }}>
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ export default function TestDetail() {
|
|||||||
const [assignClinic, setAssignClinic] = useState('all');
|
const [assignClinic, setAssignClinic] = useState('all');
|
||||||
const [assignPeople, setAssignPeople] = useState([]);
|
const [assignPeople, setAssignPeople] = useState([]);
|
||||||
const [assignDepts, setAssignDepts] = useState([]);
|
const [assignDepts, setAssignDepts] = useState([]);
|
||||||
const [assignSource, setAssignSource] = useState('');
|
|
||||||
const [assignSelected, setAssignSelected] = useState(() => new Set());
|
const [assignSelected, setAssignSelected] = useState(() => new Set());
|
||||||
const [assignMsg, setAssignMsg] = useState('');
|
const [assignMsg, setAssignMsg] = useState('');
|
||||||
const [assignErr, setAssignErr] = useState(null);
|
const [assignErr, setAssignErr] = useState(null);
|
||||||
@@ -198,7 +197,6 @@ export default function TestDetail() {
|
|||||||
}
|
}
|
||||||
setAssignPeople(r.people || []);
|
setAssignPeople(r.people || []);
|
||||||
setAssignDepts(r.departments || []);
|
setAssignDepts(r.departments || []);
|
||||||
setAssignSource(r.source || '');
|
|
||||||
setAssignSelected(new Set());
|
setAssignSelected(new Set());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -261,8 +259,8 @@ export default function TestDetail() {
|
|||||||
});
|
});
|
||||||
setAssignMsg(
|
setAssignMsg(
|
||||||
out.count != null
|
out.count != null
|
||||||
? `Создано назначение на ${out.count} сотр. (dev).`
|
? `Назначено: ${out.count} сотр.`
|
||||||
: 'Назначение в БД создано (dev).'
|
: 'Назначение сохранено.'
|
||||||
);
|
);
|
||||||
setAssignSelected(new Set());
|
setAssignSelected(new Set());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -295,11 +293,7 @@ export default function TestDetail() {
|
|||||||
questions,
|
questions,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setDraftStatus(
|
setDraftStatus(out.forked ? 'Сохранено как новая версия' : 'Сохранено');
|
||||||
out.forked
|
|
||||||
? 'Создана новая версия (вилка) и применён черновик'
|
|
||||||
: 'Черновик применён на месте'
|
|
||||||
);
|
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setDraftStatus(e.message);
|
setDraftStatus(e.message);
|
||||||
@@ -582,15 +576,6 @@ export default function TestDetail() {
|
|||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.4rem', fontSize: 14 }}>
|
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.4rem', fontSize: 14 }}>
|
||||||
{formatTestAuthorLabel(user, t?.createdBy, t?.authorFullName)}
|
{formatTestAuthorLabel(user, t?.createdBy, t?.authorFullName)}
|
||||||
</p>
|
</p>
|
||||||
<p className="muted" style={{ marginTop: 0, marginBottom: '0.5rem' }}>
|
|
||||||
Режим сотрудника: одна цепочка — одна активная версия (v{t?.version ?? '—'}
|
|
||||||
{t?.activeVersionId && (
|
|
||||||
<span className="code-inline" style={{ marginLeft: 6, fontSize: '0.8rem' }}>
|
|
||||||
· {String(t.activeVersionId).slice(0, 8)}…
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
)
|
|
||||||
</p>
|
|
||||||
{t?.description && (
|
{t?.description && (
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}>
|
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||||
{t.description}
|
{t.description}
|
||||||
@@ -604,11 +589,6 @@ export default function TestDetail() {
|
|||||||
Активная версия недоступна. Обратитесь к автору теста.
|
Активная версия недоступна. Обратитесь к автору теста.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-muted" style={{ marginTop: '1.25rem' }}>
|
|
||||||
Пройдите тест в{' '}
|
|
||||||
<Link to="/tests">каталоге</Link> — в строке с названием слева откроется карточка, справа
|
|
||||||
кнопка «Пройти».
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -645,13 +625,6 @@ export default function TestDetail() {
|
|||||||
{aiTestBusy ? 'Генерация…' : 'Сгенерировать тест (ИИ)'}
|
{aiTestBusy ? 'Генерация…' : 'Сгенерировать тест (ИИ)'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem', fontSize: 13 }}>
|
|
||||||
Заполняет все вопросы и варианты по <strong>текущей структуре</strong> (число карточек
|
|
||||||
вопросов и вариантов в каждой — задайте кнопками «+ вопрос» / «+ вариант»). Верные ответы
|
|
||||||
отмечает модель. Нужен ключ в backend:{' '}
|
|
||||||
<code className="code-inline">DEEPSEEK_API_KEY</code> или{' '}
|
|
||||||
<code className="code-inline">OPENAI_API_KEY</code>.
|
|
||||||
</p>
|
|
||||||
{test?.description && (
|
{test?.description && (
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}>
|
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||||
{test.description}
|
{test.description}
|
||||||
@@ -668,17 +641,13 @@ export default function TestDetail() {
|
|||||||
|
|
||||||
{test?.chainActive === false && (
|
{test?.chainActive === false && (
|
||||||
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
|
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
|
||||||
Эта цепочка <strong>не показывается</strong> в верхнем списке на «Тесты».
|
Скрыт из общего списка.
|
||||||
Карточку всегда можно открыть из раздела <strong>«Скрытые вами из списка»</strong>{' '}
|
|
||||||
на той же странице или по закладке с адресом карточки. Снова включить
|
|
||||||
отображение — в блоке «Публикация»; данные не удаляются.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chain?.hasAnyAttempt && (
|
{chain?.hasAnyAttempt && (
|
||||||
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
|
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
|
||||||
Уже были попытки по этой цепочке. Сохранение черновика с вопросами создаст{' '}
|
При сохранении будет создана новая версия теста.
|
||||||
<strong>новую версию</strong> (см. V.1–V.3); старая останется в истории.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data && attemptsErr && (
|
{data && attemptsErr && (
|
||||||
@@ -689,10 +658,6 @@ export default function TestDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AccSection title="История версий" defaultOpen={false}>
|
<AccSection title="История версий" defaultOpen={false}>
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
|
||||||
Активная — та, по которой сейчас идут новые попытки. Можно вручную сделать
|
|
||||||
активной другую строку.
|
|
||||||
</p>
|
|
||||||
<div className="surface-card" style={{ padding: 0, overflow: 'hidden' }}>
|
<div className="surface-card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||||
<table className="table-cabinet">
|
<table className="table-cabinet">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -735,20 +700,10 @@ export default function TestDetail() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{hasAttempts && (
|
|
||||||
<p className="text-muted" style={{ marginTop: '0.75rem', marginBottom: 0 }}>
|
|
||||||
По цепочке есть зафиксированные прогоны — разбор идёт по той версии, с
|
|
||||||
которой проходили.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</AccSection>
|
</AccSection>
|
||||||
|
|
||||||
{attemptsList != null && attemptsList.length > 0 && (
|
{attemptsList != null && attemptsList.length > 0 && (
|
||||||
<AccSection title="Прогоны и разбор" defaultOpen={false}>
|
<AccSection title="Прогоны и разбор" defaultOpen={false}>
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
|
||||||
Попытки по всем версиям цепочки. Подробный разбор вариантов — для завершённых
|
|
||||||
прогонов.
|
|
||||||
</p>
|
|
||||||
{attemptsErr && (
|
{attemptsErr && (
|
||||||
<p className="error-text" role="alert">
|
<p className="error-text" role="alert">
|
||||||
{attemptsErr}
|
{attemptsErr}
|
||||||
@@ -813,10 +768,6 @@ export default function TestDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<AccSection title="Публикация (видимость в списке)" defaultOpen={false}>
|
<AccSection title="Публикация (видимость в списке)" defaultOpen={false}>
|
||||||
<p className="text-muted" style={{ marginTop: 0 }}>
|
|
||||||
Деактивация убирает тест из общего списка; карточка открывается по прямой
|
|
||||||
ссылке.
|
|
||||||
</p>
|
|
||||||
<div className="inline-actions" style={{ marginTop: '0.5rem' }}>
|
<div className="inline-actions" style={{ marginTop: '0.5rem' }}>
|
||||||
{test?.chainActive !== false ? (
|
{test?.chainActive !== false ? (
|
||||||
<button
|
<button
|
||||||
@@ -841,12 +792,6 @@ export default function TestDetail() {
|
|||||||
</AccSection>
|
</AccSection>
|
||||||
|
|
||||||
<AccSection title="Импорт из файла" defaultOpen={false}>
|
<AccSection title="Импорт из файла" defaultOpen={false}>
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.5rem' }}>
|
|
||||||
PDF, DOCX, TXT/MD, до 10 МБ. Текст извлекается на сервере; при{' '}
|
|
||||||
<code className="code-inline">DEEPSEEK_API_KEY</code> или{' '}
|
|
||||||
<code className="code-inline">OPENAI_API_KEY</code> в backend строится черновик теста
|
|
||||||
(см. <code className="code-inline">generation.draft</code>).
|
|
||||||
</p>
|
|
||||||
<div className="inline-actions" style={{ marginBottom: '0.5rem' }}>
|
<div className="inline-actions" style={{ marginBottom: '0.5rem' }}>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -909,12 +854,6 @@ export default function TestDetail() {
|
|||||||
</AccSection>
|
</AccSection>
|
||||||
|
|
||||||
<AccSection title="Содержание: название, порог, вопросы" defaultOpen>
|
<AccSection title="Содержание: название, порог, вопросы" defaultOpen>
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
|
||||||
Редактируется активная версия. Сохранение отправляет вопросы на сервер; при уже
|
|
||||||
существующих попытках по цепочке создаётся новая версия с копией контента.
|
|
||||||
Порог зачёта — в таблице <code className="code-inline">tests</code>, на все
|
|
||||||
версии цепочки.
|
|
||||||
</p>
|
|
||||||
<div className="draft-block">
|
<div className="draft-block">
|
||||||
<label className="form-label" htmlFor="draft-title">
|
<label className="form-label" htmlFor="draft-title">
|
||||||
Название
|
Название
|
||||||
@@ -938,9 +877,6 @@ export default function TestDetail() {
|
|||||||
<label className="form-label" htmlFor="draft-pass" style={{ marginTop: '0.75rem' }}>
|
<label className="form-label" htmlFor="draft-pass" style={{ marginTop: '0.75rem' }}>
|
||||||
Порог зачёта, %
|
Порог зачёта, %
|
||||||
</label>
|
</label>
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: 6, fontSize: 13 }}>
|
|
||||||
Минимум правильных ответов (в процентах) для «зачёта»; по умолчанию в БД 70.
|
|
||||||
</p>
|
|
||||||
<input
|
<input
|
||||||
id="draft-pass"
|
id="draft-pass"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -977,10 +913,6 @@ export default function TestDetail() {
|
|||||||
{aiQBusy === qi ? '…' : 'Сгенерировать вопрос (ИИ)'}
|
{aiQBusy === qi ? '…' : 'Сгенерировать вопрос (ИИ)'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: 6, fontSize: 12 }}>
|
|
||||||
Пустое поле — вопрос и все варианты по числу кнопок; непустое — сформулировать
|
|
||||||
нормальным языком (1–3 предложения).
|
|
||||||
</p>
|
|
||||||
<textarea
|
<textarea
|
||||||
id={`qtext-${q.key}`}
|
id={`qtext-${q.key}`}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
@@ -1113,11 +1045,6 @@ export default function TestDetail() {
|
|||||||
|
|
||||||
{assignmentUi && data && (
|
{assignmentUi && data && (
|
||||||
<AccSection title="Назначение сотрудникам" defaultOpen={false}>
|
<AccSection title="Назначение сотрудникам" defaultOpen={false}>
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
|
||||||
Сотрудники из HR; при отсутствии учётки в модуле она создаётся по{' '}
|
|
||||||
<code className="code-inline">staff_id</code>. Источник данных:{' '}
|
|
||||||
<strong>{assignSource === 'hr' ? 'HR (все, с отделами)' : 'только clinic_tests'}</strong>.
|
|
||||||
</p>
|
|
||||||
{assignErr && (
|
{assignErr && (
|
||||||
<p className="error-text" role="alert">
|
<p className="error-text" role="alert">
|
||||||
{assignErr}
|
{assignErr}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export default function TestsList() {
|
|||||||
{' '}
|
{' '}
|
||||||
·{' '}
|
·{' '}
|
||||||
</span>
|
</span>
|
||||||
v{t.version} · активная {t.active_version_id?.slice(0, 8) ?? '—'}…
|
v{t.version}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,17 +128,7 @@ export default function TestsList() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{tests.length === 0 && hiddenByYou.length === 0 && (
|
{tests.length === 0 && hiddenByYou.length === 0 && (
|
||||||
<p className="text-muted">
|
<p className="text-muted">Нет тестов</p>
|
||||||
{canCreate
|
|
||||||
? 'Пусто. В списке — только тесты, которые вы ведёте, и назначенные вам. Создайте цепочку или дождитесь назначения.'
|
|
||||||
: 'Пусто: вам пока ничего не назначено и нет цепочек, где вы автор.'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{tests.length === 0 && hiddenByYou.length > 0 && (
|
|
||||||
<p className="text-muted" style={{ marginBottom: '0.75rem' }}>
|
|
||||||
В общем списке пусто — у вас есть скрытые тесты ниже; откройте карточку,
|
|
||||||
чтобы снова включить отображение.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hiddenByYou.length > 0 && (
|
{hiddenByYou.length > 0 && (
|
||||||
@@ -149,10 +139,6 @@ export default function TestsList() {
|
|||||||
>
|
>
|
||||||
Скрытые вами из списка
|
Скрытые вами из списка
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
|
||||||
Эти цепочки не видны в блоке выше. Откройте карточку и внизу раздела
|
|
||||||
«Публикация» нажмите «Снова показать в списке».
|
|
||||||
</p>
|
|
||||||
<ul className="list-stack" aria-label="Скрытые тесты автора">
|
<ul className="list-stack" aria-label="Скрытые тесты автора">
|
||||||
{hiddenByYou.map((t) => (
|
{hiddenByYou.map((t) => (
|
||||||
<li
|
<li
|
||||||
@@ -169,7 +155,7 @@ export default function TestsList() {
|
|||||||
{' '}
|
{' '}
|
||||||
·{' '}
|
·{' '}
|
||||||
</span>
|
</span>
|
||||||
v{t.version} · скрыт · {t.active_version_id?.slice(0, 8) ?? '—'}…
|
v{t.version} · скрыт
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user