diff --git a/.DS_Store b/.DS_Store index 3a1fbfc..273274a 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/README.md b/README.md index c58c6d5..8d22bc7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ **Актуальное состояние кода (не ТЗ, а «что уже есть»):** [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) · [инструкция для проверяющих на dev](docs/DEV_CONTOUR_USER_GUIDE.md) · [кабинет: коротко для врачей/кураторов](docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md). **Спринты мобильного UI (чек-лист для разработки):** [docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). -**Перенос на стек кабинета / мини-приложения:** [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md) · [простым языком](docs/migration-to-tgflaskform-plain.md). Отдельный Flask-контур: [flask_app/README.md](flask_app/README.md). +**Унификация стека (текущий этап) и слияние с 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`». --- diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 704662d..c4233ca 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -59,11 +59,21 @@ services: WEB_USE_WAITRESS: "1" FLASK_DEBUG: "0" SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me} + # БД (clinic_tests). Хост postgres — в общей сети hr_postgres_dev_net. + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://app:app@postgres:5432/clinic_tests} + # Опц. HR-кабинет (E1.1): включается флагом + URL базы hr_bot_test. + HR_AUTH: ${HR_AUTH:-0} + HR_DATABASE_URL: ${HR_DATABASE_URL:-} + # LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта. + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + LLM_BASE_URL: ${LLM_BASE_URL:-} + LLM_MODEL: ${LLM_MODEL:-} ports: - "3108:3108" networks: - app - # когда понадобится БД из контейнера — добавьте сеть postgres (hr_postgres_dev_net) + - postgres networks: app: diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index aa2dde7..54d9397 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -23,6 +23,8 @@ - **Активная версия** — та, с которой сейчас стартуют новые попытки. Автор может **вручную** переключить активную версию в таблице истории (с подтверждением), если бизнесу так нужно. - **Публикация / видимость:** в кабинете (аккордеон **«Показ в каталоге»**, подсекция **«Видимость»**) тест можно **скрыть из общего списка** (цепочка остаётся в базе) или **снова показать**; **назначения** (подсекция **«Кому выдать»**) — при включённой фиче, см. раздел «Назначения» ниже. - **Мобильный 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). ### Список тестов и доступ diff --git a/docs/images/cabinet-ui/README.md b/docs/images/cabinet-ui/README.md deleted file mode 100644 index d104168..0000000 --- a/docs/images/cabinet-ui/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Иллюстрации к [РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md](../РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) - -Сейчас здесь **SVG-схемы** (placeholder). Их **подключает** `РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md` в корне `docs/`. - -Чтобы подставить **скриншоты** с экрана кабинета / мини-приложения: - -1. Сделайте 2–3 снимка (список тестов, низ с **«Сохранить»**, **«Показ в каталоге»**). -2. Сохраните в эту папку, например `spisok.png`, `chernovik.png`, `pokaz.png`. -3. В **`РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md`** замените в строках `![...](...)` пути `placeholder-*.svg` на ваши `*.png` (или оставьте SVG). - -Текущие имена в ссылках: `placeholder-spisok.svg`, `placeholder-chernovik.svg`, `placeholder-pokaz.svg`. diff --git a/docs/images/cabinet-ui/placeholder-chernovik.svg b/docs/images/cabinet-ui/placeholder-chernovik.svg deleted file mode 100644 index caba0a1..0000000 --- a/docs/images/cabinet-ui/placeholder-chernovik.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - =87C  !>E@0=8BL G5@=>28: - A>E@0=O5B 2AQ, GB> =0?8A0;8 2 2>?@>A0E - - !>E@0=8BL G5@=>28: - -  A?8A:C - diff --git a/docs/images/cabinet-ui/placeholder-pokaz.svg b/docs/images/cabinet-ui/placeholder-pokaz.svg deleted file mode 100644 index 1fc7833..0000000 --- a/docs/images/cabinet-ui/placeholder-pokaz.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - >:07 2 :0B0;>35 - 848<>ABL  2 >1I5< A?8A:5 8;8 A:@KB - >B@C4=8:8 (5A;8 2:;NG5=>) - 07=0G5=85 = 2K40BL B5AB 2 @01>BC, =5 ?CB09B5 A A>E@0=8BL - K1@0BL 2A5E = B>;L:> 2 B5:CI5< >BD8;LB@>20==>< A?8A:5 - diff --git a/docs/images/cabinet-ui/placeholder-spisok.svg b/docs/images/cabinet-ui/placeholder-spisok.svg deleted file mode 100644 index e5ad514..0000000 --- a/docs/images/cabinet-ui/placeholder-spisok.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - !?8A>: B5AB>2 - :0@B>G:0 =06<8B5 =0 =0720=85 8;8 @>9B8 - - - diff --git a/docs/migration-final-inventory.md b/docs/migration-final-inventory.md new file mode 100644 index 0000000..4bf01cd --- /dev/null +++ b/docs/migration-final-inventory.md @@ -0,0 +1,291 @@ +# Инвентаризация backend и справочный gap-analysis с `tgFlaskForm` + +**Назначение.** §1–§9 — полная карта того, что **сейчас живёт** в `backend/` (Node.js / Express). Используется в [Этапе 1](migration-final.md#этап-1-текущий--единый-стек-express--flask-react--jinja-внутри-testingwebapp) как чек-лист «что переписать на Flask внутри `flask_app/`». §10 — **справочный gap-analysis** между этим и уже готовым модулем `cabinet/testing` в `HR_TG_Bot/tgFlaskForm` — пригодится в [Этапе 2](migration-to-tgflaskform.md) при слиянии. + +**Связано:** [migration-final.md](migration-final.md) (главный трекер двух этапов), [migration-to-tgflaskform.md](migration-to-tgflaskform.md) (план Этапа 2 — слияние с tgFlaskForm), [PROJECT_STATUS.md](PROJECT_STATUS.md), [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). + +> **Прочитайте сначала [migration-final.md](migration-final.md)** — там зафиксировано разделение на Этап 1 (унификация стека внутри TestingWebApp, БД остаётся `clinic_tests`) и Этап 2 (слияние с HR-кабинетом, БД мигрирует на `hr_bot_test`). Содержимое §10 этого документа — материал для Этапа 2. + +--- + +## 1. Карта HTTP-эндпоинтов (всё, что зовёт фронтенд) + +Источник: `backend/src/app.js`, `backend/src/routes/*.js`. Перепроверено по фактическим вызовам из `frontend/src/**` (`frontend/src/api.js` — общий `fetch` с `credentials: 'include'`). + +| # | Метод | Путь | Зовёт из фронта | Auth | Сервис(ы) | Особенности | +|---|------|------|----------------|------|----------|-------------| +| 1 | GET | `/api/health` | — | нет | inline | smoke-проверка | +| 2 | POST | `/api/auth/login` | `Login.jsx` | нет | `utils/auth`, `db/db`, `db/hrPool`, `utils/werkzeugPassword`, `utils/hrRoleMap`, `config/authConstants` | bcrypt (dev) **+** Werkzeug (HR) ветки; UPSERT `users` по `staff_id`; кладёт JWT в HTTP-only cookie | +| 3 | POST | `/api/auth/logout` | `CabinetLayout.jsx` | нет | inline | очистить cookie | +| 4 | GET | `/api/auth/me` | `CabinetLayout.jsx` | cookie JWT | `middleware/auth` + `featureFlags` | возвращает пользователя + флаги UI (`devUi`, `assignmentUi`) | +| 5 | GET | `/api/auth/dev/assignment-directory` | `TestDetail.jsx` (assign-блок) | cookie JWT, feature-flag | `services/assignmentDirectoryService` | сливает HR (`staff_members`, `employees_departments`) + `clinic_tests.users`; query: `q`, `department`, `clinic` | +| 6 | POST | `/api/tests/import/document` | `TestDetail.jsx` («Документ в вопросы») | cookie JWT | `services/documentExtractService` (PDF/DOCX/TXT), `services/documentGenService` → `services/llmClient` | `multer` (10 МБ, OS tmpdir); удаляет файл после извлечения | +| 7 | GET | `/api/tests` | `TestsList.jsx` | cookie JWT | `services/testAccessService.queryTestsVisibleToUser` + inline (hiddenByYou) | каталог + список «скрытые мной» | +| 8 | POST | `/api/tests` | `TestsList.jsx` | cookie JWT | `services/testDraftService.createTestWithVersion` | создаёт цепочку + версию 1 | +| 9 | GET | `/api/tests/:id/summary` | `TestDetail.jsx` | cookie JWT | inline + `testAccessService.userHasTestAccess` | карточка цепочки (одна строка) | +| 10 | GET | `/api/tests/:id/versions` | `TestDetail.jsx` | cookie JWT, **только автор** | inline + `testChainService.hasAnyAttemptForTest` | список версий + флаг `hasAttempts` | +| 11 | GET | `/api/tests/:id/editor` | `TestDetail.jsx` | cookie JWT, **только автор** | `testAttemptService.getEditorContent` | вопросы активной версии **с правильными ответами** | +| 12 | POST | `/api/tests/:id/ai/generate-test` | `TestDetail.jsx` (ИИ — целиком) | cookie JWT, **только автор** | `aiEditorService.generateFullTestByShape` → `llmClient` | строгая сетка `shape: [{optionsCount, hasMultipleAnswers}]`; до 40 вопросов | +| 13 | POST | `/api/tests/:id/ai/generate-question` | `TestDetail.jsx` (ИИ — один вопрос) | cookie JWT, **только автор** | `aiEditorService.generateOrRephraseQuestion` → `llmClient` | пустой текст → новый вопрос; непустой → переформулировка | +| 14 | POST | `/api/tests/:id/versions/:vid/activate` | `TestDetail.jsx` | cookie JWT, **только автор** | inline | транзакция; снимает `is_active` со всех версий, потом ставит на `:vid` | +| 15 | PATCH | `/api/tests/:id` | `TestDetail.jsx` | cookie JWT, **только автор** | inline | `chainActive` (true/false) — публикация в каталоге | +| 16 | POST | `/api/tests/:id/assign` | `TestDetail.jsx` (назначение) | cookie JWT, **только автор**, feature-flag | `assignmentUserService.ensureClinicUserIdForStaff` | принимает `userIds[]`/`staffIds[]`/legacy `userId`/`staffId`; одна строка `test_assignments` + N строк `test_assignment_targets` | +| 17 | POST | `/api/tests/:id/draft` | `TestDetail.jsx` («Сохранить») | cookie JWT, **только автор** | `testDraftService.saveTestDraft` | если есть попытки и переданы `questions` — fork новой версии (V.3) | +| 18 | POST | `/api/tests/:id/attempts/start` | `TestsList.jsx` | cookie JWT (доступ через `userHasTestAccess`) | `testAccessService.userHasTestAccess` + inline | новая попытка по активной версии | +| 19 | GET | `/api/tests/:id/attempts` | `TestDetail.jsx` («Прохождения») | cookie JWT, **только автор** | `testAttemptService.listTestAttemptsForAuthor` | до 200 попыток по всем версиям | +| 20 | GET | `/api/tests/:id/attempts/:aid/review` | `TestAttemptReview.jsx` | cookie JWT (владелец **или** автор) | `testAttemptService.getAttemptReviewForUser` → `buildReviewFromDb` | разбор попытки | +| 21 | GET | `/api/tests/:id/attempts/:aid/play` | `TestAttempt.jsx` | cookie JWT (владелец) | `testAttemptService.getPlayContent` | вопросы **без** правильных ответов | +| 22 | POST | `/api/tests/:id/attempts/:aid/submit` | `TestAttempt.jsx` | cookie JWT (владелец) | `testAttemptService.submitAttempt` | `FOR UPDATE`, проверка ответов, перезапись `user_answers`, статус `completed` | +| 23 | GET | `/api/tests/:id/chain-info` | `TestDetail.jsx` | cookie JWT (через `userHasTestAccess`) | `testAccessService` + `testChainService` | флаг `hasAnyAttempt` | + +> **Итого: 22 функциональных эндпоинта** (без `/api/health`). Все ответы — JSON. Все входы — JSON или `multipart/form-data` (только `/import/document`). + +--- + +## 2. Сервисный уровень (что должно появиться в Flask) + +| Файл (Node) | Что делает | Что нужно в Python | +|------------|-----------|---------------------| +| `services/testAccessService.js` | Запросы каталога; `userHasTestAccess(testId, userId)` | SQLAlchemy/`psycopg2` версия двух запросов | +| `services/testDraftService.js` | `createTestWithVersion`, `saveTestDraft`, `forkNewVersion`, `replaceVersionContent`, `copyQuestionTree` | Транзакции на `psycopg2`/SQLAlchemy; следить за частичным уникальным индексом `uq_test_versions_one_active_per_test` | +| `services/testAttemptService.js` | `getEditorContent`, `getPlayContent`, `submitAttempt`, `buildReviewFromDb`, `getAttemptReviewForUser`, `listTestAttemptsForAuthor`; вычисление баллов | Самый объёмный модуль; внимательно с массивами UUID (`user_answers.selected_options uuid[]`) | +| `services/testChainService.js` | `hasAnyAttemptForTest` | Один SQL `EXISTS` | +| `services/aiEditorService.js` | `parseAndValidateShape`, `generateFullTestByShape`, `generateOrRephraseQuestion`, `assertDraftMatchesShape` | Чистый Python; зависит от `llmClient` и `documentGenService.validate*` | +| `services/documentGenService.js` | `parseJsonFromLlmText`, `validateAndNormalizeDraft`, `generationForImportDocument` | Чистый Python (`json.loads` + валидация формы) | +| `services/documentExtractService.js` | `resolveDocumentKind`, `extractTextFromFile`, `extractTextFromBuffer` | **Замены пакетов:** `mammoth` → `python-docx` или `mammoth.py`; `pdf-parse` → `pypdf` (или `pdfminer.six`) | +| `services/llmClient.js` | OpenAI-совместимый Chat Completions, JSON-mode, таймаут 120 с | `httpx` / `requests` + явный `timeout` | +| `services/assignmentDirectoryService.js` | Слияние `clinic_tests.users` ↔ HR (`staff_members`, `employees_departments`); фильтры `q/department/clinic` | Два пула; `psycopg2` достаточно | +| `services/assignmentUserService.js` | `ensureClinicUserIdForStaff` (UPSERT по `staff_id`) | UPSERT с `ON CONFLICT (staff_id)` | +| `utils/auth.js` | bcrypt + JWT + (Werkzeug fallback) | `passlib[bcrypt]` или `bcrypt`; **`flask-jwt-extended`** или ручной `pyjwt` | +| `utils/werkzeugPassword.js` | `scrypt:$N:$r:$p$salt$hex`, `pbkdf2:sha256:iter$salt$hex` | Уже **родной** Python: `werkzeug.security.check_password_hash` | +| `utils/hrRoleMap.js` | строка HR-роли → `'hr'/'manager'/'employee'` | Один `def map_hr_role()` | +| `middleware/auth.js` | `authenticate`, `requireRole`, `requireDepartment`, `optionalAuth` | Flask-декоратор `@login_required`/`@roles_required` (или `before_request`) | +| `config/featureFlags.js` | `isAssignmentFeatureEnabled` | Простая функция от env | +| `config/devAuthor.js` | `isTestAuthor(createdBy, userId)` | Один сравнительный helper | +| `config/authConstants.js` | `HR_MANAGED_PASSWORD_PLACEHOLDER`, `isHrAuthEnabled` | Без изменений | +| `messages/ru.js` | Текстовые сообщения API | `app/messages/ru.py` (dict) | + +> **Особое внимание:** `testAttemptService.submitAttempt` использует `FOR UPDATE` и выгружает все `answer_options` версии разом. Простую построчную проверку «правильно/нет» делает Python-функция `same_selection(set, set)`. На больших тестах помогает индекс `idx_answer_options_question_id` — он уже есть. + +--- + +## 3. Базы данных и схемы + +### 3.1 Основная — `clinic_tests` (`DATABASE_URL`) + +Из `backend/src/db/migrations/001_initial.sql`: + +- `departments` (UUID, name) +- `users` (UUID, login UNIQUE, password_hash, full_name, role `user_role`, department_id FK, is_active, **`staff_id`** — миграция 003, **UNIQUE**) +- `tests` (UUID, title, description, passing_threshold, time_limit, allow_back, is_active, is_versioned, created_by FK) +- `test_versions` (UUID, test_id FK, version, is_active; миграция 002 добавляет `parent_id` + частичный уникальный индекс «одна активная версия на цепочку») +- `questions` (UUID, test_version_id FK, text, question_order, has_multiple_answers) +- `answer_options` (UUID, question_id FK, text, is_correct, option_order) +- `test_assignments` (UUID, test_version_id FK, assigned_by FK, deadline DATE, max_attempts) +- `test_assignment_targets` (UUID, assignment_id FK, target_type `'department'|'user'`, target_id UUID) +- `test_attempts` (UUID, test_version_id FK, user_id FK, attempt_number, status `attempt_status`, started_at, completed_at, correct_count, total_questions, passed; UNIQUE(test_version_id, user_id, attempt_number)) +- `user_answers` (UUID, attempt_id FK, question_id FK, selected_options **UUID[]**) +- `migrations` (служебная, имена применённых SQL) + +**Расширения:** `uuid-ossp`. **Кастомные типы:** `user_role`, `target_type`, `attempt_status`. + +### 3.2 Дополнительная — `hr_bot_test` (`HR_DATABASE_URL`, опционально) + +Используется только на чтение через `db/hrPool.js`: + +- `users` — для входа (HR_AUTH=1): `id`, `username`, `password_hash` (Werkzeug), `role` +- `staff_members` — `id`, `fio`, `web_login` +- `employees_departments` — `staff_id`, `department` + +### 3.3 Миграции + +3 SQL-файла в `backend/src/db/migrations/`. Простой `npm run migrate` пишет в служебную таблицу `migrations`. В Flask эквивалент: **Alembic** или ручной runner на `psycopg2`. Файлы `.sql` можно переиспользовать **как есть** — никакого Node-специфичного синтаксиса в них нет. + +--- + +## 4. Зависимости Node → Python (план замены) + +| Node-пакет | Python-замена | Комментарий | +|-----------|----------------|-------------| +| `express`, `cors`, `cookie-parser` | **Flask** + `flask-cors` | сессии встроены | +| `multer` | `Flask` (`request.files`) + `werkzeug.utils.secure_filename` + `tempfile` | лимит 10 МБ — `MAX_CONTENT_LENGTH` | +| `dotenv` | `python-dotenv` (уже есть в `flask_app/run.py`) | — | +| `pg` | `psycopg2-binary` (или `psycopg[binary]` v3) | — | +| `bcryptjs` | `bcrypt` (`pip install bcrypt`) | формат `$2b$…` совместим | +| `jsonwebtoken` | `pyjwt` или `flask-jwt-extended` | важен **тот же** алгоритм/секрет, чтобы старые cookie работали в переходный период | +| `mammoth` | `mammoth` (PyPI: `pip install mammoth`) или `python-docx` | API почти идентичен | +| `pdf-parse` | `pypdf` (`pip install pypdf`) или `pdfminer.six` | `pypdf.PdfReader().pages[].extract_text()` | +| `fetch` (LLM) | `httpx` (рекомендую — есть `timeout`, async) или `requests` | сохранить `Authorization: Bearer …`, `response_format: json_object` | + +**Уже в `tgFlaskForm` есть готовое** — `werkzeug.security.check_password_hash` (бесплатно), Sentry, Jinja2-шаблоны, обмен с `staff_members`. Переиспользовать, если выберем сценарий «общий кабинет». + +--- + +## 5. Переменные окружения (полный список) + +| Переменная | Где читается | Назначение | Обязательная | +|-----------|--------------|-----------|--------------| +| `NODE_ENV` | `app.js`, `auth.js`, `featureFlags.js`, `db.js` | dev vs production | да (нет → dev) | +| `PORT` | `server.js` | Express, по умолчанию 3001 | нет | +| `FRONTEND_URL` | `app.js` (CORS) | разрешённый origin в prod | в prod — да | +| `DATABASE_URL` **или** `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USER`/`DB_PASSWORD` | `db/poolConfig.js` | основная БД `clinic_tests` | да | +| `DB_POOL_MAX`, `DB_IDLE_TIMEOUT`, `DB_CONNECTION_TIMEOUT` | `db/poolConfig.js` | пул | нет | +| `HR_DATABASE_URL` | `db/poolConfig.js` (`getHrPoolConfig`) | hr_bot_test (read-only) | при HR_AUTH=1 — да | +| `HR_DB_POOL_MAX` | то же | пул HR | нет | +| `HR_AUTH` (1/true) | `config/authConstants.js` | вход по HR-логину | нет | +| `JWT_SECRET` | `utils/auth.js` | подпись JWT | **да** | +| `JWT_EXPIRES_IN` | `utils/auth.js` | дефолт `7d` | нет | +| `DEEPSEEK_API_KEY` | `services/llmClient.js` | LLM, приоритет №1 | для AI/импорта — да | +| `OPENAI_API_KEY` | то же | LLM, приоритет №2 | альтернатива | +| `LLM_BASE_URL` | то же | сменить хост (proxy / vLLM) | нет | +| `LLM_MODEL` | то же | по умолчанию `deepseek-chat` или `gpt-4o-mini` | нет | +| `LLM_NO_JSON` (1) | то же | отключить `response_format: json_object` (для моделей без поддержки) | нет | +| `CLINIC_ASSIGNMENT_ENABLED` (1) | `config/featureFlags.js` | прод: включить assign | в prod — да | + +В `flask_app/.env` нужно перенести **те же ключи** (имена можно сохранить, чтобы не плодить вариации). + +--- + +## 6. Что вызывает фронтенд (карта зависимостей React → API) + +Используется один тонкий клиент `frontend/src/api.js`: `fetch` с `credentials: 'include'`, базовый путь — пустой (т.е. **`/api/...` относительно текущего origin**, что в dev резолвит Vite-proxy, а в prod — Nginx). Это значит: + +- **Менять фронтенд при смене бэкенда не нужно**, если новый сервис отвечает по тем же путям. +- В dev сейчас `vite.config.js` проксирует `/api` на Express (`localhost:3001`). После переноса — заменить адрес/порт на Flask (см. `flask_app/run.py`, по умолчанию `3108`). + +Список путей (отсортирован по убыванию использований): + +``` +/api/auth/login +/api/auth/logout +/api/auth/me +/api/auth/dev/assignment-directory?q&department&clinic +/api/tests +/api/tests/:id/summary +/api/tests/:id/versions +/api/tests/:id/editor +/api/tests/:id/chain-info +/api/tests/:id/draft +/api/tests/:id/assign +/api/tests/:id/ai/generate-test +/api/tests/:id/ai/generate-question +/api/tests/:id (PATCH) +/api/tests/:id/versions/:vid/activate +/api/tests/:id/attempts +/api/tests/:id/attempts/start +/api/tests/:id/attempts/:aid/play +/api/tests/:id/attempts/:aid/review +/api/tests/:id/attempts/:aid/submit +/api/tests/import/document +``` + +--- + +## 7. Тесты, которые тянутся за собой + +В `backend/src/**/*.test.js` (Node test runner): + +- `apiSmoke.test.js` — smoke-проверки HTTP. +- `services/testChainService.test.js` +- `services/aiEditorService.test.js` +- `services/documentGenService.test.js` +- `services/documentExtractService.test.js` +- `utils/werkzeugPassword.test.js` +- `integration/v9card1.test.js` (требует `CLINIC_TESTS_INTEGRATION=1`) + +После переноса нужны их Python-аналоги (`pytest`). Часть (валидация LLM-формы, разбор Werkzeug) тривиально превращается в юниты. + +--- + +## 8. Чего сейчас в `flask_app/` НЕТ (чтобы не повторяться в Спринте 2) + +`flask_app/` содержит только: `run.py`, `app/__init__.py` (с `/health` и `/`), `app/templates/`, `app/static/`. Не реализовано **ничего** из перечня §1–§4. Это значит, что в Спринте 2 нужно с нуля поднять: + +1. Подключение к БД (минимум — основной пул `clinic_tests`). +2. Декоратор аутентификации (cookie JWT) и эндпоинты `/api/auth/*`. +3. Роуты `/api/tests/*` поэтапно — начать с **read-only** (list, summary, versions, editor, attempts, review), потом write (draft, activate, patch, assign, attempts/start/submit), потом AI/import. +4. CORS (в dev совпадает с тем, что у Express). +5. Запуск под Vite/Nginx — обновить proxy/upstream. + +--- + +## 9. Критерии «можно удалять `backend/`» (Спринт 4) + +- ETL-скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py` прогнан **на копии прод**, агрегаты совпадают (тесты, версии, вопросы, попытки) — затем прогон на проде в окне. +- Все сценарии §1 (TestingWebApp) либо имеют **рабочий аналог** в `cabinet/testing/`, либо явно **признаны не-MVP** (см. §10). +- Внутренние ссылки и закладки сотрудников переключены на URL общего кабинета (`/cabinet/testing/...`). +- Документация (`README.md`, `DEV_CONTOUR_USER_GUIDE.md`, `шаги/*`) перестала ссылаться на `backend/` и `frontend/` TestingWebApp. +- Резерв: ветка `legacy/clinic-tests-node` (или `legacy/express-backend`) зафиксирована перед удалением. + +--- + +## 10. Gap-analysis: `tgFlaskForm/cabinet/testing` vs Express + +Сверка проведена по фактическому коду `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/*` и `HR_TG_Bot/tgFlaskForm/db/queries/testing_queries.py` на 2026-04-27. Колонка «Что нужно сделать» — это и есть остаток Спринта 2 из [migration-to-tgflaskform.md](migration-to-tgflaskform.md). + +### 10.1 Маппинг сущностей и ID + +| TestingWebApp (`clinic_tests`) | tgFlaskForm (`hr_bot_test`, схема `testing_*`) | Замечание | +|---|---|---| +| `tests` (UUID) | `testing_tests` (Integer PK) | UUID → integer; маппинг хранится в `_clinic_tests_migration_map` | +| `test_versions` (UUID, `version`, `is_active`, `parent_id`) | `testing_test_versions` (Integer, `version_number`, `is_active_version`) — **+** `passing_score_percent`, `time_limit_minutes`, `allow_back_navigation` (нормализовано на версию) | В Express часть полей лежала на `tests` (`passing_threshold`, `time_limit`, `allow_back`); ETL поднимает их на версию | +| `questions` (`text`, `has_multiple_answers`, `question_order`) | `testing_questions` (`question_text`, `question_type` `'single'\|'multiple'`, `sort_order`) | `has_multiple_answers: true` ↔ `question_type='multiple'` | +| `answer_options` (`text`, `is_correct`, `option_order`) | `testing_answers` (`answer_text`, `is_correct`, `sort_order`) | прямой маппинг | +| `users` + `users.staff_id` | `staff_members.id` напрямую | Express держит «свою» строку `users` со ссылкой на `staff_id`; в HR — только `staff_members` | +| `test_assignments` + `test_assignment_targets` (target_type='user', target_id=UUID) | `testing_assignments` (одна строка = одна пара тест×сотрудник, `assigned_to=staff_id`) | **Существенное расхождение модели** — см. §10.3 | +| `test_attempts` (привязан к `test_version_id`, `user_id`) | `testing_attempts` (привязан к `assignment_id` + `test_version_id`, попытка от **сотрудника**) | Если в clinic попытка не имеет связанного assignment — ETL создаёт синтетическое назначение (`max_attempts=99`) | +| `user_answers` (`selected_options uuid[]`) | `testing_attempt_answers` (одна строка на каждый выбранный `answer_id`) | Развёртка массива в N строк | +| (нет аналога) | `testing_settings` (key-value), `testing_head_positions` (право назначения по должности) | в Express не было — RBAC проще | + +### 10.2 Эндпоинты Express → роуты `tgFlaskForm` + +| # | Express | Соответствие в `tgFlaskForm` | Статус | Что сделать | +|---|---------|------------------------------|--------|-------------| +| 1 | `POST /api/auth/login` | `webApp/auth/*` (общая сессия HR) | ✅ есть аналог | Express-сессии не переносим; пользователи входят как обычно в HR-кабинет | +| 2 | `POST /api/auth/logout` | `webApp/auth/*` | ✅ — | — | +| 3 | `GET /api/auth/me` | session + `helpers._get_staff_id` | ✅ — | — | +| 4 | `GET /api/auth/dev/assignment-directory` | `routes_assignments.assign_form` (страница) + `testing_get_employees_for_assignment` | ✅ — | На UI: добавить «Выбрать всех» (см. Спринт 3 React-кабинета), мульти-фильтры | +| 5 | `GET /api/health` | `webApp/__init__.py` | ✅ — | — | +| 6 | `GET /api/tests` (каталог + `hiddenByYou`) | `routes_tests.test_list` (по автору) + `routes_passing.my_tests` (по назначениям) | ⚠️ нет «скрытые мной» отдельным разделом | Добавить вкладку/фильтр «скрытые автором» в `cabinet/testing/test_list.html` | +| 7 | `POST /api/tests` (создать пустой) | `routes_tests.test_create` (требует full payload + ≥7 вопросов) | ⚠️ другой контракт | Решить продуктово: оставить ограничение `≥7` либо разрешить «пустой тест» как в Express. **Рекомендую** разрешить пустой и валидировать только при публикации | +| 8 | `GET /api/tests/:id/summary` | вшито в `test_edit` | ⚠️ нет отдельной summary | Добавить функцию `testing_get_test_summary(test_id)` если нужен лёгкий вариант для списка | +| 9 | `GET /api/tests/:id/versions` | `testing_get_test_for_edit` отдаёт активную; история — внутри `test_edit` | ⚠️ — | Вынести список версий в API/шаблон («История» в UX) | +| 10 | `GET /api/tests/:id/editor` | `routes_tests.test_edit` + `testing_get_test_for_edit` | ✅ — | сверить набор полей, отдаваемых шаблону, с тем что показывает React-редактор | +| 11 | `POST /api/tests/:id/ai/generate-test` (строгая `shape: [{optionsCount, hasMultipleAnswers}]`, до 40 вопросов) | `routes_ai.ai_generate` принимает `topic, question_count` (фикс. форма) | ❌ **gap** | Добавить вариант с явной формой (передавать массив `optionsCount/hasMultipleAnswers`); либо принять, что HR-кабинет генерирует «обобщённо» | +| 12 | `POST /api/tests/:id/ai/generate-question` (один вопрос: пусто=новый, текст=переформулировать) | `routes_ai.ai_improve` (улучшить) + `ai_distractors` (варианты) | ❌ **gap** | Добавить ручку «один вопрос со строгой сеткой» (объединить `improve` + `distractors` под единый сценарий) | +| 13 | `POST /api/tests/:id/versions/:vid/activate` | `routes_tests.test_version_activate` | ✅ — | — | +| 14 | `PATCH /api/tests/:id` (`chainActive`) | `routes_tests.test_toggle` | ✅ — | сверить семантику (toggle vs явный флаг) | +| 15 | `POST /api/tests/:id/assign` (массивы `userIds`/`staffIds`) | `routes_assignments.assign_submit` (`employee_ids = staff_id[]`) | ✅ совместимо | — | +| 16 | `POST /api/tests/:id/draft` (с авто-fork версии) | `routes_tests.test_update` (тоже фаркает при `has_attempts`) | ⚠️ контракт другой | Сверить, что в HR-кабинете тоже работает «правка без попыток = на месте, после попыток = новая версия». В коде есть, но нужна проверка | +| 17 | `POST /api/tests/:id/attempts/start` | `routes_passing.start_attempt(assignment_id)` | ⚠️ другой ключ | Привести к виду «start by test_id» **или** оставить как есть и в UI стартовать по `assignment_id` (логичнее) | +| 18 | `GET /api/tests/:id/attempts` | `routes_results.tracker` (общая лента) — без фильтра по тесту | ⚠️ нет per-test ленты | Добавить фильтр `test_id` или отдельный URL `/cabinet/testing/tests/:id/attempts` | +| 19 | `GET /api/tests/:id/attempts/:aid/review` | `routes_results.result(attempt_id)` | ✅ — | сверить, что разбор показывает выбранные/верные варианты по каждому вопросу | +| 20 | `GET /api/tests/:id/attempts/:aid/play` | `routes_passing.take_test(attempt_id)` | ✅ — | — | +| 21 | `POST /api/tests/:id/attempts/:aid/submit` | `routes_passing.finish_attempt(attempt_id)` (+ `save_answer`) | ⚠️ контракт другой | Express отправляет все ответы пакетом; HR — пошагово (`save_answer` × N + `finish`). Это **OK для UX** (продолжить позже), но фронт работает иначе | +| 22 | `POST /api/tests/import/document` | `routes_import.import_document` | ✅ — | сверить mime-типы и сообщения об ошибках | +| 23 | `GET /api/tests/:id/chain-info` (`hasAnyAttempt`) | вшито в `testing_get_test_for_edit['has_attempts']` | ✅ — | — | + +> **Итог по gap-analysis:** 13 из 22 эндпоинтов закрыты «как есть» или близко; 4 требуют добавления (AI «один вопрос», AI «строгая сетка», per-test attempts, summary); 5 — расхождение контракта, надо принять решение или подровнять. + +### 10.3 Расхождения, которые ETL уже обходит + +- **Назначения на отдел:** в `clinic_tests.test_assignment_targets` могут быть строки `target_type='department'`. ETL разворачивает их в N строк `testing_assignments` по списку сотрудников отдела на момент миграции (см. шапку `tools/migrate_clinic_tests_to_hr.py`). +- **Попытки без явного назначения:** в Express `test_attempts.user_id` напрямую идёт от пользователя; в HR попытка обязательно имеет `assignment_id`. ETL для каждой пары (тест, сотрудник) создаёт **синтетическое** назначение `max_attempts=99` и привязывает попытку к нему. +- **UUID → Integer:** идемпотентный маппинг лежит в `public._clinic_tests_migration_map` (entity, old_uuid → new_id) — повторный прогон ETL безопасен. + +### 10.4 Чего точно нет в `tgFlaskForm` и потребует доработки + +1. **AI «один вопрос со строгой сеткой»** (`generate_or_rephrase_question` из `aiEditorService.js`). +2. **AI «целый тест по shape»** с проверкой `optionsCount` и `hasMultipleAnswers` для каждой строки. +3. **«Скрытые автором» цепочки** как отдельный список на UI. +4. **`/tests/:id/summary`** — лёгкая ручка для карточки в списке (если потребуется в новом UI). +5. **Per-test лента попыток** (UI «Прохождения» в аккордеоне «История»). +6. **Мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md):** аккордеоны «О тесте / Вопросы / История / Показ в каталоге», фикс-футер, икон-кнопка удаления варианта, «Выбрать всех» в назначении и т.д. В Этапе 1 это переносится в Jinja-шаблоны `flask_app/app/templates/`. В Этапе 2 уже готовые шаблоны переезжают в `tgFlaskForm/webApp/templates/cabinet/testing/`. + +### 10.5 Что точно НЕ переносим + +- **Свой JWT/cookie/bcrypt** из `backend/src/utils/auth.js` и `middleware/auth.js`. Авторизация — через сессии общего кабинета. +- **Werkzeug-полифилл в Node** (`utils/werkzeugPassword.js`). В Python это родная функция `werkzeug.security.check_password_hash`. +- **Свои миграции SQL** из `backend/src/db/migrations/`. Целевая схема живёт в `tgFlaskForm/db/models.py` и сопутствующих DDL/Alembic. +- **`flask_app/`** в этом репозитории — каркас уже **не нужен**, в Спринте 4 решим: удалить или оставить как историческую заготовку. +- **`backend/src/**/*.test.js`** (Node test runner). Их предметная нагрузка покрывается **либо** уже существующими тестами `tgFlaskForm`, **либо** новыми pytest-юнитами. diff --git a/docs/migration-final.md b/docs/migration-final.md new file mode 100644 index 0000000..e460560 --- /dev/null +++ b/docs/migration-final.md @@ -0,0 +1,100 @@ +# Унификация стека TestingWebApp с `tgFlaskForm` — план и журнал + +> **Корректировка курса от 2026-04-27.** +> Ранее в этом документе фигурировал «полный переезд в HR-кабинет (`tgFlaskForm`) с cutover'ом и удалением React/Express». Это было забеганием вперёд. Текущая фаза — **только унификация стека**, без слияния репозиториев и без миграции данных. + +--- + +## Назначение документа + +Зафиксировать разделение работы на **два этапа** и текущий статус каждого. Этот файл — **трекер решения и журнал**, основной план Этапа 1 — здесь же; план Этапа 2 (на будущее) — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md). + +**Связано:** [migration-final-inventory.md](migration-final-inventory.md) (карта 22 эндпоинтов Express, БД, env, зависимости, плюс справочный gap-analysis с уже существующим модулем в `tgFlaskForm` — пригодится в Этапе 2), [PROJECT_STATUS.md](PROJECT_STATUS.md), [README](../README.md), [`flask_app/README.md`](../flask_app/README.md), [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). + +--- + +## Этап 1 (текущий) — единый стек: Express → Flask, React → Jinja, **внутри TestingWebApp** + +### Цель + +Привести TestingWebApp к **тому же стеку**, что у `HR_TG_Bot/tgFlaskForm`: + +- **Бэкенд:** Python 3 + Flask, точечный SQL/SQLAlchemy в стиле `tgFlaskForm`. Развивается в каталоге [`flask_app/`](../flask_app/) этого репозитория (сейчас — минимальный каркас). +- **Фронтенд:** Jinja-шаблоны в `flask_app/app/templates/`, мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) переносится один в один. React (`frontend/`) уходит **после** того, как Jinja-версия закроет все экраны. +- **БД:** **остаётся `clinic_tests`** (со своими UUID-ключами). Никаких изменений схемы. +- **Авторизация:** JWT/bcrypt + опциональный `HR_AUTH=1` (как в Express) — переносим как есть. + +### Что **не** делаем в Этапе 1 + +- **Не** трогаем `HR_TG_Bot/tgFlaskForm/`. Его модуль `cabinet/testing` живёт своей жизнью. +- **Не** мигрируем данные `clinic_tests → hr_bot_test`. ETL-скрипт `migrate_clinic_tests_to_hr.py` есть, но он — для Этапа 2. +- **Не** удаляем `backend/` и `frontend/` сразу. Они работают параллельно с `flask_app/` до полного паритета. Удаление — последним PR этапа. + +### Стартовая точка `flask_app/` + +| Что есть | Файл / артефакт | +|---|---| +| Flask-приложение (`create_app`) | `flask_app/app/__init__.py` | +| Точка входа (dev / waitress) | `flask_app/run.py` | +| `/health` | `flask_app/app/__init__.py` | +| Пустой `index.html` | `flask_app/app/templates/index.html` | +| Зависимости | `flask_app/requirements.txt` (Flask, python-dotenv, waitress) | +| Docker-сервис на порту 3108 | `flask_app/Dockerfile`, корневой `docker-compose.dev.yml` (сервис `testing-flask`) | + +Всё остальное — **писать**. + +### План Этапа 1 (по спринтам) + +| Спринт | Цель | Артефакты | +|---|---|---| +| **E1.0 — База Flask-приложения** ✅ | БД-пул (SQLAlchemy + psycopg2), Flask sessions через `SECRET_KEY`, конфиг через `.env`, структура blueprint'ов, шаблон `base.html` в стиле кабинета, обработчики 404/500, `/health` с проверкой БД. **Без бизнес-логики.** | `flask_app/app/db.py`, `flask_app/app/__init__.py`, `flask_app/app/blueprints/main.py`, `flask_app/app/templates/{base,index,404,500}.html`, `flask_app/app/static/css/app.css`, обновлённые `requirements.txt` и `.env.example` | +| **E1.1 — Auth и `/api/me`** ✅ | Flask sessions (signed cookie), bcrypt + Werkzeug (`werkzeug.security.check_password_hash`), опц. `HR_AUTH=1` с UPSERT в `clinic_tests.users` по `staff_id`. UI-страница `/login`, JSON-API `/api/auth/{login,logout,me}`, декораторы `login_required`/`require_role`, `current_user` доступен в шаблонах. | `flask_app/app/auth/{routes,services,decorators,hr_role}.py`, `flask_app/app/{config,messages}.py`, `flask_app/app/templates/auth/login.html`, обновлены `base.html`, `__init__.py`, `requirements.txt` (+`bcrypt`) | +| **E1.2 — Тесты: список и редактор** ✅ | Перенесены 10 эндпоинтов из Express: `GET/POST /api/tests`, `GET /api/tests/:id/{summary,versions,editor}`, `POST /api/tests/:id/draft`, `POST /api/tests/:id/versions/:vid/activate`, `PATCH /api/tests/:id`, `POST /api/tests/:id/ai/{generate-test,generate-question}`. UI: `/tests` (каталог + создание), `/tests/:id/edit` (рабочий редактор с AI). Полная мобильная отполировка UX (4 аккордеона + fixed footer + drag-n-drop) — в **E1.7**. | `flask_app/app/services/{llm_client,draft_validator,ai_editor,test_access,test_chain,test_draft,editor_content}.py`, `flask_app/app/tests/{__init__,routes}.py`, `flask_app/app/templates/tests/{list,editor}.html`, `flask_app/app/static/js/editor.js`, обновлены `base.html`, `index.html`, `__init__.py` | +| **E1.3 — Импорт документов** ✅ | `POST /api/tests/import/document` (PDF/DOCX/TXT/MD извлечение текста через `pypdf` и `python-docx`), интеграция с AI-генерацией черновика (`generation_for_import_document`), кнопка «Импорт документа» в AI-панели редактора, лимит 16 МБ. | `flask_app/app/services/{document_extract,document_gen}.py`, эндпоинт в `flask_app/app/tests/routes.py`, кнопка в `editor.html` + `editor.js`, `requirements.txt` (+`pypdf`, `python-docx`) | +| **E1.4 — Назначение и прохождение** | Эндпоинты `assign`, `attempts/start`, `attempts/:id/play`, `attempts/:id/submit`, `attempts/:id/review`. Шаблоны: `assign.html`, `take_test.html`, `test_result.html`. | `flask_app/app/assignments/`, `flask_app/app/attempts/`, шаблоны | +| **E1.5 — Трекер и настройки** | Трекер прохождений, настройки модуля (ключи AI и т.д.), цепочки тестов. Шаблоны `tracker.html`, `settings.html`. | `flask_app/app/tracker/`, `flask_app/app/settings/` | +| **E1.6 — Cutover внутри репозитория** | `docker-compose.dev.yml` указывает на `flask_app/` как основной сервис; Nginx маршрутизирует `/api` и UI на новый Flask. Удаление `backend/` и `frontend/` отдельным PR. README → актуальные команды. | `docker-compose.dev.yml`, корневой `README.md`, `frontend/` и `backend/` удаляются | +| **E1.7 — UX-полировка редактора** | Перевод базового редактора (E1.2) на мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md): 4 аккордеона (Шапка / AI-помощник / Вопросы / Действия), sticky footer, drag-n-drop вопросов, импорт документа в подразделе AI-блока (после E1.3). | `flask_app/app/templates/tests/editor.html`, `flask_app/app/static/js/editor.js`, новый `static/css/testing.css` | +| **E1.8 — AI-функции v2** ✅ | `/settings` (статус ключа из ENV + ping), `POST /api/llm/ping`, на тесте — `ai/generate-by-title` (без сетки), `ai/check` (рецензия), `ai/improve` (массовое «было → стало» с чекбоксами). На уровне вопроса — уже есть `ai/generate-question` из E1.2 (создаёт вопрос или переформулирует). Все AI-эндпоинты унифицированы: при отсутствии ключа — `{ error, code, settingsUrl: '/settings' }`. | `flask_app/app/services/{ai_editor,llm_client}.py`, `flask_app/app/blueprints/settings.py`, `flask_app/app/templates/settings.html`, ссылка «Настройки» в `base.html`, обновлены `tests/routes.py`, `editor.html`, `editor.js` | + +### Критерии готовности Этапа 1 + +- Все 22 эндпоинта Express (см. [migration-final-inventory.md](migration-final-inventory.md)) реализованы в `flask_app/` и проходят smoke-тесты. +- Все экраны мобильного UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) воспроизведены в Jinja. +- В `docker-compose.dev.yml` остался **один** сервис приложения (Flask). `backend/` и `frontend/` удалены или перенесены в ветку `legacy/clinic-tests-node`. +- БД — по-прежнему `clinic_tests`, схема не менялась. + +--- + +## Этап 2 (на будущее, без сроков) — слияние с `tgFlaskForm` + +Когда заказчик решит «вот теперь объединяем» — **вся** разработанная Flask-логика и шаблоны легко переносятся в `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/` и `templates/cabinet/testing/`, потому что **стек уже совпадает**. Это и есть смысл Этапа 1. + +Что нужно сделать в Этапе 2 (план — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md)): + +1. Перенести код Flask-приложения как blueprint в `tgFlaskForm`. +2. Адаптировать модели под существующие `Testing*` таблицы (`hr_bot_test.testing_*`). +3. Перевести авторизацию на сессии общего HR-кабинета. +4. Прогнать ETL `clinic_tests → hr_bot_test` (скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py` уже готов: 437 строк, режимы `--dry-run`/`--apply`, идемпотентность через `_clinic_tests_migration_map`). +5. Cutover (если к тому моменту появятся реальные пользователи; сейчас TestingWebApp — песочница для тестировщиков). + +**Решения, которые относятся к Этапу 2** (зафиксированы заранее, чтобы потом не переоткрывать): + +- **`test_assignments`:** переносим 1:1, дописывая отдельный блок в ETL (сейчас скрипт переносит только пары через попытки). +- **Пользователи без `staff_id`:** игнорируем с WARN; по договорённости настоящие пользователи всегда привязаны к `staff_members`. +- **Cutover / окно простоя:** не нужны, пока TestingWebApp остаётся песочницей. + +--- + +## Журнал + +| Дата | Что сделано | +|------|-------------| +| 2026-04-27 | Спринт 0 («инвентаризация» в старой нумерации) закрыт: артефакт [migration-final-inventory.md](migration-final-inventory.md) — карта 22 эндпоинтов Express, БД, env, зависимости. | +| 2026-04-27 | Принято решение: **сценарий B + b1** (полный переезд в HR-кабинет). | +| 2026-04-27 | **Курс скорректирован:** Этап 1 = унификация стека внутри TestingWebApp (Express → Flask + React → Jinja, БД остаётся `clinic_tests`). Этап 2 = слияние с `tgFlaskForm` — на будущее. ETL и удаление React переходят в Этап 2. Документы переписаны под двух-этапную картину. Эксперимент с правкой `tgFlaskForm/cabinet/testing/test_editor.html` (ветка `feat/testing-editor-jinja-redesign`) откачен и не оставил следов в HR-репо. | +| 2026-04-27 | **E1.8 закрыт.** AI v2: страница `/settings` (статус ключа из ENV, `Проверить подключение` → `POST /api/llm/ping`). Три новых эндпоинта на тесте: `POST /api/tests//ai/generate-by-title` (генерация только по названию + опции «сколько вопросов / сколько вариантов»), `POST /api/tests//ai/check` (рецензия: вердикт + разделы рекомендаций), `POST /api/tests//ai/improve` (массовое «было → стало» с проверкой неизменности сетки). UI редактора: кнопки «Сгенерировать по названию», «Проверить тест», «Улучшить тест»; общий `` для модалок check/improve; чекбоксы в improve позволяют применять изменения по выбранным вопросам. Все AI-эндпоинты унифицированы: при отсутствии ключа возвращают `{ error, code, settingsUrl: '/settings' }` 502 — фронт предлагает открыть Настройки. | +| 2026-04-27 | **E1.3 закрыт.** Импорт документов: `app/services/document_extract.py` (PDF через `pypdf`, DOCX через `python-docx`, TXT/MD), `app/services/document_gen.py` (`generation_for_import_document` — извлекает текст, при наличии LLM-ключа просит модель собрать draft через `validate_and_normalize_draft`), эндпоинт `POST /api/tests/import/document` под `@login_required` с лимитом 16 МБ. UI редактора: кнопка «Импорт документа» в AI-панели, после загрузки — confirm с предложением применить черновик; если ключа нет — алерт с превью текста. В `requirements.txt` добавлены `pypdf>=4` и `python-docx>=1.1`. | +| 2026-04-27 | **E1.2 закрыт.** Перенесены `backend/src/routes/tests.js` (только E1.2-эндпоинты — без `import/document`/`assign`/`attempts`/`chain-info`, они уйдут в E1.3-E1.5) + сервисы `testDraftService.js`, `testAccessService.js`, `testChainService.js`, `aiEditorService.js`, `documentGenService.js` (только парсер JSON и валидатор draft), `llmClient.js`, `getEditorContent` из `testAttemptService.js`. Эндпоинты регистрируются в blueprint `tests`. AI-генерация: `parseAndValidateShape` 1:1, ошибки LLM (`llm_*`-коды) пробрасываются как 502 с кодом в JSON. UI: каталог тестов с кнопкой создания (модалка ``) и рабочий редактор (inline-поля, AI-кнопки «весь тест» / «один вопрос», добавление/удаление/перемещение вопросов и вариантов, сохранение черновика, переключатель «Цепочка активна»). Полный мобильный UX редактора (аккордеоны+fixed footer+drag-n-drop из Спринта 3) вынесен в новый спринт **E1.7** — этот PR закрывает функциональность, не дизайн. | +| 2026-04-27 | **E1.1 закрыт.** Перенесены `backend/src/routes/auth.js` + `middleware/auth.js` + `utils/{auth,werkzeugPassword,hrRoleMap}.js` в `flask_app/app/auth/`. Решение: Flask sessions (signed cookie) вместо JWT, как договорились (вариант A). Поддерживаются bcrypt-хеши (`$2*`) и Werkzeug-хеши (`scrypt:`/`pbkdf2:`). Эндпоинты — те же пути, что в Express: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me` (отдаёт `user`, `devUi`, `assignmentUi`). Дополнительно — HTML-страница `/login` (форма) и `POST /logout`. Декораторы: `@login_required`, `@require_role(...)`. В шаблонах доступны `current_user`, `hr_auth_enabled`, `dev_ui`, `assignment_ui`. Защита от open-redirect в параметре `?next=`. Главная (`/`) теперь требует логин. | +| 2026-04-27 | **E1.0 закрыт.** В `flask_app/`: SQLAlchemy/psycopg2-пул в стиле `tgFlaskForm/db/session.py` (`app/db.py`, основная БД `clinic_tests` + опциональная HR-БД при `HR_AUTH=1`), фабрика `create_app` с регистрацией blueprint'ов, обработчиками 404/500 и Flask sessions, главный blueprint `main` с `/` и `/health` (smoke-проверка БД), `base.html` в стиле кабинета HR (Tailwind CDN + Manrope + Material Symbols, без зависимостей от HR-репо), шаблоны `index/404/500`, минимальный `static/css/app.css`. Бизнес-логика **не** добавлялась. | diff --git a/docs/migration-to-tgflaskform.md b/docs/migration-to-tgflaskform.md index ff5949a..db01230 100644 --- a/docs/migration-to-tgflaskform.md +++ b/docs/migration-to-tgflaskform.md @@ -1,175 +1,102 @@ -# Перенос TestingWebApp на стек HR_TG_Bot / tgFlaskForm +# Этап 2 (на будущее) — слияние TestingWebApp с `HR_TG_Bot/tgFlaskForm` -**Тот же план простым языком (две базы, люди, этапы):** [migration-to-tgflaskform-plain.md](migration-to-tgflaskform-plain.md). +> **Не текущая фаза.** Текущая работа — **Этап 1**: унификация стека внутри TestingWebApp (Express → Flask + React → Jinja). См. [migration-final.md](migration-final.md). Этот документ — план **последующего** слияния, когда заказчик решит объединять. -**Назначение документа:** зафиксировать целевую архитектуру, **спринтовый план** доведения функциональности до паритета и **порядок миграции данных** из отдельного приложения (`Express` + `React` + БД `clinic_tests`) в кабинет **`tgFlaskForm`** (Flask, шаблоны, общая БД `hr_bot_test`, таблицы `testing_*`). +**Простое объяснение тех же шагов:** [migration-to-tgflaskform-plain.md](migration-to-tgflaskform-plain.md). -**Связанные материалы:** [PROJECT_STATUS.md](PROJECT_STATUS.md), [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) (актуальный UI React-кабинета), [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). +**Связано:** [migration-final.md](migration-final.md) (главный трекер двух этапов), [migration-final-inventory.md](migration-final-inventory.md) (карта Express + gap-analysis с уже существующим модулем `tgFlaskForm`), [PROJECT_STATUS.md](PROJECT_STATUS.md), [README](../README.md), код HR-кабинета: `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`, модели: `HR_TG_Bot/tgFlaskForm/db/models.py`. --- -## 0. Стратегия переходного периода (отдельное приложение, тот же стек) +## 0. Предусловие — Этап 1 закрыт -**Решение:** переписывание с Node/React на **тот же стек, что у мини-приложения и кабинета HR** — Python 3, **Flask**, шаблоны (Jinja2), статический JS, работа с PostgreSQL в духе `tgFlaskForm`. При этом сервис **пока живёт отдельно**: свой процесс, свой URL/порт, **не** обязан совпадать с деплоем полного `HR_TG_Bot/tgFlaskForm`. +К моменту, когда этот документ берётся в работу, в TestingWebApp **уже** должно быть: -**Зачем так:** быстрее выйти на паритет по UX и данным, **без** риска «большого взрыва» в едином кабинете; позже либо встраиваете модуль в кабинет (общий `webApp`), либо оставляете отдельный вход — стек уже совпадает. +- Бэкенд переписан с Express на Flask внутри [`flask_app/`](../flask_app/), все 22 эндпоинта работают. +- Фронтенд переписан с React на Jinja-шаблоны в `flask_app/app/templates/`. +- БД — по-прежнему `clinic_tests`, схема не менялась. +- В репозитории остался один сервис приложения. -**Обязательно зафиксировать продуктово:** - -| Вопрос | Рекомендация | -|--------|----------------| -| Где **пишут** тесты и попытки, пока два контура? | Один «канонический» контур на запись; второй 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`). +Если что-то из этого ещё не готово — Этап 2 не начинается. --- -## 1. Зачем переносить +## 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). +| Аспект | После Этапа 1 (отдельный сервис) | После Этапа 2 (часть HR-кабинета) | +|---|---|---| +| Репозиторий | `TestingWebApp/flask_app/` | `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/` | +| Деплой | Свой Docker-сервис, свой URL/порт | Часть основного `tgFlaskForm`, общий URL `/cabinet/testing/...` | +| БД | `clinic_tests`, UUID | `hr_bot_test`, integer ID, схема `testing_*` | +| Авторизация | JWT/bcrypt + опциональный `HR_AUTH` | Сессии общего HR-кабинета, привязка к `staff_members` | +| Модели | Свои (как в Express, но на Python) | Существующие `TestingTest`, `TestingTestVersion`, `TestingQuestion`, `TestingAnswer`, `TestingAssignment`, `TestingAttempt`, `TestingAttemptAnswer`, `TestingSetting`, `TestingHeadPosition` в `db/models.py` | +| UI | Jinja-шаблоны в `flask_app/app/templates/` | Jinja-шаблоны в `tgFlaskForm/webApp/templates/cabinet/testing/` | --- -## 2. Исходный и целевой стек (кратко) - -**Исходный (TestingWebApp):** +## 2. План Этапа 2 (по спринтам) -- 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 вариантов). +### E2.0 — Сверка кода и моделей -**Целевой (`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). - ---- +- Сравнить структуру `flask_app/` (после Этапа 1) с уже существующим модулем `tgFlaskForm/webApp/interfaces/testing/`. Где функции называются иначе — выбрать одно имя. +- Сверить модели: `clinic_tests` UUID-схема vs `hr_bot_test` `testing_*` integer-схема. Зафиксировать поле-в-поле соответствия (большая часть уже сделана в [migration-final-inventory.md §10](migration-final-inventory.md#10-gap-analysis-tgflaskformcabinettesting-vs-express)). +- **Критерий выхода:** документ соответствий + решение по спорным точкам (например, кто прав — `is_active` на цепочке или на версии). -## 3. Спринтовый план (переписывание = паритет + миграция + снятие стенда) +### E2.1 — Перенос кода как blueprint -Длительность спринта ориентировочно **2 календарные недели**; границы можно сжимать/растягивать под состав команды. Нумерация условная: **Спринт 0** — подготовка, далее функциональные слои. +- Скопировать роуты, сервисы, шаблоны из `flask_app/` в `tgFlaskForm`. **Адаптировать**: + - Импорты — на `db/models.py` и `db/queries/testing_queries.py` HR-кабинета. + - Авторизация — сменить с JWT/bcrypt на сессии и `werkzeug.security.check_password_hash`. + - URL-prefix с корневого на `/cabinet/testing/`. + - Шаблоны — наследование от `cabinet/base.html` (хедер, нижний нав-бар). +- **Критерий выхода:** все экраны открываются через HR-кабинет, локальный smoke-тест зелёный. -### Спринт 0 — Инвентаризация и критерии готовности +### E2.2 — Миграция данных (ETL) -**Цель:** зафиксировать разрыв «TestingWebApp ↔ tgFlaskForm» и правила миграции. +Скрипт уже готов: [`HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (437 строк, режимы `--dry-run`/`--apply`, идемпотентность через `public._clinic_tests_migration_map`). -- Составить **матрицу сценариев** по [ТЗ.md](ТЗ.md) и [PROJECT_STATUS.md](PROJECT_STATUS.md): редактор теста, версии, назначения, прохождение, разбор, трекер, настройки модуля, AI. -- Зафиксировать отличия схемы: UUID vs integer, модель назначений (цель: каждая строка `TestingAssignment` = один `staff_id`). -- Решение по **импорту из PDF/DOCX** (в Node-версии есть извлечение текста для черновика): либо перенос в Python (`tgFlaskForm`), либо явный scope «после миграции». -- **Критерий выхода:** подписанный чек-лист паритета + утверждённый порядок миграции (раздел 4 этого документа). +Перед прогоном **на актуальных данных** дописать: -### Спринт 1 — Данные и идентификаторы +- **Перенос `test_assignments` 1:1** — сейчас скрипт переносит только пары «тест-сотрудник» через попытки; нужны и «висящие» назначения без попыток. (Решение Этапа 2.) +- **Логирование пользователей без `staff_id`:** автор → WARN, попытка → WARN; никаких хардовых ошибок. (Решение Этапа 2.) -**Цель:** подготовить перенос без потери смысла связей. +**Порядок:** -- Убедиться, что у всех значимых пользователей `clinic_tests.users` есть сопоставление с **`staff_members.id`** (колонка `staff_id` и/или правила сопоставления по логину из HR). -- Спроектировать **таблицы соответствия** для одноразового 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-проверки количества строк (тесты, версии, вопросы, попытки). +1. Бэкап `clinic_tests` и `hr_bot_test`. +2. `--dry-run` на копии прод-БД, разбор лога. +3. `--apply` на той же копии, ручная сверка через UI HR-кабинета. +4. После приёмки — `--dry-run` + `--apply` на боевой БД. -### Спринт 2 — Паритет бизнес-логики в Flask +### E2.3 — Cutover -**Цель:** закрыть расхождения поведения, а не только UI. +Если к этому моменту у TestingWebApp всё ещё «песочница для тестировщиков» (как сейчас) — простое переключение, без окна простоя и баннеров. Если появятся реальные пользователи — добавить пункт E2.3.1: коммуникация и redirect. -- Версионирование: правила «первая правка без попыток / новая версия после попыток», активная версия — согласовать с уже реализованным в `testing_queries.py` и довести до полного соответствия ТЗ при необходимости. -- Назначения: если в `clinic_tests` остались назначения **на отдел**, описать стратегию **разворачивания** в N строк `TestingAssignment` (по списку `staff_id` отдела на дату миграции) или доработать модель в HR (отдельное решение продукт-оунера). -- Прохождение: таймер, лимит попыток, дедлайн, случайный порядок вопросов (`question_seed`) — сверка с ТЗ и доработка в Python при расхождении. -- **Критерий выхода:** автоматические тесты на критичные запросы (где их ещё нет) + ручной прогон чек-листа из спринта 0. +- Заморозка записи в `flask_app/` старой инсталляции (read-only). +- Прогон ETL на боевом. +- Маршрутизация: внешние ссылки `clinic-tests.example.com/*` → `hr-cabinet.example.com/cabinet/testing/*`. +- В корневом репозитории TestingWebApp — ветка `legacy/clinic-tests-flask`, в README — ссылка на этот документ и дату EOL. -### Спринт 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. +**Критерий выхода:** мониторинг ошибок (например Sentry, уже подключён в `webApp/__init__.py`), отсутствие P1 в первую неделю. --- -## 4. Как происходит миграция данных (пошагово) - -### 4.1 Предпосылки +## 3. Что трогаем в HR-кабинете до Этапа 2 -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» повторных действий). +**Ничего.** Существующий модуль `tgFlaskForm/webApp/interfaces/testing/` развивается своим темпом командой HR-кабинета и **не** должен подстраиваться под TestingWebApp до момента слияния. Если в нём появляются полезные правки — переносим в `flask_app/` обратным потоком. --- -## 5. Риски и как их снимать +## 4. Риски Этапа 2 и как их снимать | Риск | Мера | |------|------| -| Неполное сопоставление `users` ↔ `staff_members` | Закрыть в спринте 1; не начинать ETL без процента покрытия, согласованного с заказчиком | -| Разная семантика назначений (отдел, версия) | Явные правила в спринте 0 + лог развёртки отделов | -| Потеря истории попыток из-за смены модели assignment | Моделирование на копии БД в спринте 1–2 | -| Дублирование разработки UI | Опираться на уже существующий модуль в `tgFlaskForm`, не переписывать с нуля параллельный SPA | +| Несовпадение `users.staff_id` ↔ `staff_members.id` | Проверка перед `--apply`; пользователей без `staff_id` пропускаем по решению. | +| Расхождение моделей (UUID vs integer, поля «на цепочке» vs «на версии») | Закрыть в E2.0; подкрепить unit-тестами на конвертацию. | +| Назначения «отдел → N сотрудников» | Логировать развёртку с пометкой `created_from_department=...`. | +| Двойное развитие модуля HR-кабинета | До Этапа 2 — не править `tgFlaskForm/cabinet/testing` под нужды TestingWebApp. | --- -## 6. Итог - -Переписывание в данном контексте — это не «ещё один greenfield на Flask», а **консолидация** уже начатого модуля в `tgFlaskForm` с **одноразовой миграцией** из `clinic_tests` и выводом из эксплуатации связки React + Express. Спринты 0–4 дают сквозной маршрут от анализа до cutover; детали ETL должны быть закреплены в коде скрипта и журнале прогона к концу **спринта 1**. +## 5. Производительность кабинета (общее) -**См. также:** если пользователи жалуются на медленную загрузку страниц кабинета/Flask — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md). +Если после переноса страницы кабинета или мини-приложения работают медленно — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md). Это вспомогательный документ, не часть этапов миграции. diff --git a/flask_app/.env.example b/flask_app/.env.example index 41a1761..76009d5 100644 --- a/flask_app/.env.example +++ b/flask_app/.env.example @@ -1,7 +1,25 @@ -# Порт HTTP (не пересекать с :3107 текущего compose) +# ─── HTTP сервер ───────────────────────────────────────────────── +# Порт (не пересекать с :3107 текущего docker-compose.dev.yml) 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 + +# Секрет для подписи cookies / Flask sessions. +# В dev можно оставить заглушку — будет сгенерирован случайный, но при рестарте +# процесса сессии "обнулятся" (это критично для логина/CSRF). +SECRET_KEY=change-me-in-dev-only + +# ─── База данных (clinic_tests, та же, что у Express-бэкенда) ──── +# Этап 1: продолжаем работать с clinic_tests, схему не меняем. +# Локально: +# DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests +# В Docker рядом с HR (общая сеть Postgres_TG_Bots): +# DATABASE_URL=postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests +DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests + +# ─── Опциональная HR-аутентификация (как в Express-бэкенде) ────── +# Если HR_AUTH=1, используем БД hr_bot_test для проверки логина/пароля +# (Werkzeug-хеш в public.users.password по web_login = username). +# HR_AUTH=1 +# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test diff --git a/flask_app/README.md b/flask_app/README.md index 4c440e9..8508fcd 100644 --- a/flask_app/README.md +++ b/flask_app/README.md @@ -44,11 +44,18 @@ python run.py ## Дальнейшие шаги (код) -1. Подключить БД (`clinic_tests` **или** `hr_bot_test` + `testing_*` — одно из двух, см. [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md) §0). -2. Переносить маршруты и шаблоны по образцу `tgFlaskForm` (blueprint `testing`, `db/queries/testing_queries.py`, шаблоны `cabinet/testing/`). -3. ETL при переходе на HR-БД: `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`. +Этот каталог — место разработки **Этапа 1** ([migration-final.md](../docs/migration-final.md)). + +1. Подключить БД **`clinic_tests`** (схема не меняется), psycopg2-пул в стиле `tgFlaskForm/db/`. +2. Перенести 22 эндпоинта Express из `backend/` в blueprint'ы Flask, ориентируясь на чек-лист в [migration-final-inventory.md](../docs/migration-final-inventory.md). +3. Перенести экраны React (`frontend/src/pages/*`) в Jinja-шаблоны `app/templates/`, повторяя мобильный UX [Спринта 3](../docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). +4. Когда паритет закрыт — `docker-compose.dev.yml` указывает на этот сервис как основной, `backend/` и `frontend/` уходят. + +ETL `clinic_tests → hr_bot_test` и слияние с `tgFlaskForm` — это **Этап 2**, на будущее ([migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md)). ## Связанные документы -- [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md) +- [docs/migration-final.md](../docs/migration-final.md) — главный трекер двух этапов. +- [docs/migration-final-inventory.md](../docs/migration-final-inventory.md) — карта Express-функционала, чек-лист Этапа 1. +- [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md) — план Этапа 2. - [README корня репозитория](../README.md) diff --git a/flask_app/app/__init__.py b/flask_app/app/__init__.py index 6a14556..e2678b8 100644 --- a/flask_app/app/__init__.py +++ b/flask_app/app/__init__.py @@ -1,8 +1,16 @@ -# -*- coding: utf-8 -*- +"""Фабрика Flask-приложения. + +Этап 1: + E1.0 — фундамент (БД-пул, sessions, base.html). + E1.1 — авторизация (cookie-сессии Flask, bcrypt + Werkzeug, опц. HR_AUTH). +""" +from __future__ import annotations + import os import secrets +from datetime import timedelta -from flask import Flask, jsonify +from flask import Flask, jsonify, render_template, request def create_app() -> Flask: @@ -13,17 +21,55 @@ def create_app() -> Flask: static_folder='static', static_url_path='/static', ) + sk = (os.environ.get('SECRET_KEY') or '').strip() app.config['SECRET_KEY'] = sk or secrets.token_hex(32) + app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB upload limit + + app.config.update( + SESSION_COOKIE_NAME='testing_session', + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE='Lax', + SESSION_COOKIE_SECURE=(os.environ.get('FLASK_ENV') == 'production'), + PERMANENT_SESSION_LIFETIME=timedelta(days=7), + ) - @app.route('/health') - def health(): - return jsonify(status='ok', service='testing-flask-app') + from .blueprints.main import main_bp + from .blueprints.settings import settings_bp + from .auth import auth_bp + from .tests import tests_bp - @app.route('/') - def index(): - from flask import render_template + app.register_blueprint(main_bp) + app.register_blueprint(auth_bp) + app.register_blueprint(tests_bp) + app.register_blueprint(settings_bp) - return render_template('index.html') + from .config import is_assignment_feature_enabled, is_dev_ui, is_hr_auth_enabled + from .auth.decorators import current_user as _current_user + + @app.context_processor + def _inject_globals(): + return { + 'current_user': _current_user(), + 'hr_auth_enabled': is_hr_auth_enabled(), + 'dev_ui': is_dev_ui(), + 'assignment_ui': is_assignment_feature_enabled(), + } + + @app.errorhandler(404) + def _not_found(_e): + if _is_api_path(): + return jsonify(error='not_found'), 404 + return render_template('404.html'), 404 + + @app.errorhandler(500) + def _internal_error(_e): + if _is_api_path(): + return jsonify(error='internal_error'), 500 + return render_template('500.html'), 500 return app + + +def _is_api_path() -> bool: + return request.path.startswith('/api/') diff --git a/flask_app/app/auth/__init__.py b/flask_app/app/auth/__init__.py new file mode 100644 index 0000000..cde819c --- /dev/null +++ b/flask_app/app/auth/__init__.py @@ -0,0 +1,5 @@ +"""Auth: логин/логаут/me — пара UI-страниц и JSON-API. + +См. routes.py, services.py, decorators.py. +""" +from .routes import auth_bp # noqa: F401 diff --git a/flask_app/app/auth/decorators.py b/flask_app/app/auth/decorators.py new file mode 100644 index 0000000..864e5d8 --- /dev/null +++ b/flask_app/app/auth/decorators.py @@ -0,0 +1,69 @@ +"""Декораторы доступа: подгружают пользователя из сессии. + +`g.current_user` доступен в шаблонах как `current_user` (см. context_processor в `app/__init__.py`). +""" +from __future__ import annotations + +from functools import wraps +from typing import Iterable + +from flask import g, jsonify, redirect, request, session, url_for + +from ..messages import RU +from .services import AuthUser, load_user_by_id + + +def _wants_json() -> bool: + if request.path.startswith('/api/'): + return True + accept = request.headers.get('Accept', '') + return 'application/json' in accept and 'text/html' not in accept + + +def _load_current_user() -> AuthUser | None: + if hasattr(g, 'current_user'): + return g.current_user + user_id = session.get('user_id') + user = load_user_by_id(user_id) if user_id else None + g.current_user = user + return user + + +def login_required(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + user = _load_current_user() + if user is None: + if _wants_json(): + return jsonify(error=RU['authRequired']), 401 + return redirect(url_for('auth.login_page', next=request.full_path or '/')) + return fn(*args, **kwargs) + + return wrapper + + +def require_role(roles: str | Iterable[str]): + allowed = {roles} if isinstance(roles, str) else set(roles) + + def decorator(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + user = _load_current_user() + if user is None: + if _wants_json(): + return jsonify(error=RU['authRequired']), 401 + return redirect(url_for('auth.login_page', next=request.full_path or '/')) + if user.role not in allowed: + if _wants_json(): + return jsonify(error=RU['insufficientPermissions']), 403 + return ('Доступ запрещён.', 403) + return fn(*args, **kwargs) + + return wrapper + + return decorator + + +def current_user() -> AuthUser | None: + """Хелпер для шаблонов и view-функций.""" + return _load_current_user() diff --git a/flask_app/app/auth/hr_role.py b/flask_app/app/auth/hr_role.py new file mode 100644 index 0000000..ff6c1de --- /dev/null +++ b/flask_app/app/auth/hr_role.py @@ -0,0 +1,13 @@ +"""Маппинг роли HR → роль модуля тестов (порт `backend/src/utils/hrRoleMap.js`).""" +from __future__ import annotations + + +def map_hr_role_to_app(hr_role: str | None) -> str: + r = (hr_role or '').strip().lower() + if not r: + return 'employee' + if r == 'admin' or 'hr' in r or 'дире' in r: + return 'hr' + if 'manager' in r or 'рук' in r or 'завед' in r: + return 'manager' + return 'employee' diff --git a/flask_app/app/auth/routes.py b/flask_app/app/auth/routes.py new file mode 100644 index 0000000..591341e --- /dev/null +++ b/flask_app/app/auth/routes.py @@ -0,0 +1,107 @@ +"""Маршруты auth: HTML (`/login`, `/logout`) и JSON (`/api/auth/*`).""" +from __future__ import annotations + +import logging + +from flask import ( + Blueprint, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) + +from ..config import is_assignment_feature_enabled, is_dev_ui +from ..messages import RU +from .decorators import login_required, current_user +from .services import AuthError, authenticate_credentials + +log = logging.getLogger(__name__) + +auth_bp = Blueprint('auth', __name__) + + +def _safe_next(default: str = '/') -> str: + """Защита от open-redirect: разрешаем только относительные пути.""" + nxt = request.values.get('next') or default + if not nxt.startswith('/') or nxt.startswith('//'): + return default + return nxt + + +def _do_login(login: str, password: str): + user = authenticate_credentials(login, password) + session.clear() + session['user_id'] = user.id + session.permanent = True + return user + + +# ─── HTML ──────────────────────────────────────────────────────────── + +@auth_bp.route('/login', methods=['GET']) +def login_page(): + if current_user() is not None: + return redirect(_safe_next('/')) + return render_template('auth/login.html', next=_safe_next('/')) + + +@auth_bp.route('/login', methods=['POST']) +def login_submit(): + login = (request.form.get('login') or '').strip() + password = request.form.get('password') or '' + try: + _do_login(login, password) + except AuthError as e: + flash(e.message, 'error') + return render_template('auth/login.html', next=_safe_next('/'), login=login), e.status + except Exception: + log.exception('login_submit failed') + flash(RU['loginFailed'], 'error') + return render_template('auth/login.html', next=_safe_next('/'), login=login), 500 + return redirect(_safe_next('/')) + + +@auth_bp.route('/logout', methods=['POST', 'GET']) +def logout(): + session.clear() + if request.method == 'GET': + return redirect(url_for('auth.login_page')) + return redirect(url_for('auth.login_page')) + + +# ─── JSON API ──────────────────────────────────────────────────────── + +@auth_bp.route('/api/auth/login', methods=['POST']) +def api_login(): + data = request.get_json(silent=True) or {} + login = (data.get('login') or '').strip() + password = data.get('password') or '' + try: + user = _do_login(login, password) + except AuthError as e: + return jsonify(error=e.message), e.status + except Exception: + log.exception('api_login failed') + return jsonify(error=RU['loginFailed']), 500 + return jsonify(user=user.to_public_dict()) + + +@auth_bp.route('/api/auth/logout', methods=['POST']) +def api_logout(): + session.clear() + return jsonify(message=RU['loggedOut']) + + +@auth_bp.route('/api/auth/me', methods=['GET']) +@login_required +def api_me(): + user = current_user() + return jsonify( + user=user.to_public_dict() if user else None, + devUi=is_dev_ui(), + assignmentUi=is_assignment_feature_enabled(), + ) diff --git a/flask_app/app/auth/services.py b/flask_app/app/auth/services.py new file mode 100644 index 0000000..451e7bf --- /dev/null +++ b/flask_app/app/auth/services.py @@ -0,0 +1,217 @@ +"""Бизнес-логика логина — порт `backend/src/routes/auth.js` + `utils/auth.js`. + +Поддерживает оба режима: +- Локальный (по умолчанию): bcrypt в `clinic_tests.users.password_hash`. +- HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (Werkzeug scrypt/pbkdf2), + затем находим запись `staff_members` по `web_login` и UPSERT-им в + `clinic_tests.users` по `staff_id`. + +Werkzeug-хеши проверяются через `werkzeug.security.check_password_hash`, +bcrypt-хеши — через пакет `bcrypt`. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +import bcrypt +from sqlalchemy import text +from werkzeug.security import check_password_hash as _werkzeug_check + +from ..config import ( + HR_MANAGED_PASSWORD_PLACEHOLDER, + is_hr_auth_enabled, +) +from ..db import get_engine, get_hr_engine +from ..messages import RU +from .hr_role import map_hr_role_to_app + + +@dataclass +class AuthUser: + id: str # UUID в виде строки + login: str + full_name: str | None + role: str + department_id: Optional[str] + staff_id: Optional[int] + + def to_public_dict(self) -> dict: + out = { + 'id': str(self.id), + 'login': self.login, + 'fullName': self.full_name, + 'role': self.role, + 'departmentId': self.department_id, + } + out['staffId'] = self.staff_id + return out + + +class AuthError(Exception): + """Ошибка авторизации с HTTP-кодом и сообщением для пользователя.""" + + def __init__(self, status: int, message: str) -> None: + super().__init__(message) + self.status = status + self.message = message + + +def _verify_password(plain: str, hashed: str | None) -> bool: + """Универсальная проверка пароля: bcrypt + Werkzeug (scrypt/pbkdf2).""" + if not hashed: + return False + if hashed == HR_MANAGED_PASSWORD_PLACEHOLDER: + return False + if hashed.startswith('scrypt:') or hashed.startswith('pbkdf2:'): + try: + return _werkzeug_check(hashed, plain) + except Exception: + return False + if hashed.startswith('$2'): + try: + return bcrypt.checkpw(plain.encode('utf-8'), hashed.encode('utf-8')) + except Exception: + return False + try: + return _werkzeug_check(hashed, plain) + except Exception: + return False + + +def authenticate_credentials(login: str, password: str) -> AuthUser: + """Главная точка входа. Возвращает AuthUser или поднимает AuthError.""" + login = (login or '').strip() + password = password or '' + if not login or not password: + raise AuthError(400, RU['loginAndPasswordRequired']) + + if is_hr_auth_enabled(): + return _authenticate_via_hr(login, password) + return _authenticate_local(login, password) + + +# ─── локальный режим ──────────────────────────────────────────────── + +def _authenticate_local(login: str, password: str) -> AuthUser: + eng = get_engine() + with eng.connect() as conn: + row = conn.execute( + text( + 'SELECT id, login, password_hash, full_name, role, department_id, staff_id ' + 'FROM users WHERE login = :login AND is_active = true' + ), + {'login': login}, + ).mappings().first() + + if not row: + raise AuthError(401, RU['invalidCredentials']) + + if row['password_hash'] == HR_MANAGED_PASSWORD_PLACEHOLDER: + raise AuthError(401, RU['useHrLogin']) + + if not _verify_password(password, row['password_hash']): + raise AuthError(401, RU['invalidCredentials']) + + return AuthUser( + id=str(row['id']), + login=row['login'], + full_name=row['full_name'], + role=row['role'], + department_id=row['department_id'], + staff_id=row['staff_id'], + ) + + +# ─── HR_AUTH=1 ────────────────────────────────────────────────────── + +def _authenticate_via_hr(login: str, password: str) -> AuthUser: + hr_eng = get_hr_engine() + if hr_eng is None: + raise AuthError(500, RU['hrDatabaseUrlMissing']) + + with hr_eng.connect() as hr_conn: + u = hr_conn.execute( + text( + 'SELECT id, username, password_hash, role FROM users ' + 'WHERE LOWER(TRIM(username)) = LOWER(TRIM(:login))' + ), + {'login': login}, + ).mappings().first() + if not u or not u['password_hash']: + raise AuthError(401, RU['invalidCredentials']) + if not _verify_password(password, u['password_hash']): + raise AuthError(401, RU['invalidCredentials']) + + s = hr_conn.execute( + text( + 'SELECT id, fio FROM staff_members ' + "WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM(:login))" + ), + {'login': login}, + ).mappings().first() + if not s: + raise AuthError(403, RU['noStaffForLogin']) + + staff_id = int(s['id']) + fio = s['fio'] or login + app_role = map_hr_role_to_app(u['role']) + + eng = get_engine() + with eng.begin() as conn: + row = conn.execute( + text( + """ + INSERT INTO users (login, password_hash, full_name, role, + department_id, is_active, staff_id) + VALUES (:login, :ph, :fn, :role, NULL, true, :staff_id) + ON CONFLICT (staff_id) DO UPDATE SET + login = EXCLUDED.login, + full_name = EXCLUDED.full_name, + role = EXCLUDED.role, + password_hash = EXCLUDED.password_hash + RETURNING id, login, full_name, role, department_id, staff_id + """ + ), + { + 'login': login, + 'ph': HR_MANAGED_PASSWORD_PLACEHOLDER, + 'fn': fio, + 'role': app_role, + 'staff_id': staff_id, + }, + ).mappings().first() + + return AuthUser( + id=str(row['id']), + login=row['login'], + full_name=row['full_name'], + role=row['role'], + department_id=row['department_id'], + staff_id=row['staff_id'], + ) + + +def load_user_by_id(user_id: str) -> Optional[AuthUser]: + """Догружает пользователя из `clinic_tests.users` (используется при каждом запросе).""" + if not user_id: + return None + eng = get_engine() + with eng.connect() as conn: + row = conn.execute( + text( + 'SELECT id, login, full_name, role, department_id, staff_id ' + 'FROM users WHERE id = :id AND is_active = true' + ), + {'id': user_id}, + ).mappings().first() + if not row: + return None + return AuthUser( + id=str(row['id']), + login=row['login'], + full_name=row['full_name'], + role=row['role'], + department_id=row['department_id'], + staff_id=row['staff_id'], + ) diff --git a/flask_app/app/blueprints/__init__.py b/flask_app/app/blueprints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flask_app/app/blueprints/main.py b/flask_app/app/blueprints/main.py new file mode 100644 index 0000000..dfb3534 --- /dev/null +++ b/flask_app/app/blueprints/main.py @@ -0,0 +1,31 @@ +"""Главный blueprint — посадочная страница и health-чек. + +В E1.0 здесь нет бизнес-логики; страницы и эндпоинты добавляются в следующих +спринтах (E1.1 — auth, E1.2 — тесты, и т.д.). +""" +from __future__ import annotations + +from flask import Blueprint, jsonify, render_template + +from .. import db as app_db +from ..auth.decorators import login_required + +main_bp = Blueprint('main', __name__) + + +@main_bp.route('/health') +def health(): + """Smoke-проверка приложения и подключений к БД (без авторизации).""" + db_status = app_db.ping() + overall = 'ok' if db_status.get('main') == 'ok' else 'degraded' + return jsonify( + status=overall, + service='testing-flask-app', + db=db_status, + ) + + +@main_bp.route('/') +@login_required +def index(): + return render_template('index.html') diff --git a/flask_app/app/blueprints/settings.py b/flask_app/app/blueprints/settings.py new file mode 100644 index 0000000..54fe3c8 --- /dev/null +++ b/flask_app/app/blueprints/settings.py @@ -0,0 +1,33 @@ +"""Страница настроек: статус LLM-ключа и проверка подключения (E1.8). + +Ключ — общий, читается из ENV (`DEEPSEEK_API_KEY` / `OPENAI_API_KEY`). Здесь — +только просмотр статуса и smoke-проверка. Изменение ключа — через `.env` и +рестарт процесса. +""" +from __future__ import annotations + +from flask import Blueprint, jsonify, render_template + +from ..auth.decorators import login_required +from ..services.llm_client import get_llm_config, ping_llm + +settings_bp = Blueprint('settings', __name__) + + +@settings_bp.route('/settings', methods=['GET']) +@login_required +def settings_page(): + cfg = get_llm_config() + return render_template( + 'settings.html', + configured=cfg is not None, + provider=cfg.provider if cfg else None, + model=cfg.model if cfg else None, + base_url=cfg.base_url if cfg else None, + ) + + +@settings_bp.route('/api/llm/ping', methods=['POST', 'GET']) +@login_required +def api_llm_ping(): + return jsonify(ping_llm()) diff --git a/flask_app/app/config.py b/flask_app/app/config.py new file mode 100644 index 0000000..3f0a99d --- /dev/null +++ b/flask_app/app/config.py @@ -0,0 +1,39 @@ +"""Точечные настройки и feature-флаги (1:1 с Express-бэкендом).""" +from __future__ import annotations + +import os + + +HR_MANAGED_PASSWORD_PLACEHOLDER = '$HR$MANAGED$NO_LOCAL$' +"""Заглушка пароля для пользователей, попавших в clinic_tests через HR-апсёрт. +При локальном входе compare всегда даёт False (см. authenticate_local).""" + + +def _truthy(val: str | None) -> bool: + return (val or '').strip().lower() in ('1', 'true', 'yes', 'on') + + +def is_hr_auth_enabled() -> bool: + """`HR_AUTH=1` → логин через `hr_bot_test.users` (Werkzeug).""" + return _truthy(os.environ.get('HR_AUTH')) + + +def is_assignment_feature_enabled() -> bool: + """API/UI назначения тестов сотрудникам (см. backend/src/config/featureFlags.js).""" + if (os.environ.get('FLASK_ENV') or '').lower() == 'development': + return True + if (os.environ.get('FLASK_DEBUG') or '').strip() == '1': + return True + raw = (os.environ.get('CLINIC_ASSIGNMENT_ENABLED') or '').strip().lower() + if raw in ('1', 'true', 'yes'): + return True + if raw in ('0', 'false', 'no'): + return False + return False + + +def is_dev_ui() -> bool: + """В Express это `NODE_ENV=development`. У нас — FLASK_ENV/FLASK_DEBUG.""" + if (os.environ.get('FLASK_ENV') or '').lower() == 'development': + return True + return (os.environ.get('FLASK_DEBUG') or '').strip() == '1' diff --git a/flask_app/app/db.py b/flask_app/app/db.py new file mode 100644 index 0000000..68dd37b --- /dev/null +++ b/flask_app/app/db.py @@ -0,0 +1,141 @@ +"""Подключение к PostgreSQL — тот же паттерн, что в HR_TG_Bot/tgFlaskForm/db/session.py. + +В Этапе 1 работаем с БД `clinic_tests` (схема не меняется). Опционально доступна +вторая БД `hr_bot_test` для HR-аутентификации (см. .env.example, флаг HR_AUTH). +""" +from __future__ import annotations + +import os +import threading +from typing import Optional + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import QueuePool + +_lock = threading.Lock() +_engine: Optional[Engine] = None +_session_local: Optional[sessionmaker] = None +_hr_engine: Optional[Engine] = None +_hr_session_local: Optional[sessionmaker] = None + + +def get_database_url() -> str: + """URL основной БД (`clinic_tests`). + + Приоритет: DATABASE_URL → отдельные DB_*-переменные. + """ + if db_url := os.environ.get('DATABASE_URL'): + return db_url.strip() + + db_host = os.environ.get('DB_HOST', 'localhost') + db_port = os.environ.get('DB_PORT', '5432') + db_name = os.environ.get('DB_NAME', 'clinic_tests') + db_user = os.environ.get('DB_USER', 'hr_bot_user') + db_password = os.environ.get('DB_PASSWORD', 'hrbot123') + return f'postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}' + + +def get_hr_database_url() -> Optional[str]: + """URL БД HR (`hr_bot_test`) — только если включён HR_AUTH.""" + if not _hr_auth_enabled(): + return None + if url := os.environ.get('HR_DATABASE_URL'): + return url.strip() + return None + + +def _hr_auth_enabled() -> bool: + val = (os.environ.get('HR_AUTH') or '').strip().lower() + return val in ('1', 'true', 'yes', 'on') + + +def get_engine() -> Engine: + """Возвращает общий engine основной БД (singleton на процесс).""" + global _engine + if _engine is not None: + return _engine + with _lock: + if _engine is not None: + return _engine + _engine = create_engine( + get_database_url(), + poolclass=QueuePool, + pool_size=5, + max_overflow=10, + pool_pre_ping=True, + ) + return _engine + + +def get_session(): + """Создаёт новую ORM-сессию поверх общего engine.""" + global _session_local + if _session_local is None: + with _lock: + if _session_local is None: + _session_local = sessionmaker(bind=get_engine()) + return _session_local() + + +def get_hr_engine() -> Optional[Engine]: + """Engine для HR-БД. Возвращает None, если HR_AUTH не включён.""" + if not _hr_auth_enabled(): + return None + global _hr_engine + if _hr_engine is not None: + return _hr_engine + url = get_hr_database_url() + if not url: + return None + with _lock: + if _hr_engine is not None: + return _hr_engine + _hr_engine = create_engine( + url, + poolclass=QueuePool, + pool_size=3, + max_overflow=5, + pool_pre_ping=True, + ) + return _hr_engine + + +def get_hr_session(): + """Сессия для HR-БД (или None при выключенном HR_AUTH).""" + eng = get_hr_engine() + if eng is None: + return None + global _hr_session_local + if _hr_session_local is None: + with _lock: + if _hr_session_local is None: + _hr_session_local = sessionmaker(bind=eng) + return _hr_session_local() + + +def ping() -> dict: + """Smoke-проверка подключения к БД (используется в /health).""" + out: dict = {'main': 'unknown'} + try: + with get_engine().connect() as conn: + conn.exec_driver_sql('SELECT 1') + out['main'] = 'ok' + except Exception as e: + out['main'] = f'error: {type(e).__name__}: {e}' + + if _hr_auth_enabled(): + out['hr'] = 'unknown' + try: + eng = get_hr_engine() + if eng is None: + out['hr'] = 'disabled (HR_DATABASE_URL not set)' + else: + with eng.connect() as conn: + conn.exec_driver_sql('SELECT 1') + out['hr'] = 'ok' + except Exception as e: + out['hr'] = f'error: {type(e).__name__}: {e}' + + return out diff --git a/flask_app/app/messages.py b/flask_app/app/messages.py new file mode 100644 index 0000000..7620d39 --- /dev/null +++ b/flask_app/app/messages.py @@ -0,0 +1,25 @@ +"""Русские сообщения API (порт `backend/src/messages/ru.js`).""" + +RU = { + 'loginAndPasswordRequired': 'Укажите логин и пароль.', + 'invalidCredentials': 'Неверный логин или пароль.', + 'useHrLogin': 'Войдите через учётную запись кадровой системы (тот же логин, что в HR).', + 'hrDatabaseUrlMissing': 'База кадровой системы не настроена: задайте HR_DATABASE_URL.', + 'hrDatabaseNotConfigured': 'База кадровой системы не настроена.', + 'noStaffForLogin': ( + 'К учётной записи не привязан сотрудник: в HR в карточке сотрудника ' + 'должно совпадать поле веб-логина (web_login) с логином входа.' + ), + 'loggedOut': 'Вы вышли из системы.', + 'logoutFailed': 'Не удалось выйти. Повторите попытку.', + 'userDataFailed': 'Не удалось загрузить данные пользователя.', + 'loginFailed': 'Ошибка входа. Повторите попытку.', + 'authRequired': 'Требуется вход в систему.', + 'tokenInvalid': 'Сессия истекла или недействительна. Войдите снова.', + 'userNotFound': 'Пользователь не найден.', + 'authError': 'Ошибка проверки доступа.', + 'insufficientPermissions': 'Недостаточно прав.', + 'departmentAccessDenied': 'Нет доступа к этому подразделению.', + 'notFound': 'Не найдено.', + 'internal': 'Внутренняя ошибка сервера.', +} diff --git a/flask_app/app/services/__init__.py b/flask_app/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flask_app/app/services/ai_editor.py b/flask_app/app/services/ai_editor.py new file mode 100644 index 0000000..bcac67b --- /dev/null +++ b/flask_app/app/services/ai_editor.py @@ -0,0 +1,352 @@ +"""AI-генерация теста/вопроса в редакторе (порт `services/aiEditorService.js`).""" +from __future__ import annotations + +from typing import Any + +from .draft_validator import ( + assert_draft_matches_shape, + parse_json_from_llm_text, + validate_and_normalize_draft, +) +from .llm_client import LlmError, chat_completion_text_content, get_llm_config + + +class HttpError(Exception): + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + self.message = message + + +def parse_and_validate_shape(s: Any) -> list[dict]: + if not isinstance(s, list) or not s: + raise HttpError(400, 'Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].') + if len(s) > 40: + raise HttpError(400, 'Не более 40 вопросов за раз.') + out = [] + for i, row in enumerate(s): + if not isinstance(row, dict): + raise HttpError(400, f'shape[{i}]: ожидается объект.') + try: + n = int(float(row.get('optionsCount'))) + except (TypeError, ValueError): + raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.') + if n < 2 or n > 12: + raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.') + out.append({'optionsCount': n, 'hasMultipleAnswers': bool(row.get('hasMultipleAnswers'))}) + return out + + +def _require_cfg(): + cfg = get_llm_config() + if cfg is None: + raise HttpError(503, 'Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.') + return cfg + + +def generate_full_test_by_shape(test_title: str, test_description: str, shape: list[dict]) -> dict: + cfg = _require_cfg() + title = (test_title or '').strip() or 'Тест' + desc = (test_description or '').strip() + lines = [] + for i, sh in enumerate(shape): + if sh['hasMultipleAnswers']: + tail = 'несколько вариантов помечены как верные (hasMultipleAnswers: true).' + else: + tail = 'ровно один верный вариант (hasMultipleAnswers: false).' + lines.append(f'Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов ответа; {tail}') + + system = ( + 'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. ' + 'Схема: {"title": string, "description": string (может быть пустой строкой), ' + '"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", ' + '"options": [{ "text", "isCorrect" }]}.' + ) + user = ( + 'Составь тест по теме.\n\n' + f'Название (можно уточнить, но смысл сохранить): {title}\n' + f'Краткое описание / контекст темы: ' + f'{desc or "не указано; придумай согласованную тему с названием."}\n\n' + f'Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше):\n' + + '\n'.join(lines) + + '\n\nПравила: варианты — осмысленные, по теме; отметь isCorrect согласно ' + 'hasMultipleAnswers; для одного правильного — ровна одна true.' + ) + + raw = chat_completion_text_content(cfg, system, user, 0.35) + parsed = parse_json_from_llm_text(raw) + draft = validate_and_normalize_draft(parsed) + assert_draft_matches_shape({'questions': draft['questions']}, shape) + return { + 'title': draft['title'], + 'description': draft['description'], + 'questions': draft['questions'], + } + + +# ─── E1.8: AI v2 ────────────────────────────────────────────────────── + + +def generate_test_by_title( + test_title: str, + test_description: str = '', + questions_count: int = 10, + options_count: int = 4, + has_multiple_answers: bool = False, +) -> dict: + """Генерация теста ТОЛЬКО по названию: AI сам предлагает вопросы. + + Сетка не задаётся жёстко: пользователю даётся подсказка о желаемом числе + вопросов и вариантов, но мы валидируем мягко (не assert_draft_matches_shape). + """ + cfg = _require_cfg() + title = (test_title or '').strip() + if not title: + raise HttpError(400, 'Укажите название теста.') + desc = (test_description or '').strip() + n_q = max(3, min(40, int(questions_count or 10))) + n_opt = max(2, min(12, int(options_count or 4))) + + system = ( + 'Ты опытный методист, составляешь учебные тесты. Отвечай ТОЛЬКО одним ' + 'JSON-объектом на русском. Схема: {"title", "description", "questions": [' + '{"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect"}]}' + ']}. Минимум 2 варианта, отметь хотя бы один isCorrect: true.' + ) + user = ( + 'Составь учебный тест по этой теме.\n\n' + f'Название теста: {title}\n' + f'Описание/контекст: {desc or "не указано — определи по названию."}\n\n' + f'Подсказка по сетке: примерно {n_q} вопросов, в каждом по {n_opt} вариантов ' + f'ответа; ' + f'тип ответа — {"несколько правильных" if has_multiple_answers else "один правильный"} ' + f'(но если по смыслу нужно отступить — отступи). ' + 'Покрой ключевые подтемы. Дистракторы делай правдоподобными, не очевидно ' + 'неверными. Текст — короткий, понятный.' + ) + raw = chat_completion_text_content(cfg, system, user, 0.45) + parsed = parse_json_from_llm_text(raw) + draft = validate_and_normalize_draft(parsed) + return { + 'title': draft['title'], + 'description': draft['description'], + 'questions': draft['questions'], + } + + +def check_test_quality(test_title: str, test_description: str, questions: list[dict]) -> dict: + """AI-рецензия теста: общий вердикт + список рекомендаций по разделам.""" + cfg = _require_cfg() + title = (test_title or '').strip() or 'Тест' + desc = (test_description or '').strip() + qs = questions or [] + if not qs: + raise HttpError(400, 'В тесте нет вопросов — нечего проверять.') + + system = ( + 'Ты ревьюер учебных тестов. Отвечай ТОЛЬКО JSON: {"verdict": "ok"|"warn"|"bad", ' + '"summary": string (1-2 предложения), ' + '"sections": [{"title": string, "items": [string, ...]}]}. ' + 'Разделы рекомендаций: «Чёткость формулировок», «Качество дистракторов», ' + '"Охват темы», «Сбалансированность сложности». Пропусти раздел, если ' + 'претензий нет. Вердикт: ok — годен; warn — есть замечания; bad — серьёзные ' + 'проблемы. Все тексты — на русском, короткие и предметные.' + ) + test_dump = { + 'title': title, + 'description': desc, + 'questions': [ + { + 'text': q.get('text', ''), + 'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')), + 'options': [ + {'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))} + for o in (q.get('options') or []) + ], + } + for q in qs + ], + } + import json as _json + + user = 'Проверь качество теста и дай рекомендации:\n\n' + _json.dumps( + test_dump, ensure_ascii=False + ) + raw = chat_completion_text_content(cfg, system, user, 0.25) + parsed = parse_json_from_llm_text(raw) + if not isinstance(parsed, dict): + raise LlmError('Неверный формат ответа модели.', code='llm_shape') + verdict = str(parsed.get('verdict') or '').strip().lower() + if verdict not in ('ok', 'warn', 'bad'): + verdict = 'warn' + summary = str(parsed.get('summary') or '').strip() + raw_sections = parsed.get('sections') or [] + sections: list[dict] = [] + if isinstance(raw_sections, list): + for s in raw_sections: + if not isinstance(s, dict): + continue + t = str(s.get('title') or '').strip() + items = s.get('items') or [] + if not t or not isinstance(items, list) or not items: + continue + clean_items = [str(x).strip() for x in items if str(x).strip()] + if clean_items: + sections.append({'title': t, 'items': clean_items}) + return {'verdict': verdict, 'summary': summary, 'sections': sections} + + +def improve_test_full(test_title: str, test_description: str, questions: list[dict]) -> dict: + """AI-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса. + + Для каждого вопроса: original + suggested, флаги textChanged/optionsChanged. + UI решает, что применить (чекбоксы). + """ + cfg = _require_cfg() + title = (test_title or '').strip() or 'Тест' + desc = (test_description or '').strip() + qs = questions or [] + if not qs: + raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.') + + system = ( + 'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь ' + 'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку ' + 'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и ' + 'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. ' + 'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", ' + '"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.' + ) + test_dump = { + 'title': title, + 'description': desc, + 'questions': [ + { + 'text': q.get('text', ''), + 'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')), + 'options': [ + {'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))} + for o in (q.get('options') or []) + ], + } + for q in qs + ], + } + import json as _json + + user = 'Улучши тест без изменения сетки:\n\n' + _json.dumps( + test_dump, ensure_ascii=False + ) + raw = chat_completion_text_content(cfg, system, user, 0.3) + parsed = parse_json_from_llm_text(raw) + + shape = [ + { + 'optionsCount': len(q.get('options') or []), + 'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')), + } + for q in qs + ] + assert_draft_matches_shape(parsed, shape) + draft = validate_and_normalize_draft( + {'title': title, 'questions': parsed.get('questions') or []} + ) + suggested_qs = draft['questions'] + + items = [] + for i, (orig, sug) in enumerate(zip(qs, suggested_qs)): + orig_opts = [ + {'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))} + for o in (orig.get('options') or []) + ] + sug_opts = sug['options'] + text_changed = (str(orig.get('text', '')).strip() != sug['text']) + options_changed = ( + len(orig_opts) != len(sug_opts) + or any( + a['text'] != b['text'] or a['isCorrect'] != b['isCorrect'] + for a, b in zip(orig_opts, sug_opts) + ) + ) + items.append( + { + 'index': i, + 'original': { + 'text': str(orig.get('text', '')).strip(), + 'hasMultipleAnswers': bool(orig.get('hasMultipleAnswers')), + 'options': orig_opts, + }, + 'suggested': { + 'text': sug['text'], + 'hasMultipleAnswers': sug['hasMultipleAnswers'], + 'options': sug_opts, + }, + 'textChanged': text_changed, + 'optionsChanged': options_changed, + 'changed': text_changed or options_changed, + } + ) + + return {'items': items} + + +def generate_or_rephrase_question( + test_title: str, + test_description: str, + question_text: str, + options_count: Any, + has_multiple_answers: bool, +) -> dict: + cfg = _require_cfg() + try: + n = int(float(options_count)) + except (TypeError, ValueError): + raise HttpError(400, 'optionsCount: от 2 до 12.') + if n < 2 or n > 12: + raise HttpError(400, 'optionsCount: от 2 до 12.') + + topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip() + qt = (question_text or '').strip() + + if qt: + system = ( + 'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — ' + 'чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости ' + 'от сложности исходного черновика, без вариантов ответа.' + ) + user = ( + f'Тема теста: {topic}\n\n' + f'Исходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n{qt}' + ) + raw = chat_completion_text_content(cfg, system, user, 0.3) + parsed = parse_json_from_llm_text(raw) + text = str((parsed or {}).get('text') or '').strip() + if not text: + raise LlmError('Пустой text в ответе модели.', code='llm_shape') + return {'mode': 'rephrase', 'text': text} + + system = ( + 'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", ' + '"options": [{ "text", "isCorrect" }]}. Все на русском.' + ) + multi_clause = ( + 'true (несколько верных, минимум 2 isCorrect: true, остальные false).' + if has_multiple_answers + else 'false (ровно один isCorrect: true).' + ) + user = ( + f'Тема теста: {topic}\n\n' + f'Сформулируй ОДИН вопрос по этой теме с ровно {n} вариантами ответа. ' + f'hasMultipleAnswers = {multi_clause}' + ) + raw = chat_completion_text_content(cfg, system, user, 0.35) + parsed = parse_json_from_llm_text(raw) + shape = [{'optionsCount': n, 'hasMultipleAnswers': bool(has_multiple_answers)}] + assert_draft_matches_shape({'questions': [parsed]}, shape) + draft = validate_and_normalize_draft({'title': 'временно', 'questions': [parsed]}) + return { + 'mode': 'full', + 'text': draft['questions'][0]['text'], + 'hasMultipleAnswers': draft['questions'][0]['hasMultipleAnswers'], + 'options': draft['questions'][0]['options'], + } diff --git a/flask_app/app/services/document_extract.py b/flask_app/app/services/document_extract.py new file mode 100644 index 0000000..e3b58d6 --- /dev/null +++ b/flask_app/app/services/document_extract.py @@ -0,0 +1,89 @@ +"""Извлечение текста из PDF/DOCX/TXT/MD (порт `services/documentExtractService.js`).""" +from __future__ import annotations + +from io import BytesIO +from typing import Optional + + +class HttpError(Exception): + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + self.message = message + + +SUPPORTED_MIME = { + 'application/pdf': 'pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'text/plain': 'text', + 'text/markdown': 'text', +} +SUPPORTED_EXT = { + '.pdf': 'pdf', + '.docx': 'docx', + '.txt': 'text', + '.md': 'text', +} + + +def resolve_document_kind(mimetype: str | None, original_name: str | None = '') -> Optional[str]: + m = (mimetype or '').lower() + n = (original_name or '').lower() + if m in SUPPORTED_MIME: + return SUPPORTED_MIME[m] + for ext, kind in SUPPORTED_EXT.items(): + if n.endswith(ext): + return kind + return None + + +def extract_text_from_buffer(kind: str, buf: bytes) -> str: + if kind == 'text': + try: + return buf.decode('utf-8') + except UnicodeDecodeError: + return buf.decode('utf-8', errors='replace') + + if kind == 'docx': + try: + from docx import Document + except ImportError: + raise HttpError(500, 'python-docx не установлен (см. requirements.txt).') + doc = Document(BytesIO(buf)) + parts = [] + for p in doc.paragraphs: + if p.text: + parts.append(p.text) + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + if cell.text: + parts.append(cell.text) + return '\n'.join(parts).replace('\r\n', '\n').strip() + + if kind == 'pdf': + try: + from pypdf import PdfReader + except ImportError: + raise HttpError(500, 'pypdf не установлен (см. requirements.txt).') + reader = PdfReader(BytesIO(buf)) + parts = [] + for page in reader.pages: + try: + t = page.extract_text() or '' + except Exception: + t = '' + if t: + parts.append(t) + return '\n'.join(parts).replace('\r\n', '\n').strip() + + return '' + + +def extract_text_from_file(mimetype: str | None, file_storage, original_name: str | None) -> str: + """`file_storage` — werkzeug FileStorage. Читает целиком в память (≤16 МБ).""" + kind = resolve_document_kind(mimetype, original_name) + if not kind: + raise HttpError(400, 'Неподдерживаемый формат. Допустимы: PDF, DOCX, TXT, MD.') + buf = file_storage.read() + return extract_text_from_buffer(kind, buf) diff --git a/flask_app/app/services/document_gen.py b/flask_app/app/services/document_gen.py new file mode 100644 index 0000000..d4256b8 --- /dev/null +++ b/flask_app/app/services/document_gen.py @@ -0,0 +1,72 @@ +"""Генерация черновика теста из извлечённого текста (порт части `documentGenService.js`).""" +from __future__ import annotations + +from .draft_validator import ( + parse_json_from_llm_text, + validate_and_normalize_draft, +) +from .llm_client import LlmError, chat_completion_text_content, get_llm_config + + +MAX_EXTRACT_CHARS = 14000 + + +def generation_for_import_document(extracted_text: str) -> dict: + text = (extracted_text or '').strip() + if not text: + return { + 'available': False, + 'message': 'Нет извлечённого текста — нечего передавать в модель.', + } + cfg = get_llm_config() + if cfg is None: + return { + 'available': False, + 'message': ( + 'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY ' + 'в .env. Превью текста ниже — можно вставить вручную.' + ), + 'textPreview': text[:4000], + } + if len(text) > MAX_EXTRACT_CHARS: + slice_ = text[:MAX_EXTRACT_CHARS] + '\n\n[…фрагмент обрезан для API]' + else: + slice_ = text + try: + system = ( + 'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом ' + 'без пояснений. Схема: {"title": string, "description"?: string, ' + '"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, ' + '"options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. ' + 'Для одиночного выбора ровно один isCorrect: true. ' + 'Текст и формулировки — на русском, по содержанию входного материала.' + ) + user = ( + 'Составь тест с вопросами с одним или несколькими правильными ответами ' + 'на основе текста:\n\n' + slice_ + ) + raw = chat_completion_text_content(cfg, system, user, 0.25) + parsed = parse_json_from_llm_text(raw) + draft = validate_and_normalize_draft(parsed) + return { + 'available': True, + 'message': ( + f'Сгенерировано: «{draft["title"]}», вопросов: ' + f'{len(draft["questions"])}. Нажмите «Применить сгенерированный черновик».' + ), + 'draft': draft, + } + except LlmError as e: + return { + 'available': False, + 'message': f'Генерация не удалась: {e}', + 'errorCode': e.code, + 'textPreview': text[:4000], + } + except Exception as e: + return { + 'available': False, + 'message': f'Генерация не удалась: {e}', + 'errorCode': 'unknown', + 'textPreview': text[:4000], + } diff --git a/flask_app/app/services/draft_validator.py b/flask_app/app/services/draft_validator.py new file mode 100644 index 0000000..a8a6254 --- /dev/null +++ b/flask_app/app/services/draft_validator.py @@ -0,0 +1,105 @@ +"""Парсер JSON от LLM и валидатор draft (порт частей `documentGenService.js`).""" +from __future__ import annotations + +import json as _json +import re +from typing import Any + +from .llm_client import LlmError + + +_FENCE_RE = re.compile(r'^```(?:json)?\s*([\s\S]*?)```$', re.MULTILINE) + + +def parse_json_from_llm_text(text: str) -> Any: + if not isinstance(text, str) or not text.strip(): + raise LlmError('Пустой ответ модели.', code='llm_empty') + t = text.strip() + if m := _FENCE_RE.match(t): + t = m.group(1).strip() + try: + return _json.loads(t) + except _json.JSONDecodeError: + raise LlmError('Ответ модели не является корректным JSON.', code='llm_json_parse') + + +def validate_and_normalize_draft(o: Any) -> dict: + if not isinstance(o, dict): + raise LlmError('JSON не содержит объекта с данными.', code='llm_shape') + title = str(o.get('title') or '').strip() + if not title: + raise LlmError('В ответе нет поля title.', code='llm_shape') + desc = o.get('description') + description = str(desc).strip() if desc and str(desc).strip() else None + + raw_qs = o.get('questions') + if not isinstance(raw_qs, list) or not raw_qs: + raise LlmError('В ответе нет вопросов (questions).', code='llm_shape') + if len(raw_qs) > 40: + raise LlmError('Слишком много вопросов в ответе (макс. 40).', code='llm_shape') + + questions = [] + for i, q in enumerate(raw_qs): + if not isinstance(q, dict): + raise LlmError(f'Вопрос {i + 1}: неверный формат.', code='llm_shape') + text = str(q.get('text') or '').strip() + if not text: + raise LlmError(f'Вопрос {i + 1}: пустой текст.', code='llm_shape') + has_multi = bool(q.get('hasMultipleAnswers')) + raw_opts = q.get('options') + if not isinstance(raw_opts, list) or len(raw_opts) < 2: + raise LlmError(f'Вопрос {i + 1}: нужны минимум 2 варианта ответа.', code='llm_shape') + if len(raw_opts) > 12: + raise LlmError(f'Вопрос {i + 1}: слишком много вариантов (макс. 12).', code='llm_shape') + + options = [] + for j, op in enumerate(raw_opts): + if not isinstance(op, dict): + raise LlmError(f'Вопрос {i + 1}, вариант {j + 1}: неверный формат.', code='llm_shape') + options.append( + { + 'text': (str(op.get('text') or '').strip() or f'Вариант {j + 1}'), + 'isCorrect': bool(op.get('isCorrect')), + } + ) + correct_n = sum(1 for x in options if x['isCorrect']) + if correct_n == 0: + raise LlmError( + f'Вопрос {i + 1}: отметьте минимум один правильный вариант.', + code='llm_shape', + ) + if not has_multi and correct_n > 1: + raise LlmError( + f'Вопрос {i + 1}: с одним правильным ответом должен быть один вариант ' + f'isCorrect, либо укажите hasMultipleAnswers: true.', + code='llm_shape', + ) + questions.append({'text': text, 'hasMultipleAnswers': has_multi, 'options': options}) + + return {'title': title, 'description': description, 'questions': questions} + + +def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None: + """Проверяет, что число вопросов и вариантов = ровно как в shape.""" + qs = o.get('questions') if isinstance(o, dict) else None + if not isinstance(qs, list): + raise LlmError('В ответе нет questions.', code='llm_shape') + if len(qs) != len(shape): + raise LlmError( + f'Ожидалось вопросов: {len(shape)}, в ответе: {len(qs)}.', + code='llm_shape', + ) + for i, (q, sh) in enumerate(zip(qs, shape)): + opts = q.get('options') if isinstance(q, dict) else None + if not isinstance(opts, list): + raise LlmError(f'Вопрос {i + 1}: нет options.', code='llm_shape') + if len(opts) != sh['optionsCount']: + raise LlmError( + f'Вопрос {i + 1}: ожидалось вариантов {sh["optionsCount"]}, в ответе: {len(opts)}.', + code='llm_shape', + ) + if bool(q.get('hasMultipleAnswers')) != sh['hasMultipleAnswers']: + raise LlmError( + f'Вопрос {i + 1}: hasMultipleAnswers должен быть {sh["hasMultipleAnswers"]}.', + code='llm_shape', + ) diff --git a/flask_app/app/services/editor_content.py b/flask_app/app/services/editor_content.py new file mode 100644 index 0000000..c1339a8 --- /dev/null +++ b/flask_app/app/services/editor_content.py @@ -0,0 +1,95 @@ +"""Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами. + +Порт `getEditorContent` + `loadQuestionsForVersion` (только includeCorrect=true вариант) +из `services/testAttemptService.js`. +""" +from __future__ import annotations + +from sqlalchemy import text + +from ..db import get_engine +from ..messages import RU +from .test_access import is_test_author + + +class HttpError(Exception): + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + self.message = message + + +def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]: + qrows = conn.execute( + text( + 'SELECT id, text, question_order, has_multiple_answers ' + 'FROM questions WHERE test_version_id = :v ORDER BY question_order' + ), + {'v': test_version_id}, + ).mappings().all() + out = [] + for r in qrows: + orows = conn.execute( + text( + 'SELECT id, text, is_correct, option_order ' + 'FROM answer_options WHERE question_id = :q ORDER BY option_order' + ), + {'q': r['id']}, + ).mappings().all() + options = [] + for o in orows: + base = { + 'id': str(o['id']), + 'text': o['text'], + 'optionOrder': o['option_order'], + } + if include_correct: + base['isCorrect'] = bool(o['is_correct']) + options.append(base) + out.append( + { + 'id': str(r['id']), + 'text': r['text'], + 'questionOrder': r['question_order'], + 'hasMultipleAnswers': bool(r['has_multiple_answers']), + 'options': options, + } + ) + return out + + +def get_editor_content(user_id: str, test_id: str) -> dict: + eng = get_engine() + with eng.connect() as conn: + tr = conn.execute( + text( + 'SELECT id, title, description, passing_threshold, created_by ' + 'FROM tests WHERE id = :id' + ), + {'id': test_id}, + ).mappings().first() + if not tr: + raise HttpError(404, 'Тест не найден.') + if not is_test_author(tr['created_by'], user_id): + raise HttpError(403, 'Доступ запрещён.') + tv = conn.execute( + text( + 'SELECT id FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1' + ), + {'id': test_id}, + ).mappings().first() + if not tv: + raise HttpError(400, 'Нет активной версии теста.') + version_id = tv['id'] + questions = load_questions_for_version(conn, version_id, include_correct=True) + + return { + 'test': { + 'id': str(tr['id']), + 'title': tr['title'], + 'description': tr['description'], + 'passingThreshold': tr['passing_threshold'], + }, + 'activeVersionId': str(version_id), + 'questions': questions, + } diff --git a/flask_app/app/services/llm_client.py b/flask_app/app/services/llm_client.py new file mode 100644 index 0000000..39473df --- /dev/null +++ b/flask_app/app/services/llm_client.py @@ -0,0 +1,156 @@ +"""OpenAI-совместимый клиент Chat Completions (порт `services/llmClient.js`).""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + +import urllib.request +import urllib.error +import json as _json + + +class LlmError(Exception): + """Ошибка работы с LLM API.""" + + def __init__(self, message: str, code: str = 'llm_error', status: int | None = None): + super().__init__(message) + self.code = code + self.status = status + + +@dataclass +class LlmConfig: + provider: str + api_key: str + base_url: str + model: str + + +def get_llm_config() -> Optional[LlmConfig]: + if k := os.environ.get('DEEPSEEK_API_KEY'): + return LlmConfig( + provider='deepseek', + api_key=k, + base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.deepseek.com/v1').rstrip('/'), + model=os.environ.get('LLM_MODEL') or 'deepseek-chat', + ) + if k := os.environ.get('OPENAI_API_KEY'): + return LlmConfig( + provider='openai', + api_key=k, + base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.openai.com/v1').rstrip('/'), + model=os.environ.get('LLM_MODEL') or 'gpt-4o-mini', + ) + return None + + +def chat_completion_text_content( + cfg: LlmConfig, + system: str, + user: str, + temperature: float = 0.25, + timeout: int = 120, +) -> str: + """Возвращает `assistant.message.content` (строку).""" + body: dict = { + 'model': cfg.model, + 'messages': [ + {'role': 'system', 'content': system}, + {'role': 'user', 'content': user}, + ], + 'temperature': temperature, + } + if (os.environ.get('LLM_NO_JSON') or '').strip() != '1': + body['response_format'] = {'type': 'json_object'} + + req = urllib.request.Request( + f'{cfg.base_url}/chat/completions', + data=_json.dumps(body).encode('utf-8'), + headers={ + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {cfg.api_key}', + }, + method='POST', + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = _json.loads(resp.read().decode('utf-8')) + except urllib.error.HTTPError as e: + text = '' + try: + text = e.read().decode('utf-8', errors='replace') + except Exception: + pass + raise LlmError( + f'LLM {e.code}: {(text or "").replace(chr(10), " ")[:280]}', + code='llm_http', + status=e.code, + ) + except (urllib.error.URLError, TimeoutError) as e: + msg = str(getattr(e, 'reason', '') or e) + if 'timed out' in msg.lower(): + raise LlmError('Превышен таймаут ожидания ответа LLM (120 с).', code='llm_timeout') + raise LlmError(f'Сбой сети при обращении к LLM: {msg}', code='llm_network') + + try: + content = data['choices'][0]['message']['content'] + except (KeyError, IndexError, TypeError): + content = None + if not isinstance(content, str) or not content.strip(): + raise LlmError('Пустой content в ответе API.', code='llm_empty') + return content + + +def ping_llm(timeout: int = 30) -> dict: + """Smoke-проверка подключения к LLM. Не бросает исключений — всё в результате. + + Возвращает: {'ok': bool, 'provider', 'model', 'error'?, 'latencyMs'?, 'sample'?} + """ + import time + + cfg = get_llm_config() + if cfg is None: + return { + 'ok': False, + 'configured': False, + 'error': 'Ключ не задан. Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY в .env.', + } + started = time.monotonic() + try: + raw = chat_completion_text_content( + cfg, + 'Отвечай ТОЛЬКО JSON: {"ok": true}.', + 'ping', + temperature=0.0, + timeout=timeout, + ) + ms = int((time.monotonic() - started) * 1000) + return { + 'ok': True, + 'configured': True, + 'provider': cfg.provider, + 'model': cfg.model, + 'latencyMs': ms, + 'sample': raw[:120], + } + except LlmError as e: + ms = int((time.monotonic() - started) * 1000) + return { + 'ok': False, + 'configured': True, + 'provider': cfg.provider, + 'model': cfg.model, + 'latencyMs': ms, + 'error': str(e), + 'code': e.code, + } + except Exception as e: + return { + 'ok': False, + 'configured': True, + 'provider': cfg.provider, + 'model': cfg.model, + 'error': f'{type(e).__name__}: {e}', + 'code': 'unknown', + } diff --git a/flask_app/app/services/test_access.py b/flask_app/app/services/test_access.py new file mode 100644 index 0000000..d1c0e20 --- /dev/null +++ b/flask_app/app/services/test_access.py @@ -0,0 +1,108 @@ +"""Кто видит тест: автор + назначенные пользователи (порт `services/testAccessService.js`).""" +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import text + +from ..db import get_engine + + +def is_test_author(created_by, user_id) -> bool: + """`tests.created_by` — UUID. Сравниваем по строковому представлению.""" + if created_by is None or user_id is None: + return False + return str(created_by) == str(user_id) + + +@dataclass +class AccessResult: + ok: bool + is_author: bool + not_found: bool + + +def user_has_test_access(user_id: str, test_id: str) -> AccessResult: + eng = get_engine() + with eng.connect() as conn: + row = conn.execute( + text('SELECT created_by FROM tests WHERE id = :id'), + {'id': test_id}, + ).mappings().first() + if not row: + return AccessResult(ok=False, is_author=False, not_found=True) + if is_test_author(row['created_by'], user_id): + return AccessResult(ok=True, is_author=True, not_found=False) + ar = conn.execute( + text( + """ + SELECT 1 + FROM test_assignments ta + INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id + INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id + WHERE tv_a.test_id = :test_id + AND tat.target_type = 'user' + AND tat.target_id = :user_id + LIMIT 1 + """ + ), + {'test_id': test_id, 'user_id': user_id}, + ).first() + return AccessResult(ok=ar is not None, is_author=False, not_found=False) + + +def list_visible_tests(user_id: str) -> list[dict]: + """Каталог: только активная цепочка + (автор OR назначен).""" + eng = get_engine() + with eng.connect() as conn: + rows = conn.execute( + text( + """ + SELECT DISTINCT t.id, t.title, t.description, t.is_active AS chain_active, + t.created_at, t.updated_at, + tv.id AS active_version_id, tv.version, + t.created_by, u.full_name AS author_full_name + FROM tests t + INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true + INNER JOIN users u ON u.id = t.created_by + WHERE t.is_active = true + AND ( + t.created_by = :uid + OR EXISTS ( + SELECT 1 + FROM test_assignments ta + INNER JOIN test_versions tv2 ON tv2.id = ta.test_version_id + INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id + WHERE tv2.test_id = t.id + AND tat.target_type = 'user' + AND tat.target_id = :uid + ) + ) + ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC + """ + ), + {'uid': user_id}, + ).mappings().all() + return [dict(r) for r in rows] + + +def list_hidden_by_author(user_id: str) -> list[dict]: + """Скрытые автором цепочки (`is_active = false`) — видны только автору.""" + eng = get_engine() + with eng.connect() as conn: + rows = conn.execute( + text( + """ + SELECT t.id, t.title, t.description, t.is_active AS chain_active, + t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, + t.created_by, u.full_name AS author_full_name + FROM tests t + INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true + INNER JOIN users u ON u.id = t.created_by + WHERE t.is_active = false AND t.created_by = :uid + ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC + """ + ), + {'uid': user_id}, + ).mappings().all() + return [dict(r) for r in rows] diff --git a/flask_app/app/services/test_chain.py b/flask_app/app/services/test_chain.py new file mode 100644 index 0000000..1a601de --- /dev/null +++ b/flask_app/app/services/test_chain.py @@ -0,0 +1,22 @@ +"""Утилиты по цепочке теста (попытки/версии).""" +from __future__ import annotations + +from sqlalchemy import text + + +def has_any_attempt_for_test(conn, test_id: str) -> bool: + """`conn` может быть Connection или Engine — обе поддерживают .execute().""" + row = conn.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM test_attempts ta + INNER JOIN test_versions tv ON ta.test_version_id = tv.id + WHERE tv.test_id = :test_id + ) AS has_any + """ + ), + {'test_id': test_id}, + ).first() + return bool(row[0]) diff --git a/flask_app/app/services/test_draft.py b/flask_app/app/services/test_draft.py new file mode 100644 index 0000000..cbdcf23 --- /dev/null +++ b/flask_app/app/services/test_draft.py @@ -0,0 +1,234 @@ +"""Создание/правка теста, fork версии при наличии попыток (порт `testDraftService.js`).""" +from __future__ import annotations + +from typing import Any + +from sqlalchemy import text + +from ..db import get_engine +from ..messages import RU +from .test_access import is_test_author +from .test_chain import has_any_attempt_for_test + + +class HttpError(Exception): + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + self.message = message + + +def create_test_with_version(author_id: str, *, title: str, description: str | None) -> dict: + eng = get_engine() + with eng.begin() as conn: + t = conn.execute( + text( + """ + INSERT INTO tests (title, description, created_by, is_active, is_versioned) + VALUES (:title, :desc, :uid, true, true) RETURNING id + """ + ), + {'title': title, 'desc': description or None, 'uid': author_id}, + ).mappings().first() + test_id = t['id'] + v = conn.execute( + text( + """ + INSERT INTO test_versions (test_id, version, is_active, parent_id) + VALUES (:tid, 1, true, NULL) RETURNING id + """ + ), + {'tid': test_id}, + ).mappings().first() + return {'testId': str(test_id), 'versionId': str(v['id'])} + + +def _get_active_version_row(conn, test_id: str) -> dict | None: + row = conn.execute( + text( + 'SELECT * FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1' + ), + {'id': test_id}, + ).mappings().first() + return dict(row) if row else None + + +def _copy_question_tree(conn, from_version_id, to_version_id) -> None: + questions = conn.execute( + text( + 'SELECT id, text, question_order, has_multiple_answers ' + 'FROM questions WHERE test_version_id = :v ORDER BY question_order' + ), + {'v': from_version_id}, + ).mappings().all() + for q in questions: + new_q = conn.execute( + text( + """ + INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers) + VALUES (:v, :text, :ord, :multi) RETURNING id + """ + ), + { + 'v': to_version_id, + 'text': q['text'], + 'ord': q['question_order'], + 'multi': q['has_multiple_answers'], + }, + ).mappings().first() + nqid = new_q['id'] + opts = conn.execute( + text( + 'SELECT text, is_correct, option_order FROM answer_options ' + 'WHERE question_id = :q ORDER BY option_order' + ), + {'q': q['id']}, + ).mappings().all() + for o in opts: + conn.execute( + text( + """ + INSERT INTO answer_options (question_id, text, is_correct, option_order) + VALUES (:q, :text, :ic, :ord) + """ + ), + {'q': nqid, 'text': o['text'], 'ic': o['is_correct'], 'ord': o['option_order']}, + ) + + +def _replace_version_content(conn, test_version_id, payload: dict) -> None: + conn.execute( + text( + """ + DELETE FROM answer_options WHERE question_id IN ( + SELECT id FROM questions WHERE test_version_id = :v + ) + """ + ), + {'v': test_version_id}, + ) + conn.execute( + text('DELETE FROM questions WHERE test_version_id = :v'), + {'v': test_version_id}, + ) + questions = payload.get('questions') or [] + for i, q in enumerate(questions): + ins_q = conn.execute( + text( + """ + INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers) + VALUES (:v, :text, :ord, :multi) RETURNING id + """ + ), + { + 'v': test_version_id, + 'text': q.get('text'), + 'ord': q.get('question_order') or (i + 1), + 'multi': bool(q.get('hasMultipleAnswers')), + }, + ).mappings().first() + qid = ins_q['id'] + opts = q.get('options') or [] + for j, o in enumerate(opts): + conn.execute( + text( + """ + INSERT INTO answer_options (question_id, text, is_correct, option_order) + VALUES (:q, :text, :ic, :ord) + """ + ), + { + 'q': qid, + 'text': o.get('text'), + 'ic': bool(o.get('isCorrect')), + 'ord': o.get('option_order') or (j + 1), + }, + ) + + +def _fork_new_version(conn, test_id: str) -> dict: + av = _get_active_version_row(conn, test_id) + if not av: + raise HttpError(500, RU['internal']) # invariant: должна быть активная версия + mx = conn.execute( + text( + 'SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = :t' + ), + {'t': test_id}, + ).mappings().first() + next_v = (mx['v'] or 0) + 1 + conn.execute( + text('UPDATE test_versions SET is_active = false WHERE test_id = :t'), + {'t': test_id}, + ) + nv = conn.execute( + text( + """ + INSERT INTO test_versions (test_id, version, is_active, parent_id) + VALUES (:t, :ver, true, :parent) RETURNING * + """ + ), + {'t': test_id, 'ver': next_v, 'parent': av['id']}, + ).mappings().first() + _copy_question_tree(conn, av['id'], nv['id']) + return dict(nv) + + +def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict: + if not isinstance(payload, dict): + payload = {} + eng = get_engine() + with eng.begin() as conn: + t = conn.execute( + text('SELECT id, created_by FROM tests WHERE id = :id'), + {'id': test_id}, + ).mappings().first() + if not t: + raise HttpError(404, RU['testNotFound'] if 'testNotFound' in RU else 'Тест не найден.') + if not is_test_author(t['created_by'], author_id): + raise HttpError(403, 'Доступ запрещён.') + + if payload.get('title') is not None or payload.get('description') is not None: + conn.execute( + text( + """ + UPDATE tests + SET title = COALESCE(:title, title), + description = COALESCE(:desc, description), + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + """ + ), + { + 'title': payload.get('title'), + 'desc': payload.get('description'), + 'id': test_id, + }, + ) + if payload.get('passingThreshold') is not None: + try: + raw = float(payload['passingThreshold']) + pt = max(0, min(100, round(raw))) + conn.execute( + text( + 'UPDATE tests SET passing_threshold = :pt, updated_at = CURRENT_TIMESTAMP WHERE id = :id' + ), + {'pt': pt, 'id': test_id}, + ) + except (TypeError, ValueError): + pass + + has_attempts = has_any_attempt_for_test(conn, test_id) + version_row = _get_active_version_row(conn, test_id) + if not version_row: + raise HttpError(500, 'Нет активной версии теста.') + + forked = False + if has_attempts and 'questions' in payload and payload.get('questions') is not None: + version_row = _fork_new_version(conn, test_id) + forked = True + + if payload.get('questions') is not None: + _replace_version_content(conn, version_row['id'], payload) + + return {'testId': test_id, 'versionId': str(version_row['id']), 'forked': forked} diff --git a/flask_app/app/static/css/app.css b/flask_app/app/static/css/app.css new file mode 100644 index 0000000..643152e --- /dev/null +++ b/flask_app/app/static/css/app.css @@ -0,0 +1,17 @@ +/* Точечные стили поверх Tailwind CDN. + В E1.0 файл почти пустой — задаёт только сглаживание иконок и базовый focus-ring, + чтобы кнопки/ссылки были консистентны со стилем кабинета HR. */ + +.material-symbols-outlined { + font-variation-settings: + 'FILL' 0, + 'wght' 400, + 'GRAD' 0, + 'opsz' 20; +} + +:focus-visible { + outline: 2px solid #6366f1; /* brand-500 */ + outline-offset: 2px; + border-radius: 6px; +} diff --git a/flask_app/app/static/js/editor.js b/flask_app/app/static/js/editor.js new file mode 100644 index 0000000..b535bb2 --- /dev/null +++ b/flask_app/app/static/js/editor.js @@ -0,0 +1,546 @@ +/* Редактор теста: рабочий минимум. + * Работает с эндпоинтами /api/tests//{draft, ai/generate-test, ai/generate-question} + * и /api/tests/ (PATCH chainActive). + * + * Полная мобильная отполировка UX (4 аккордеона, fixed footer, drag-n-drop) + * запланирована отдельным спринтом E1.7. + */ +(() => { + 'use strict'; + + const root = document.getElementById('editor-root'); + if (!root) return; + + const TEST_ID = root.dataset.testId; + const initial = JSON.parse(root.dataset.initial); + + const $ = (sel, parent = document) => parent.querySelector(sel); + const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel)); + + const titleEl = $('#test-title'); + const descEl = $('#test-description'); + const thresholdEl = $('#test-threshold'); + const questionsEl = $('#questions'); + const qCountEl = $('#q-count'); + const saveStatusEl = $('#save-status'); + const aiStatusEl = $('#ai-status'); + const chainActiveEl = $('#chain-active'); + + const tplQ = $('#tpl-question'); + const tplO = $('#tpl-option'); + + let chainActive = true; + + // ─── render ───────────────────────────────────────────────────────── + + function renderQuestion(q) { + const node = tplQ.content.firstElementChild.cloneNode(true); + node._q = { id: q.id || null }; + $('.q-text', node).value = q.text || ''; + $('.q-multi', node).checked = !!q.hasMultipleAnswers; + + const optsEl = $('.q-options', node); + (q.options || []).forEach((o) => optsEl.appendChild(renderOption(o))); + + bindQuestionEvents(node); + return node; + } + + function renderOption(o) { + const node = tplO.content.firstElementChild.cloneNode(true); + $('.opt-text', node).value = o.text || ''; + $('.opt-correct', node).checked = !!o.isCorrect; + $('.opt-delete', node).addEventListener('click', () => { + node.remove(); + }); + return node; + } + + function bindQuestionEvents(node) { + $('.q-delete', node).addEventListener('click', () => { + if (!confirm('Удалить вопрос?')) return; + node.remove(); + renumber(); + }); + $('.q-up', node).addEventListener('click', () => { + if (node.previousElementSibling) { + node.parentNode.insertBefore(node, node.previousElementSibling); + renumber(); + } + }); + $('.q-down', node).addEventListener('click', () => { + if (node.nextElementSibling) { + node.parentNode.insertBefore(node.nextElementSibling, node); + renumber(); + } + }); + $('.q-add-option', node).addEventListener('click', () => { + $('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false })); + }); + $('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node)); + } + + function renumber() { + $$('#questions .q-item').forEach((li, i) => { + $('.q-num', li).textContent = `Вопрос #${i + 1}`; + }); + qCountEl.textContent = $$('#questions .q-item').length; + } + + function loadInitial() { + titleEl.value = initial.test.title || ''; + descEl.value = initial.test.description || ''; + thresholdEl.value = + initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold); + + questionsEl.innerHTML = ''; + (initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); + renumber(); + } + + // ─── collect ─────────────────────────────────────────────────────── + + function collectPayload() { + const questions = $$('#questions .q-item').map((li, i) => ({ + text: $('.q-text', li).value.trim(), + question_order: i + 1, + hasMultipleAnswers: $('.q-multi', li).checked, + options: $$('.opt-item', li).map((op, j) => ({ + text: $('.opt-text', op).value.trim(), + isCorrect: $('.opt-correct', op).checked, + option_order: j + 1, + })), + })); + const payload = { + title: titleEl.value.trim() || null, + description: descEl.value.trim() || null, + questions, + }; + const t = thresholdEl.value; + if (t !== '' && Number.isFinite(Number(t))) payload.passingThreshold = Number(t); + return payload; + } + + function collectShape() { + return $$('#questions .q-item').map((li) => ({ + optionsCount: Math.max(2, $$('.opt-item', li).length || 4), + hasMultipleAnswers: $('.q-multi', li).checked, + })); + } + + // ─── actions ─────────────────────────────────────────────────────── + + $('#add-question').addEventListener('click', () => { + questionsEl.appendChild( + renderQuestion({ + text: '', + hasMultipleAnswers: false, + options: [ + { text: '', isCorrect: true }, + { text: '', isCorrect: false }, + { text: '', isCorrect: false }, + { text: '', isCorrect: false }, + ], + }), + ); + renumber(); + }); + + $('#save-draft').addEventListener('click', async () => { + saveStatusEl.textContent = 'Сохраняем…'; + try { + const r = await fetch(`/api/tests/${TEST_ID}/draft`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(collectPayload()), + }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'Не удалось сохранить.'); + if (chainActiveEl.checked !== chainActive) { + const r2 = await fetch(`/api/tests/${TEST_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chainActive: chainActiveEl.checked }), + }); + if (r2.ok) chainActive = chainActiveEl.checked; + } + saveStatusEl.textContent = data.forked + ? 'Сохранено (создана новая версия — есть попытки прохождения).' + : 'Сохранено.'; + setTimeout(() => (saveStatusEl.textContent = ''), 4000); + } catch (e) { + saveStatusEl.textContent = ''; + alert(e.message || 'Не удалось сохранить.'); + } + }); + + $('#ai-generate-test').addEventListener('click', async () => { + const shape = collectShape(); + if (!shape.length) { + alert('Сначала добавьте хотя бы один вопрос (его настройки определяют сетку).'); + return; + } + aiStatusEl.textContent = 'Генерируем…'; + try { + const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + testTitle: titleEl.value, + testDescription: descEl.value, + shape, + }), + }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'AI: ошибка.'); + const draft = data.draft; + if (draft.title) titleEl.value = draft.title; + if (draft.description) descEl.value = draft.description; + questionsEl.innerHTML = ''; + (draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); + renumber(); + aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`; + setTimeout(() => (aiStatusEl.textContent = ''), 4000); + } catch (e) { + aiStatusEl.textContent = ''; + alert(e.message || 'AI: ошибка.'); + } + }); + + // ─── импорт документа (E1.3) ─────────────────────────────────── + + $('#ai-import-file').addEventListener('change', async (ev) => { + const file = ev.target.files && ev.target.files[0]; + ev.target.value = ''; + if (!file) return; + aiStatusEl.textContent = `Загружаем «${file.name}»…`; + try { + const fd = new FormData(); + fd.append('file', file); + const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'Не удалось импортировать.'); + const g = data.generation || {}; + if (!g.available) { + aiStatusEl.textContent = ''; + const msg = g.message || 'AI недоступен.'; + const preview = (g.textPreview || data.extractedText || '').slice(0, 600); + alert(msg + (preview ? '\n\nПервые 600 символов:\n' + preview : '')); + return; + } + const ok = confirm( + `${g.message}\n\nПрименить как новый черновик?\n` + + `Текущие вопросы будут заменены.`, + ); + if (!ok) { + aiStatusEl.textContent = ''; + return; + } + const draft = g.draft; + if (draft.title) titleEl.value = draft.title; + if (draft.description) descEl.value = draft.description; + questionsEl.innerHTML = ''; + (draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); + renumber(); + aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`; + setTimeout(() => (aiStatusEl.textContent = ''), 4000); + } catch (e) { + aiStatusEl.textContent = ''; + alert(e.message || 'Не удалось импортировать.'); + } + }); + + // ─── AI v2 (E1.8): generate-by-title / check / improve ───────── + + function aiAlert(data, fallback) { + const msg = (data && data.error) || fallback || 'AI: ошибка.'; + if (data && data.settingsUrl) { + if (confirm(msg + '\n\nОткрыть Настройки?')) { + window.location.href = data.settingsUrl; + } + return; + } + alert(msg); + } + + function escHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + const modal = $('#ai-modal'); + const modalTitle = $('#ai-modal-title'); + const modalBody = $('#ai-modal-body'); + const modalActions = $('#ai-modal-actions'); + $('#ai-modal-close').addEventListener('click', () => modal.close()); + + function openModal(title, bodyHtml, actions) { + modalTitle.textContent = title; + modalBody.innerHTML = bodyHtml; + modalActions.innerHTML = ''; + (actions || []).forEach((a) => { + const b = document.createElement('button'); + b.textContent = a.label; + b.className = a.className + || 'px-3 py-2 rounded-lg bg-ink-100 hover:bg-ink-200 text-sm'; + b.addEventListener('click', () => a.onClick(b)); + modalActions.appendChild(b); + }); + modal.showModal(); + } + + $('#ai-generate-by-title').addEventListener('click', async () => { + const title = titleEl.value.trim(); + if (!title) { + alert('Сначала заполните название теста.'); + titleEl.focus(); + return; + } + const nQRaw = prompt('Сколько вопросов сгенерировать?', '10'); + if (nQRaw == null) return; + const nQ = Math.max(3, Math.min(40, parseInt(nQRaw, 10) || 10)); + const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4'); + if (nORaw == null) return; + const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4)); + aiStatusEl.textContent = 'Генерируем по названию…'; + try { + const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + testTitle: title, + testDescription: descEl.value, + questionsCount: nQ, + optionsCount: nO, + }), + }); + const data = await r.json(); + if (!r.ok) { + aiStatusEl.textContent = ''; + return aiAlert(data); + } + const draft = data.draft; + const ok = confirm( + `Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` + + 'Применить как черновик? Текущие вопросы будут заменены.', + ); + if (!ok) { + aiStatusEl.textContent = ''; + return; + } + if (draft.title) titleEl.value = draft.title; + if (draft.description) descEl.value = draft.description; + questionsEl.innerHTML = ''; + (draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); + renumber(); + aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`; + setTimeout(() => (aiStatusEl.textContent = ''), 4000); + } catch (e) { + aiStatusEl.textContent = ''; + aiAlert(null, e.message); + } + }); + + $('#ai-check').addEventListener('click', async () => { + const payload = collectPayload(); + if (!payload.questions.length) { + alert('В тесте нет вопросов — нечего проверять.'); + return; + } + aiStatusEl.textContent = 'Анализируем…'; + try { + const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + testTitle: titleEl.value, + testDescription: descEl.value, + questions: payload.questions, + }), + }); + const data = await r.json(); + aiStatusEl.textContent = ''; + if (!r.ok) return aiAlert(data); + const rev = data.review || {}; + const verdict = rev.verdict || 'warn'; + const verdictMap = { + ok: ['Годен', 'bg-green-50 text-green-800 border-green-200'], + warn: ['Есть замечания', 'bg-yellow-50 text-yellow-800 border-yellow-200'], + bad: ['Серьёзные проблемы', 'bg-red-50 text-red-800 border-red-200'], + }; + const [verdictText, verdictCls] = verdictMap[verdict] || verdictMap.warn; + let html = `
+
${verdictText}
+
${escHtml(rev.summary || '')}
`; + if (Array.isArray(rev.sections) && rev.sections.length) { + html += rev.sections.map((s) => ` +
+
${escHtml(s.title)}
+
    + ${s.items.map((it) => `
  • ${escHtml(it)}
  • `).join('')} +
+
`).join(''); + } else { + html += '

Замечаний нет.

'; + } + openModal('Проверка теста', html, [ + { label: 'Закрыть', onClick: () => modal.close(), + className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' }, + ]); + } catch (e) { + aiStatusEl.textContent = ''; + aiAlert(null, e.message); + } + }); + + $('#ai-improve').addEventListener('click', async () => { + const payload = collectPayload(); + if (!payload.questions.length) { + alert('В тесте нет вопросов — нечего улучшать.'); + return; + } + aiStatusEl.textContent = 'Улучшаем…'; + try { + const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + testTitle: titleEl.value, + testDescription: descEl.value, + questions: payload.questions, + }), + }); + const data = await r.json(); + aiStatusEl.textContent = ''; + if (!r.ok) return aiAlert(data); + const items = data.items || []; + if (!items.length) { + openModal('Улучшение теста', '

Нечего улучшать.

', [ + { label: 'Закрыть', onClick: () => modal.close() }, + ]); + return; + } + const changed = items.filter((i) => i.changed); + if (!changed.length) { + openModal('Улучшение теста', '

AI не предложил изменений.

', [ + { label: 'Закрыть', onClick: () => modal.close() }, + ]); + return; + } + let html = `

+ Отметьте вопросы, к которым нужно применить улучшения. ${changed.length} из ${items.length}.

`; + html += changed.map((it) => ` +
+ +
+
+
Было
+
+ ${escHtml(it.original.text)} +
+
    + ${it.original.options.map((o) => + `
  • + ${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}
  • `).join('')} +
+
+
+
Стало
+
+ ${escHtml(it.suggested.text)} +
+
    + ${it.suggested.options.map((o) => + `
  • ${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}
  • `).join('')} +
+
+
+
`).join(''); + openModal('Улучшение теста', html, [ + { label: 'Отмена', onClick: () => modal.close() }, + { + label: 'Применить выбранное', + className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm', + onClick: () => { + const qs = $$('#questions .q-item'); + modalBody.querySelectorAll('[data-idx]').forEach((row) => { + if (!$('.apply-q', row).checked) return; + const idx = parseInt(row.dataset.idx, 10); + const it = items.find((x) => x.index === idx); + if (!it || !qs[idx]) return; + const node = qs[idx]; + $('.q-text', node).value = it.suggested.text; + $('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers; + const optsEl = $('.q-options', node); + optsEl.innerHTML = ''; + it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o))); + }); + modal.close(); + aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.'; + setTimeout(() => (aiStatusEl.textContent = ''), 5000); + }, + }, + ]); + } catch (e) { + aiStatusEl.textContent = ''; + aiAlert(null, e.message); + } + }); + + async function aiGenerateQuestion(node) { + const qText = $('.q-text', node).value.trim(); + const optsCount = Math.max(2, $$('.opt-item', node).length || 4); + const multi = $('.q-multi', node).checked; + aiStatusEl.textContent = 'AI: один вопрос…'; + try { + const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + testTitle: titleEl.value, + testDescription: descEl.value, + questionText: qText, + optionsCount: optsCount, + hasMultipleAnswers: multi, + }), + }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'AI: ошибка.'); + $('.q-text', node).value = data.text || ''; + if (data.mode === 'full' && Array.isArray(data.options)) { + const optsEl = $('.q-options', node); + optsEl.innerHTML = ''; + data.options.forEach((o) => optsEl.appendChild(renderOption(o))); + $('.q-multi', node).checked = !!data.hasMultipleAnswers; + } + aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.'; + setTimeout(() => (aiStatusEl.textContent = ''), 4000); + } catch (e) { + aiStatusEl.textContent = ''; + alert(e.message || 'AI: ошибка.'); + } + } + + // ─── chain active state (грузим summary, чтобы знать стартовое значение) ─── + + fetch(`/api/tests/${TEST_ID}/summary`) + .then((r) => r.json()) + .then((data) => { + if (data && data.test && typeof data.test.chainActive === 'boolean') { + chainActive = data.test.chainActive; + chainActiveEl.checked = chainActive; + } else { + chainActiveEl.checked = true; + chainActive = true; + } + }) + .catch(() => { + chainActiveEl.checked = true; + }); + + loadInitial(); +})(); diff --git a/flask_app/app/templates/404.html b/flask_app/app/templates/404.html new file mode 100644 index 0000000..66c6f3f --- /dev/null +++ b/flask_app/app/templates/404.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}404 — страница не найдена{% endblock %} +{% block content %} +
+ search_off +

Страница не найдена

+

Проверьте адрес или вернитесь на главную.

+
+{% endblock %} diff --git a/flask_app/app/templates/500.html b/flask_app/app/templates/500.html new file mode 100644 index 0000000..fa5e24c --- /dev/null +++ b/flask_app/app/templates/500.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}500 — внутренняя ошибка{% endblock %} +{% block content %} +
+ error +

Что-то пошло не так

+

Попробуйте обновить страницу. Если ошибка повторяется — посмотрите логи сервера.

+
+{% endblock %} diff --git a/flask_app/app/templates/auth/login.html b/flask_app/app/templates/auth/login.html new file mode 100644 index 0000000..01763ca --- /dev/null +++ b/flask_app/app/templates/auth/login.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}Вход — Тестирование{% endblock %} + +{% block content %} +
+
+
+ login +

Вход в систему

+
+

+ Используйте логин и пароль. + {% if hr_auth_enabled %} + Учётка кадровой системы (HR). + {% endif %} +

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, msg in messages %} +
+ {{ msg }} +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ + + + + + + +
+
+
+{% endblock %} diff --git a/flask_app/app/templates/base.html b/flask_app/app/templates/base.html new file mode 100644 index 0000000..e3c40f0 --- /dev/null +++ b/flask_app/app/templates/base.html @@ -0,0 +1,111 @@ + + + + + + {% block title %}Тестирование персонала{% endblock %} + + {# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #} + + + + + + + + + + {% block head %}{% endblock %} + + +
+
+ + quiz + Тестирование + + +
+
+ +
+ {% block content %}{% endblock %} +
+ +
+ {% block footer %}testing-flask-app · Этап 1{% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/flask_app/app/templates/index.html b/flask_app/app/templates/index.html index 5686c21..df457c8 100644 --- a/flask_app/app/templates/index.html +++ b/flask_app/app/templates/index.html @@ -1,9 +1,45 @@ - - - - - - Тестирование - - - +{% extends "base.html" %} +{% block title %}Тестирование — главная{% endblock %} + +{% block content %} +
+

Сервис тестирования персонала

+

+ Этап 1 миграции: переход на единый стек (Flask + Jinja). Бизнес-функции + переносятся последовательно — авторизация, каталог тестов, редактор, + назначения, прохождение, импорт/AI. +

+ + +
+ +
+ {% for title, descr, icon in [ + ('Авторизация', 'E1.1 — логин по HR/локальному пользователю.', 'login'), + ('Тесты', 'E1.2 — каталог, фильтры, карточки.', 'list_alt'), + ('Редактор', 'E1.3 — создание/правка теста, AI-помощник.', 'edit_note'), + ('Назначения', 'E1.4 — назначить сотрудникам, отслеживать.', 'assignment'), + ('Прохождение', 'E1.5 — UI прохождения теста сотрудником.', 'fact_check'), + ('Импорт/AI', 'E1.6 — генерация черновиков из документов.', 'auto_awesome'), + ] %} +
+
+ {{ icon }} +

{{ title }}

+
+

{{ descr }}

+
+ {% endfor %} +
+{% endblock %} diff --git a/flask_app/app/templates/settings.html b/flask_app/app/templates/settings.html new file mode 100644 index 0000000..39cc89d --- /dev/null +++ b/flask_app/app/templates/settings.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} +{% block title %}Настройки — LLM{% endblock %} + +{% block content %} +
+
+ settings +

Настройки

+
+ +

Подключение к LLM

+

+ Ключ задаётся в .env сервера + (общий, не на пользователя). Поддерживаются DeepSeek и OpenAI-совместимые API. + После изменения .env нужен рестарт процесса. +

+ +
+
Статус ключа
+
+ {% if configured %} + + check_circle Задан + + {% else %} + + error Не задан + + {% endif %} +
+
Провайдер
+
{{ provider or '—' }}
+
Модель
+
{{ model or '—' }}
+
Base URL
+
{{ base_url or '—' }}
+
+ + {% if not configured %} +
+

Как задать ключ

+
DEEPSEEK_API_KEY=sk-...
+# либо
+OPENAI_API_KEY=sk-...
+# опционально:
+# LLM_BASE_URL=https://api.deepseek.com/v1
+# LLM_MODEL=deepseek-chat
+

+ Файл: flask_app/.env. После сохранения — рестарт процесса. +

+
+ {% endif %} + +
+ + +
+ + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/flask_app/app/templates/tests/editor.html b/flask_app/app/templates/tests/editor.html new file mode 100644 index 0000000..301b726 --- /dev/null +++ b/flask_app/app/templates/tests/editor.html @@ -0,0 +1,191 @@ +{% extends "base.html" %} +{% block title %}{{ content.test.title }} — редактор{% endblock %} + +{% block content %} +
+ +
+
+
+ + +
+
+ +
+
+
+ + +
+
+ auto_awesome +

AI-помощник

+
+

+ Сгенерировать вопросы по текущей сетке (число вопросов и вариантов берётся из таблицы ниже). +

+
+ + + + + + +
+

+ Поддерживаются PDF, DOCX, TXT, MD (до 16 МБ). AI извлечёт текст и предложит черновик теста. +

+
+ + +
+
+

Вопросы (0)

+ +
+
    +
    + + +
    + +
    + + К каталогу + +
    +
    +
    + + + + + + +
    +
    +

    AI

    + +
    +
    +
    +
    +
    + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/flask_app/app/templates/tests/list.html b/flask_app/app/templates/tests/list.html new file mode 100644 index 0000000..8f5a709 --- /dev/null +++ b/flask_app/app/templates/tests/list.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} +{% block title %}Тесты — каталог{% endblock %} + +{% block content %} +
    +
    +
    +

    Каталог тестов

    +

    Активные тесты, к которым у вас есть доступ.

    +
    + +
    + + {% if visible %} +
      + {% for t in visible %} +
    • +
      +

      {{ t.title }}

      + v{{ t.version }} +
      + {% if t.description %} +

      {{ t.description }}

      + {% endif %} +

      Автор: {{ t.author_full_name or '—' }}

      + +
    • + {% endfor %} +
    + {% else %} +

    Доступных тестов пока нет.

    + {% endif %} + + {% if hidden %} +
    + + Скрытые вами цепочки ({{ hidden|length }}) + +
      + {% for t in hidden %} +
    • + {{ t.title }} · v{{ t.version }} + Открыть +
    • + {% endfor %} +
    +
    + {% endif %} +
    + + +
    +
    +

    Новый тест

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