From b72b485fce8cabb487712da2783ca3d1c2ab3921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D0=BD=20=D0=9B=D0=B5=D0=B1=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9?= Date: Thu, 30 Apr 2026 19:53:49 +0500 Subject: [PATCH] UI bugfixes with boss --- .DS_Store | Bin 10244 -> 10244 bytes flask_app/.env.example | 5 + flask_app/app/services/ai_editor.py | 11 +- flask_app/app/services/document_gen.py | 2 + flask_app/app/services/draft_validator.py | 13 + flask_app/app/services/editor_content.py | 4 +- flask_app/app/services/test_access.py | 66 ++++- flask_app/app/services/test_attempt.py | 11 +- flask_app/app/services/test_draft.py | 4 +- flask_app/app/static/css/app.css | 48 ++- flask_app/app/static/js/attempt.js | 79 +++-- flask_app/app/static/js/editor.js | 279 ++++++++++++------ flask_app/app/templates/tests/attempt.html | 12 - .../app/templates/tests/attempt_review.html | 8 + flask_app/app/templates/tests/editor.html | 131 ++++---- flask_app/app/templates/tests/list.html | 26 +- flask_app/app/tests/routes.py | 18 +- 17 files changed, 468 insertions(+), 249 deletions(-) diff --git a/.DS_Store b/.DS_Store index c1ca77eba9d7037ff1628bb4d3e1b675cce19571..9dd0c902985ef6458abb2b2720cf8a3fb75de02b 100644 GIT binary patch delta 32 ocmZn(XbG6$&&aVcU^hP_$7UXZJkHHiC5zZ5Hb`$~SNO{g0IygIjsO4v delta 53 zcmZn(XbG6$&&atkU^hP_=Vl&(JWdIAhGK?fh9ZVkAk1SZPAN{#Ny^X9Vcb|)%C?zZ H;V(M?lQIvD diff --git a/flask_app/.env.example b/flask_app/.env.example index 76009d5..636715e 100644 --- a/flask_app/.env.example +++ b/flask_app/.env.example @@ -23,3 +23,8 @@ DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests # (Werkzeug-хеш в public.users.password по web_login = username). # HR_AUTH=1 # HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test + +# ─── Редактирование тестов (до внедрения RBAC) ─────────────────── +# По умолчанию любой залогиненный пользователь может править любой тест. +# Чтобы снова ограничить сохранение/ИИ/версии только автором цепочки: +# CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR=1 diff --git a/flask_app/app/services/ai_editor.py b/flask_app/app/services/ai_editor.py index 2abc957..b1ab4a2 100644 --- a/flask_app/app/services/ai_editor.py +++ b/flask_app/app/services/ai_editor.py @@ -6,6 +6,7 @@ from typing import Any from .draft_validator import ( assert_draft_matches_shape, parse_json_from_llm_text, + shuffle_options_in_questions, validate_and_normalize_draft, ) from .llm_client import LlmError, chat_completion_text_content, get_llm_config @@ -103,6 +104,7 @@ def generate_full_test_by_shape(test_title: str, test_description: str, shape: l parsed = parse_json_from_llm_text(raw) draft = validate_and_normalize_draft(parsed) assert_draft_matches_shape({'questions': draft['questions']}, shape) + shuffle_options_in_questions(draft['questions']) return { 'title': draft['title'], 'description': draft['description'], @@ -153,6 +155,7 @@ def generate_test_by_title( raw = chat_completion_text_content(cfg, system, user, 0.45) parsed = parse_json_from_llm_text(raw) draft = validate_and_normalize_draft(parsed) + shuffle_options_in_questions(draft['questions']) return { 'title': draft['title'], 'description': draft['description'], @@ -475,9 +478,11 @@ def generate_or_rephrase_question( shape = [{'optionsCount': n, 'hasMultipleAnswers': bool(has_multiple_answers)}] assert_draft_matches_shape({'questions': [parsed]}, shape) draft = validate_and_normalize_draft({'title': 'временно', 'questions': [parsed]}) + q0 = draft['questions'][0] + shuffle_options_in_questions([q0]) return { 'mode': 'full', - 'text': draft['questions'][0]['text'], - 'hasMultipleAnswers': draft['questions'][0]['hasMultipleAnswers'], - 'options': draft['questions'][0]['options'], + 'text': q0['text'], + 'hasMultipleAnswers': q0['hasMultipleAnswers'], + 'options': q0['options'], } diff --git a/flask_app/app/services/document_gen.py b/flask_app/app/services/document_gen.py index 8d8e260..3985c80 100644 --- a/flask_app/app/services/document_gen.py +++ b/flask_app/app/services/document_gen.py @@ -4,6 +4,7 @@ from __future__ import annotations from .draft_validator import ( normalize_draft_to_shape, parse_json_from_llm_text, + shuffle_options_in_questions, validate_and_normalize_draft, ) from .llm_client import LlmError, chat_completion_text_content, get_llm_config @@ -64,6 +65,7 @@ def generation_for_import_document(extracted_text: str, user_hint: str = '', sha draft = validate_and_normalize_draft(parsed) if shape: draft = normalize_draft_to_shape(draft, shape) + shuffle_options_in_questions(draft['questions']) return { 'available': True, 'message': ( diff --git a/flask_app/app/services/draft_validator.py b/flask_app/app/services/draft_validator.py index dd3b20a..d7c1989 100644 --- a/flask_app/app/services/draft_validator.py +++ b/flask_app/app/services/draft_validator.py @@ -2,6 +2,7 @@ from __future__ import annotations import json as _json +import random import re from typing import Any @@ -79,6 +80,18 @@ def validate_and_normalize_draft(o: Any) -> dict: return {'title': title, 'description': description, 'questions': questions} +def shuffle_options_in_questions(questions: list[dict] | None) -> None: + """Случайный порядок вариантов в каждом вопросе; isCorrect остаётся у соответствующего текста.""" + if not questions: + return + for q in questions: + if not isinstance(q, dict): + continue + opts = q.get('options') + if isinstance(opts, list) and len(opts) >= 2: + random.shuffle(opts) + + def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None: """Проверяет, что число вопросов и вариантов = ровно как в shape.""" qs = o.get('questions') if isinstance(o, dict) else None diff --git a/flask_app/app/services/editor_content.py b/flask_app/app/services/editor_content.py index 1b92184..4009b2a 100644 --- a/flask_app/app/services/editor_content.py +++ b/flask_app/app/services/editor_content.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload from ..db import get_session from ..messages import RU from ..models import AnswerOption, Question, Test, TestVersion -from .test_access import is_test_author +from .test_access import is_test_author, is_test_edit_open from .test_chain import has_any_attempt_for_test @@ -67,7 +67,7 @@ def get_editor_content(user_id: str, test_id: str) -> dict: test = session.get(Test, tid) if not test: raise HttpError(404, 'Тест не найден.') - if not is_test_author(test.created_by, user_id): + if not is_test_author(test.created_by, user_id) and not is_test_edit_open(): raise HttpError(403, 'Доступ запрещён.') active_version = ( diff --git a/flask_app/app/services/test_access.py b/flask_app/app/services/test_access.py index 95d7954..3f2742c 100644 --- a/flask_app/app/services/test_access.py +++ b/flask_app/app/services/test_access.py @@ -1,12 +1,26 @@ """Кто видит тест: автор + назначенные пользователи.""" from __future__ import annotations +import os from dataclasses import dataclass -from sqlalchemy import exists, select +from sqlalchemy import exists, func from ..db import get_session -from ..models import Test, TestAssignment, TestAssignmentTarget, TestAttempt, TestVersion, User +from ..models import Question, Test, TestAssignment, TestAssignmentTarget, TestAttempt, TestVersion, User + + +def _truthy_env(val: str | None) -> bool: + return (val or '').strip().lower() in ('1', 'true', 'yes', 'on') + + +def is_test_edit_open() -> bool: + """Пока без RBAC: любой залогиненный пользователь может править любой тест. + + Задайте ``CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR=1``, чтобы снова требовать роль автора + для редактирования, подсказок, списка попыток и разбора чужих попыток. + """ + return not _truthy_env(os.environ.get('CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR')) def is_test_author(created_by, user_id) -> bool: @@ -64,10 +78,20 @@ def list_visible_tests(user_id: str) -> list[dict]: except (ValueError, AttributeError): uid = None + qcount_sq = ( + session.query( + Question.test_version_id.label('tv_id'), + func.count(Question.id).label('qc'), + ) + .group_by(Question.test_version_id) + .subquery() + ) + rows = ( - session.query(Test, TestVersion, User) + session.query(Test, TestVersion, User, qcount_sq.c.qc) .join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True)) .outerjoin(User, User.id == Test.created_by) + .outerjoin(qcount_sq, qcount_sq.c.tv_id == TestVersion.id) .filter(Test.is_active.is_(True)) .order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc()) .all() @@ -85,6 +109,11 @@ def list_visible_tests(user_id: str) -> list[dict]: 'version': tv.version, 'created_by': str(t.created_by) if t.created_by else None, 'author_full_name': u.full_name if u else '—', + 'passing_threshold': int(t.passing_threshold or 0), + 'time_limit': t.time_limit, + 'result_mode': (t.result_mode or 'end'), + 'hints_enabled': bool(t.hints_enabled), + 'questions_count': int(qc or 0), 'has_in_progress_attempt': bool( uid and session.query( exists().where( @@ -96,7 +125,7 @@ def list_visible_tests(user_id: str) -> list[dict]: ).scalar() ), } - for t, tv, u in rows + for t, tv, u, qc in rows ] @@ -108,10 +137,20 @@ def list_hidden_by_author(user_id: str) -> list[dict]: except (ValueError, AttributeError): return [] + qcount_sq = ( + session.query( + Question.test_version_id.label('tv_id'), + func.count(Question.id).label('qc'), + ) + .group_by(Question.test_version_id) + .subquery() + ) + rows = ( - session.query(Test, TestVersion, User) + session.query(Test, TestVersion, User, qcount_sq.c.qc) .join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True)) .join(User, User.id == Test.created_by) + .outerjoin(qcount_sq, qcount_sq.c.tv_id == TestVersion.id) .filter(Test.is_active.is_(False), Test.created_by == uid) .order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc()) .all() @@ -129,6 +168,21 @@ def list_hidden_by_author(user_id: str) -> list[dict]: 'version': tv.version, 'created_by': str(t.created_by), 'author_full_name': u.full_name, + 'passing_threshold': int(t.passing_threshold or 0), + 'time_limit': t.time_limit, + 'result_mode': (t.result_mode or 'end'), + 'hints_enabled': bool(t.hints_enabled), + 'questions_count': int(qc or 0), + 'has_in_progress_attempt': bool( + session.query( + exists().where( + TestAttempt.user_id == uid, + TestAttempt.status == 'in_progress', + TestAttempt.test_version_id == TestVersion.id, + TestVersion.test_id == t.id, + ) + ).scalar() + ), } - for t, tv, u in rows + for t, tv, u, qc in rows ] diff --git a/flask_app/app/services/test_attempt.py b/flask_app/app/services/test_attempt.py index 1a79e5b..e0202b0 100644 --- a/flask_app/app/services/test_attempt.py +++ b/flask_app/app/services/test_attempt.py @@ -17,7 +17,7 @@ from ..models import ( User, UserAnswer, ) -from .test_access import is_test_author, user_has_test_access +from .test_access import is_test_author, is_test_edit_open, user_has_test_access class HttpError(Exception): @@ -311,6 +311,9 @@ def build_review_from_db(session: Session, attempt_id: str) -> dict: 'testId': str(test.id), 'testTitle': test.title, 'passingThreshold': int(test.passing_threshold or 0), + 'timeLimit': test.time_limit, + 'resultMode': test.result_mode or 'end', + 'hintsEnabled': bool(test.hints_enabled), 'correctCount': int(attempt.correct_count or 0), 'totalQuestions': total, 'percent': percent, @@ -343,7 +346,7 @@ def get_attempt_review_for_user(session_or_eng, current_user_id: str, test_id: s is_owner = str(attempt.user_id) == str(current_user_id) is_author = is_test_author(attempt.test_version.test.created_by, current_user_id) - if not is_owner and not is_author: + if not is_owner and not is_author and not is_test_edit_open(): raise HttpError(403, 'Доступ запрещён.') return build_review_from_db(session, attempt_id) @@ -387,7 +390,7 @@ def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str test = session.get(Test, tid) if not test: raise HttpError(404, 'Тест не найден.') - if not is_test_author(test.created_by, author_id): + if not is_test_author(test.created_by, author_id) and not is_test_edit_open(): raise HttpError(403, 'Доступ запрещён.') active_version = ( @@ -507,7 +510,7 @@ def list_test_attempts_for_author(session_or_eng, author_id: str, test_id: str) test = session.get(Test, tid) if not test: raise HttpError(404, 'Тест не найден.') - if not is_test_author(test.created_by, author_id): + if not is_test_author(test.created_by, author_id) and not is_test_edit_open(): raise HttpError(403, 'Доступ запрещён.') rows = ( diff --git a/flask_app/app/services/test_draft.py b/flask_app/app/services/test_draft.py index 1e57714..6b43c46 100644 --- a/flask_app/app/services/test_draft.py +++ b/flask_app/app/services/test_draft.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from ..db import get_session from ..messages import RU from ..models import AnswerOption, Question, Test, TestVersion -from .test_access import is_test_author +from .test_access import is_test_author, is_test_edit_open from .test_chain import has_any_attempt_for_test @@ -157,7 +157,7 @@ def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict: test = session.get(Test, tid) if not test: raise HttpError(404, 'Тест не найден.') - if not is_test_author(test.created_by, author_id): + if not is_test_author(test.created_by, author_id) and not is_test_edit_open(): raise HttpError(403, 'Доступ запрещён.') if payload.get('title') is not None: diff --git a/flask_app/app/static/css/app.css b/flask_app/app/static/css/app.css index 135981a..eee1ea9 100644 --- a/flask_app/app/static/css/app.css +++ b/flask_app/app/static/css/app.css @@ -1234,6 +1234,41 @@ body.ui-legacy .attempts-card-list__action { color: #b42318; } +.attempt-feedback-panel { + margin-top: 1rem; + padding: 0.85rem 1rem; + border-radius: 0.85rem; + border: 1px solid color-mix(in srgb, var(--outline-variant) 55%, transparent); + background: color-mix(in srgb, var(--surface-container-low) 88%, var(--surface)); +} + +.attempt-feedback-verdict { + font-weight: 700; + margin: 0 0 0.35rem; + font-size: 0.95rem; +} + +.attempt-feedback-verdict--ok { + color: var(--primary); +} + +.attempt-feedback-verdict--bad { + color: #b42318; +} + +.attempt-feedback-correct { + margin: 0 0 0.5rem; + font-size: 0.88rem; + color: var(--on-surface-variant); +} + +.attempt-feedback-explanation { + margin: 0; + font-size: 0.9rem; + line-height: 1.45; + color: var(--on-surface); +} + .attempt-answer-actions { margin-top: 1.25rem; padding-top: 0.25rem; @@ -1309,19 +1344,6 @@ body.ui-legacy .attempts-card-list__action { color: #b42318; } -.attempt-hint-verdict { - font-weight: 700; - margin-bottom: 0.35rem; -} - -.attempt-hint-verdict--ok { - color: var(--primary); -} - -.attempt-hint-verdict--bad { - color: #b42318; -} - body.ui-modern .attempt-flow { min-height: min(75dvh, 880px); } diff --git a/flask_app/app/static/js/attempt.js b/flask_app/app/static/js/attempt.js index bc2a12f..78404b6 100644 --- a/flask_app/app/static/js/attempt.js +++ b/flask_app/app/static/js/attempt.js @@ -22,12 +22,6 @@ const btnFinish = document.getElementById('attempt-finish'); const resultEl = document.getElementById('attempt-result'); - const hintModal = document.getElementById('hint-modal'); - const hintVerdict = document.getElementById('hint-verdict'); - const hintCorrect = document.getElementById('hint-correct'); - const hintExplanation = document.getElementById('hint-explanation'); - const hintCloseBtn = document.getElementById('hint-close-btn'); - let playData = null; const selections = {}; const checked = {}; @@ -65,6 +59,41 @@ return playData && playData.resultMode === 'immediate'; } + function formatAttemptMeta(d) { + const qs = d.questions || []; + const n = qs.length; + const tl = d.timeLimit; + const timestr = !tl || Number(tl) <= 0 ? 'без ограничения' : `${tl} мин`; + const res = d.resultMode === 'immediate' ? 'сразу' : 'в конце'; + const hint = d.resultMode !== 'immediate' + ? 'недоступны' + : (d.hintsEnabled ? 'вкл' : 'выкл'); + const th = d.passingThreshold ?? 0; + return `Порог: ${th}% · Вопросов: ${n} · Время: ${timestr} · Результат: ${res} · Подсказки: ${hint}`; + } + + function renderFeedbackPanel(data) { + const wrap = document.createElement('div'); + wrap.className = 'attempt-feedback-panel'; + const ok = data.isCorrect; + const verdict = document.createElement('p'); + verdict.className = `attempt-feedback-verdict ${ok ? 'attempt-feedback-verdict--ok' : 'attempt-feedback-verdict--bad'}`; + verdict.textContent = ok ? 'Верно!' : 'Неверно.'; + wrap.appendChild(verdict); + const correct = (data.correctOptionTexts || []).join('; '); + if (correct) { + const p = document.createElement('p'); + p.className = 'attempt-feedback-correct'; + p.textContent = `Правильный ответ: ${correct}`; + wrap.appendChild(p); + } + const exp = document.createElement('p'); + exp.className = 'attempt-feedback-explanation'; + exp.textContent = data.explanation || 'Объяснение недоступно.'; + wrap.appendChild(exp); + return wrap; + } + function stepAnswered(q) { const k = String(q.id); if (isImmediate()) return !!checked[k]; @@ -151,6 +180,10 @@ } card.appendChild(ul); + if (isChk && isImmediate() && playData.hintsEnabled) { + card.appendChild(renderFeedbackPanel(checked[qid])); + } + if (isImmediate() && !isChk) { const wrap = document.createElement('div'); wrap.className = 'attempt-answer-actions'; @@ -204,29 +237,11 @@ if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.'); checked[k] = data; renderStep(); - if (playData.hintsEnabled) { - showHint(data); - } } catch (e) { setErr(e.message); } } - function showHint(data) { - hintVerdict.textContent = data.isCorrect ? 'Верно!' : 'Неверно.'; - hintVerdict.className = `attempt-hint-verdict ${data.isCorrect ? 'attempt-hint-verdict--ok' : 'attempt-hint-verdict--bad'}`; - const correct = (data.correctOptionTexts || []).join('; '); - hintCorrect.textContent = correct ? (`Правильный ответ: ${correct}`) : ''; - hintExplanation.textContent = data.explanation || 'Объяснение недоступно.'; - if (typeof hintModal.showModal === 'function') hintModal.showModal(); - else hintModal.setAttribute('open', ''); - } - - hintCloseBtn.addEventListener('click', () => { - if (typeof hintModal.close === 'function') hintModal.close(); - else hintModal.removeAttribute('open'); - }); - btnPrev.addEventListener('click', () => { if (currentIdx <= 0) return; currentIdx -= 1; @@ -243,9 +258,14 @@ btnFinish.addEventListener('click', () => submit(false)); function startTimer(minutes) { - if (!minutes || minutes <= 0) return; - deadlineMs = Date.now() + minutes * 60 * 1000; + if (!timerEl) return; timerEl.style.display = ''; + if (!minutes || minutes <= 0) { + deadlineMs = null; + timerEl.textContent = 'без ограничения'; + return; + } + deadlineMs = Date.now() + minutes * 60 * 1000; const tick = () => { const left = Math.max(0, deadlineMs - Date.now()); const m = Math.floor(left / 60000); @@ -267,10 +287,7 @@ if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.'); playData = data; titleEl.textContent = data.testTitle || 'Прохождение теста'; - const parts = [`Порог зачёта ${data.passingThreshold ?? 0}%`]; - if (data.resultMode === 'immediate') parts.push('обратная связь после каждого ответа'); - if (data.hintsEnabled) parts.push('с подсказками'); - subEl.textContent = parts.join(' · '); + subEl.textContent = formatAttemptMeta(data); if (!Array.isArray(data.questions) || !data.questions.length) { setErr('В активной версии нет вопросов.'); btnNext.disabled = true; @@ -279,7 +296,7 @@ } currentIdx = 0; renderStep(); - if (data.timeLimit) startTimer(Number(data.timeLimit)); + startTimer(Number(data.timeLimit)); } catch (e) { setErr(e.message); btnFinish.disabled = true; diff --git a/flask_app/app/static/js/editor.js b/flask_app/app/static/js/editor.js index f007fb2..ae33e0d 100644 --- a/flask_app/app/static/js/editor.js +++ b/flask_app/app/static/js/editor.js @@ -34,9 +34,6 @@ const templateGlobalMultiEl = $('#template-global-multi'); const templateMinCorrectEl = $('#template-min-correct'); const templateMaxCorrectEl = $('#template-max-correct'); - const hintsStatusEl = $('#hints-status'); - const hintsActionsEl = $('#test-hints-actions'); - const generateHintsBtn = $('#btn-generate-hints'); const docProgressEl = $('#doc-progress'); const introUpdatedEl = $('#intro-updated'); const introForkBannerEl = $('#intro-fork-banner'); @@ -365,14 +362,36 @@ m.textContent = v; } + function syncEditorHeroExtra() { + const timeVal = document.getElementById('editor-hero-time-val'); + const resVal = document.getElementById('editor-hero-result-val'); + const hintsVal = document.getElementById('editor-hero-hints-val'); + if (!timeVal || !resVal || !hintsVal) return; + const tlEl = document.getElementById('test-time-limit'); + const raw = tlEl && tlEl.value !== '' ? Number(tlEl.value) : 0; + timeVal.textContent = (!raw || raw <= 0) ? 'без ограничения' : `${raw} мин`; + const mode = document.querySelector('input[name="result-mode"]:checked'); + const imm = mode && mode.value === 'immediate'; + resVal.textContent = imm ? 'сразу' : 'в конце'; + const hintsCheckbox = document.getElementById('test-hints-enabled'); + if (!imm) hintsVal.textContent = 'недоступны'; + else if (hintsCheckbox && hintsCheckbox.checked) hintsVal.textContent = 'вкл'; + else hintsVal.textContent = 'выкл'; + } + + function syncHeroMetaRow() { + syncThresholdMirror(); + syncEditorHeroExtra(); + } + function loadInitial() { titleEl.value = initial.test.title || ''; descEl.value = initial.test.description || ''; autoResize(titleEl); autoResize(descEl); if (thresholdEl) { - thresholdEl.addEventListener('input', syncThresholdMirror); - thresholdEl.addEventListener('change', syncThresholdMirror); + thresholdEl.addEventListener('input', syncHeroMetaRow); + thresholdEl.addEventListener('change', syncHeroMetaRow); } if (titleEl && titleEl.tagName === 'TEXTAREA') { titleEl.addEventListener('input', () => { @@ -396,7 +415,7 @@ if (docUserHint) docUserHint.addEventListener('input', () => autoResize(docUserHint)); thresholdEl.value = initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold); - syncThresholdMirror(); + syncHeroMetaRow(); const timeLimitEl = document.getElementById('test-time-limit'); const hintsEl = document.getElementById('test-hints-enabled'); @@ -405,7 +424,10 @@ if (timeLimitEl) { timeLimitEl.value = initial.test.timeLimit == null ? '' : Number(initial.test.timeLimit); - timeLimitEl.addEventListener('input', scheduleDirtyCheck); + timeLimitEl.addEventListener('input', () => { + scheduleDirtyCheck(); + syncEditorHeroExtra(); + }); } const initMode = (initial.test.resultMode === 'immediate') ? 'immediate' : 'end'; resultModeRadios.forEach((r) => { @@ -414,8 +436,8 @@ const mode = document.querySelector('input[name="result-mode"]:checked'); const isImmediate = mode && mode.value === 'immediate'; if (hintsRow) hintsRow.style.display = isImmediate ? '' : 'none'; - if (hintsActionsEl) hintsActionsEl.style.display = (isImmediate && hintsEl && hintsEl.checked) ? '' : 'none'; if (hintsEl && !isImmediate) hintsEl.checked = false; + syncHeroMetaRow(); scheduleDirtyCheck(); }); }); @@ -424,12 +446,11 @@ hintsEl.addEventListener('change', () => { const mode = document.querySelector('input[name="result-mode"]:checked'); const isImmediate = mode && mode.value === 'immediate'; - if (hintsActionsEl) hintsActionsEl.style.display = (isImmediate && hintsEl.checked) ? '' : 'none'; + syncHeroMetaRow(); scheduleDirtyCheck(); }); } if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : 'none'; - if (hintsActionsEl) hintsActionsEl.style.display = (initMode === 'immediate' && hintsEl && hintsEl.checked) ? '' : 'none'; questionsEl.innerHTML = ''; (initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); @@ -519,6 +540,60 @@ return data; } + function applyServerTestState(test) { + if (!test || typeof test !== 'object') return; + if (typeof test.title === 'string' && titleEl) titleEl.value = test.title; + if (typeof test.description === 'string' && descEl) descEl.value = test.description; + if (thresholdEl && test.passingThreshold != null) { + thresholdEl.value = Number(test.passingThreshold); + } + + const timeLimitEl = document.getElementById('test-time-limit'); + if (timeLimitEl) timeLimitEl.value = test.timeLimit == null ? '' : Number(test.timeLimit); + + const hintsEl = document.getElementById('test-hints-enabled'); + const hintsRow = document.getElementById('test-hints-row'); + const mode = (test.resultMode === 'immediate') ? 'immediate' : 'end'; + const modeEl = document.querySelector(`input[name="result-mode"][value="${mode}"]`); + if (modeEl) modeEl.checked = true; + if (hintsRow) hintsRow.style.display = (mode === 'immediate') ? '' : 'none'; + if (hintsEl) { + hintsEl.checked = !!test.hintsEnabled && mode === 'immediate'; + } + + autoResize(titleEl); + autoResize(descEl); + syncHeroMetaRow(); + } + + async function refreshMetaAfterSave() { + const [v, s, e] = await Promise.all([ + fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null), + fetch(`/api/tests/${TEST_ID}/summary`).then((r) => r.json()).catch(() => null), + fetch(`/api/tests/${TEST_ID}/editor`).then((r) => r.json()).catch(() => null), + ]); + + if (v && Array.isArray(v.versions)) { + renderVersions(v.versions); + hasForkRisk = hasForkRisk || (v.versions.length > 1); + if (typeof v.hasAttempts === 'boolean') { + hasAnyAttempts = hasAnyAttempts || v.hasAttempts; + hasForkRisk = hasForkRisk || v.hasAttempts; + } + } + if (s && s.test) { + if (introUpdatedEl) introUpdatedEl.textContent = fmtDt(s.test.updated_at || s.test.updatedAt); + const versionEl = document.getElementById('intro-version'); + if (versionEl && s.test.version != null) versionEl.textContent = s.test.version; + if (typeof s.test.hasAttempts === 'boolean') { + hasAnyAttempts = hasAnyAttempts || s.test.hasAttempts; + hasForkRisk = hasForkRisk || s.test.hasAttempts; + } + } + if (e && e.test) applyServerTestState(e.test); + updateForkBanner(); + } + async function refreshHintsInForm() { const r = await fetch(`/api/tests/${TEST_ID}/editor`); const data = await r.json().catch(() => ({})); @@ -572,6 +647,7 @@ }); if (r2.ok) chainActive = chainActiveEl.checked; } + await refreshMetaAfterSave(); resetBaselineDraft(); const msg = data.forked ? 'Сохранено. Создана новая версия — у теста есть попытки прохождения.' @@ -587,23 +663,36 @@ const sr = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`); const st = await sr.json().catch(() => ({})); if (sr.ok && Number(st.missing) > 0) { - saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`; - const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' }); - const gd = await gr.json().catch(() => ({})); - if (!gr.ok) { - saveStatusEl.textContent = ''; - alert(gd.error || 'Не удалось сгенерировать подсказки.'); - if (saveMsg) saveMsg.textContent = msg + ' (часть подсказок не создана)'; - if (saveModal) saveModal.showModal(); - return; + const okGen = confirm( + `Подсказок пока нет или заполнены не все: не хватает ${st.missing} из ${st.total}.\n\n` + + 'Сгенерировать недостающие подсказки через ИИ?', + ); + if (okGen) { + saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`; + const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' }); + const gd = await gr.json().catch(() => ({})); + if (!gr.ok) { + saveStatusEl.textContent = ''; + alert(gd.error || 'Не удалось сгенерировать подсказки.'); + if (saveMsg) saveMsg.textContent = msg + ' (подсказки не созданы)'; + if (saveModal) saveModal.showModal(); + return; + } + const skipped = Number(gd.skipped || 0); + const tail = gd.failed + ? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.` + : ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`; + if (saveMsg) saveMsg.textContent = msg + tail; + try { + await refreshHintsInForm(); + } catch (_) { + /* не блокируем успех сохранения */ + } + } else if (saveMsg) { + saveMsg.textContent = msg; } - const skipped = Number(gd.skipped || 0); - const tail = gd.failed - ? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.` - : ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`; - if (saveMsg) saveMsg.textContent = msg + tail; - } else { - if (saveMsg) saveMsg.textContent = msg; + } else if (saveMsg) { + saveMsg.textContent = msg; } } catch (err) { if (saveMsg) saveMsg.textContent = msg + ' (ИИ-подсказки не созданы)'; @@ -972,18 +1061,6 @@ const cancelBtn = document.getElementById('btn-cancel'); if (cancelBtn) cancelBtn.addEventListener('click', doCancel); - const cancelBtnInline = document.getElementById('btn-cancel-inline'); - if (cancelBtnInline) cancelBtnInline.addEventListener('click', doCancel); - - // Кнопка «Сохранить» под вопросами — дублирует основную - const saveDraftInlineBtn = document.getElementById('save-draft-inline'); - const saveStatusInlineEl = document.getElementById('save-status-inline'); - if (saveDraftInlineBtn) { - saveDraftInlineBtn.addEventListener('click', () => { - document.getElementById('save-draft')?.click(); - }); - } - function openModal(title, bodyHtml, actions) { modalTitle.textContent = title; modalBody.innerHTML = bodyHtml; @@ -1205,6 +1282,19 @@ } }); + /** Перемешивает строки вариантов в DOM; чекбоксы «верный» остаются при своих полях. */ + function shuffleQuestionOptionsDom(qNode) { + const optsEl = $('.q-options', qNode); + if (!optsEl) return; + const rows = Array.from(optsEl.querySelectorAll('.opt-item')); + if (rows.length < 2) return; + for (let i = rows.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [rows[i], rows[j]] = [rows[j], rows[i]]; + } + rows.forEach((el) => optsEl.appendChild(el)); + } + async function aiGenerateQuestion(node) { const qTextEl = $('.q-text', node); const qText = qTextEl.value.trim(); @@ -1278,6 +1368,7 @@ dIdx++; } }); + shuffleQuestionOptionsDom(node); } syncOptionInputTypes(node); @@ -1507,73 +1598,69 @@ }); } - // ─── Создать шаблон ──────────────────────────────────────────── - const createTemplateBtn = $('#create-template'); - if (createTemplateBtn) { - createTemplateBtn.addEventListener('click', () => { - const qCount = Math.min(30, Math.max(1, parseInt($('#ai-q-count').value || '7', 10))); - const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt($('#ai-o-count').value || '3', 10))); - const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked); - const range = getTemplateCorrectRange(oCount, globalMulti); - const existing = $$('#questions .q-item').length; - if (existing > 0) { - const ok = confirm( - `Создать шаблон: ${qCount} вопросов × ${oCount} вариантов?\n` + - 'Текущие вопросы будут заменены.' - ); - if (!ok) return; - } - questionsEl.innerHTML = ''; - for (let qi = 0; qi < qCount; qi++) { - const opts = []; - for (let oi = 0; oi < oCount; oi++) { - opts.push({ text: '', isCorrect: oi < range.minCorrect }); - } - questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: globalMulti, options: opts })); - } - renumber(); - scheduleDirtyCheck(); - // Прокручиваем к первому вопросу - questionsEl.firstElementChild?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // ─── Автосоздание шаблона ────────────────────────────────────── + let templateRebuildTimer = null; + let lastAppliedTemplateKey = ''; + + function hasMeaningfulQuestions() { + return $$('#questions .q-item').some((node) => { + const qText = ($('.q-text', node)?.value || '').trim(); + if (qText) return true; + return $$('.opt-text', node).some((o) => (o.value || '').trim()); }); } - async function generateHintsForCurrentTest() { - if (!generateHintsBtn) return; - generateHintsBtn.disabled = true; - if (hintsStatusEl) hintsStatusEl.textContent = 'Сохраняем текущие изменения…'; - try { - await saveCurrentDraftQuietly(); - if (hintsStatusEl) hintsStatusEl.textContent = 'Генерируем подсказки…'; - const r = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' }); - const data = await r.json().catch(() => ({})); - if (!r.ok) throw new Error(data.error || 'Не удалось сгенерировать подсказки.'); - try { - await refreshHintsInForm(); - } catch (_) { - // Статус покажем как успешный; пользователь может перезагрузить страницу. - } - const skipped = Number(data.skipped || 0); - if (hintsStatusEl) { - hintsStatusEl.textContent = data.failed - ? `Создано ${data.generated}, ошибок ${data.failed}${skipped ? `, пропущено ${skipped}` : ''}.` - : `Подсказки созданы: ${data.generated}${skipped ? `, пропущено ${skipped}` : ''}.`; + function buildTemplateFromControls({ askConfirm } = { askConfirm: true }) { + const qCount = Math.min(30, Math.max(1, parseInt(aiQCountEl?.value || '7', 10))); + const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt(aiOCountEl?.value || '3', 10))); + const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked); + const range = getTemplateCorrectRange(oCount, globalMulti); + const key = JSON.stringify({ qCount, oCount, globalMulti, min: range.minCorrect, max: range.maxCorrect }); + if (key === lastAppliedTemplateKey) return; + + if (askConfirm && hasMeaningfulQuestions()) { + const ok = confirm( + `Обновить шаблон: ${qCount} вопросов × ${oCount} вариантов?\n` + + 'Текущие вопросы будут заменены.' + ); + if (!ok) return; + } + + questionsEl.innerHTML = ''; + for (let qi = 0; qi < qCount; qi++) { + const opts = []; + for (let oi = 0; oi < oCount; oi++) { + opts.push({ text: '', isCorrect: oi < range.minCorrect }); } - } catch (e) { - if (hintsStatusEl) hintsStatusEl.textContent = e.message || 'Ошибка генерации подсказок.'; - } finally { - generateHintsBtn.disabled = false; + questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: globalMulti, options: opts })); } + renumber(); + scheduleDirtyCheck(); + lastAppliedTemplateKey = key; } - if (generateHintsBtn) { - generateHintsBtn.addEventListener('click', generateHintsForCurrentTest); + function scheduleTemplateRebuild() { + if (templateRebuildTimer) clearTimeout(templateRebuildTimer); + templateRebuildTimer = setTimeout(() => buildTemplateFromControls({ askConfirm: true }), 200); } - if (templateGlobalMultiEl) templateGlobalMultiEl.addEventListener('change', syncTemplateRangeUi); - if (templateMinCorrectEl) templateMinCorrectEl.addEventListener('input', syncTemplateRangeUi); - if (templateMaxCorrectEl) templateMaxCorrectEl.addEventListener('input', syncTemplateRangeUi); - if (aiOCountEl) aiOCountEl.addEventListener('input', syncTemplateRangeUi); + if (templateGlobalMultiEl) templateGlobalMultiEl.addEventListener('change', () => { + syncTemplateRangeUi(); + scheduleTemplateRebuild(); + }); + if (templateMinCorrectEl) templateMinCorrectEl.addEventListener('change', () => { + syncTemplateRangeUi(); + scheduleTemplateRebuild(); + }); + if (templateMaxCorrectEl) templateMaxCorrectEl.addEventListener('change', () => { + syncTemplateRangeUi(); + scheduleTemplateRebuild(); + }); + if (aiOCountEl) aiOCountEl.addEventListener('change', () => { + syncTemplateRangeUi(); + scheduleTemplateRebuild(); + }); + if (aiQCountEl) aiQCountEl.addEventListener('change', scheduleTemplateRebuild); syncTemplateRangeUi(); Promise.all([ diff --git a/flask_app/app/templates/tests/attempt.html b/flask_app/app/templates/tests/attempt.html index ac47b68..5677a5d 100644 --- a/flask_app/app/templates/tests/attempt.html +++ b/flask_app/app/templates/tests/attempt.html @@ -39,18 +39,6 @@ - - -
-

Подсказка

-

-

-

-
- -
-
-
{% endblock %} diff --git a/flask_app/app/templates/tests/attempt_review.html b/flask_app/app/templates/tests/attempt_review.html index 6a74cbe..ceb4593 100644 --- a/flask_app/app/templates/tests/attempt_review.html +++ b/flask_app/app/templates/tests/attempt_review.html @@ -5,6 +5,14 @@

Разбор: {{ review.testTitle }}

+ {% set tl = review.timeLimit %} + {% set timestr = 'без ограничения' if tl is none or tl == 0 else (tl|string ~ ' мин') %} + {% set rm = review.resultMode or 'end' %} + {% set res = 'сразу' if rm == 'immediate' else 'в конце' %} + {% set hint = 'недоступны' if rm != 'immediate' else ('вкл' if review.hintsEnabled else 'выкл') %} +

+ Порог: {{ review.passingThreshold }}% · Вопросов: {{ review.totalQuestions }} · Время: {{ timestr }} · Результат: {{ res }} · Подсказки: {{ hint }} +

Правильно: {{ review.correctCount }} из {{ review.totalQuestions }} ({{ review.percent }}%). Порог: {{ review.passingThreshold }}%. diff --git a/flask_app/app/templates/tests/editor.html b/flask_app/app/templates/tests/editor.html index 3d061fe..d8371ef 100644 --- a/flask_app/app/templates/tests/editor.html +++ b/flask_app/app/templates/tests/editor.html @@ -12,9 +12,6 @@ - - @@ -33,6 +30,12 @@ · Вопросов: 0 · + Время: + · + Результат: + · + Подсказки: + · Активна в каталоге

@@ -55,7 +58,7 @@ Параметры теста - Порог, таймер, режим показа результата и подсказок + Порог, таймер, режим результата, подсказки и шаблон структуры вопросов
@@ -79,24 +82,56 @@ Когда показывать результат -
@@ -48,6 +64,7 @@ {{ t.author_full_name or '—' }} · Версия {{ t.version }} · скрыт + {{ catalog_test_params_line(t) }}
@@ -96,6 +113,7 @@ Открыть
+

{{ catalog_test_params_line(t) }}

{% endfor %} @@ -111,10 +129,12 @@ diff --git a/flask_app/app/tests/routes.py b/flask_app/app/tests/routes.py index 6030c23..0d2105e 100644 --- a/flask_app/app/tests/routes.py +++ b/flask_app/app/tests/routes.py @@ -56,7 +56,13 @@ from ..services.test_attempt import ( start_attempt, submit_attempt, ) -from ..services.test_access import is_test_author, list_hidden_by_author, list_visible_tests +from ..services.test_access import ( + is_test_author, + is_test_edit_open, + list_hidden_by_author, + list_visible_tests, + user_has_test_access, +) from ..services.test_chain import has_any_attempt_for_test from ..services.test_draft import ( HttpError as DraftHttpError, @@ -97,7 +103,7 @@ def _check_test_author_or_404(test_id: str, user_id: str) -> Test: if not test: from werkzeug.exceptions import NotFound raise NotFound(RU['notFound']) - if not is_test_author(test.created_by, user_id): + if not is_test_author(test.created_by, user_id) and not is_test_edit_open(): from werkzeug.exceptions import Forbidden raise Forbidden('Доступ запрещён.') return test @@ -150,11 +156,11 @@ def api_test_summary(test_id): return jsonify(error=RU['notFound']), 404 is_author = is_test_author(test.created_by, user.id) - if not test.is_active and not is_author: + open_edit = is_test_edit_open() + if not test.is_active and not is_author and not open_edit: return jsonify(error=RU['notFound']), 404 - if not is_author: - from ..services.test_access import user_has_test_access + if not is_author and not open_edit: acc = user_has_test_access(user.id, test_id) if not acc.ok: return jsonify(error=RU['notFound']), 404 @@ -199,7 +205,7 @@ def api_test_versions(test_id): ) if not test: return jsonify(error=RU['notFound']), 404 - if not is_test_author(test.created_by, user.id): + if not is_test_author(test.created_by, user.id) and not is_test_edit_open(): return jsonify(error='Доступ запрещён.'), 403 has_attempts = has_any_attempt_for_test(session, tid)