UI bugfixes with boss
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -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': (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}` : ''}.`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (hintsStatusEl) hintsStatusEl.textContent = e.message || 'Ошибка генерации подсказок.';
|
||||
} finally {
|
||||
generateHintsBtn.disabled = false;
|
||||
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 });
|
||||
}
|
||||
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([
|
||||
|
||||
@@ -39,18 +39,6 @@
|
||||
<p id="attempt-error" class="callout callout--error attempt-error-box" style="display:none;"></p>
|
||||
|
||||
<div id="attempt-result" class="surface-card attempt-result-card" style="display:none;"></div>
|
||||
|
||||
<dialog id="hint-modal" class="save-modal attempt-hint-dialog">
|
||||
<div class="save-modal__inner attempt-hint-inner">
|
||||
<h3 class="font-headline text-base font-semibold mb-2" id="hint-title">Подсказка</h3>
|
||||
<p id="hint-verdict" class="attempt-hint-verdict"></p>
|
||||
<p id="hint-correct" class="text-sm text-ink-600 mb-2"></p>
|
||||
<p id="hint-explanation" class="text-sm text-ink-800 leading-relaxed"></p>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button type="button" class="btn btn-primary btn--sm" id="hint-close-btn">Понятно</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
<div class="test-detail-page attempt-review-page">
|
||||
<p class="link-back"><a href="/tests">← к списку тестов</a></p>
|
||||
<h1 class="font-headline" style="font-size:1.35rem;margin-top:0;">Разбор: {{ review.testTitle }}</h1>
|
||||
{% 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 'выкл') %}
|
||||
<p class="text-sm text-muted" style="margin:0.35rem 0 0.75rem;line-height:1.45;">
|
||||
Порог: {{ review.passingThreshold }}% · Вопросов: {{ review.totalQuestions }} · Время: {{ timestr }} · Результат: {{ res }} · Подсказки: {{ hint }}
|
||||
</p>
|
||||
<p>
|
||||
Правильно: <strong>{{ review.correctCount }}</strong> из {{ review.totalQuestions }}
|
||||
({{ review.percent }}%). Порог: {{ review.passingThreshold }}%.
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
||||
class="hero-brick__title font-headline"></textarea>
|
||||
|
||||
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
||||
class="hero-brick__title font-headline"></textarea>
|
||||
|
||||
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
|
||||
class="hero-brick__desc"></textarea>
|
||||
|
||||
@@ -33,6 +30,12 @@
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Вопросов: <b id="q-count">0</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Время: <b id="editor-hero-time-val">—</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Результат: <b id="editor-hero-result-val">—</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Подсказки: <b id="editor-hero-hints-val">—</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span id="chain-active-display">Активна в каталоге</span>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +58,7 @@
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Параметры теста</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Порог, таймер, режим показа результата и подсказок</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Порог, таймер, режим результата, подсказки и шаблон структуры вопросов</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
@@ -79,24 +82,56 @@
|
||||
<legend class="settings-row__label">Когда показывать результат</legend>
|
||||
<label class="settings-radio">
|
||||
<input type="radio" name="result-mode" value="end" />
|
||||
<span>В конце теста <span class="settings-row__hint">(подсказок не будет)</span></span>
|
||||
<span>В конце теста</span>
|
||||
</label>
|
||||
<label class="settings-radio">
|
||||
<input type="radio" name="result-mode" value="immediate" />
|
||||
<span>Сразу после ответа <span class="settings-row__hint">(с подсказкой)</span></span>
|
||||
<span>Сразу после ответа</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="settings-row settings-row--toggle" id="test-hints-row" style="display:none;">
|
||||
<span class="settings-row__label">
|
||||
Показывать подсказку после ответа
|
||||
<span class="settings-row__hint">Краткое объяснение во всплывающем окне</span>
|
||||
<span class="settings-row__hint">Краткое объяснение под вариантами ответа</span>
|
||||
</span>
|
||||
<input id="test-hints-enabled" type="checkbox" />
|
||||
</label>
|
||||
<div class="settings-row settings-row--block" id="test-hints-actions" style="display:none;">
|
||||
<button id="btn-generate-hints" class="btn btn-ghost btn--sm" type="button">Сгенерировать подсказки</button>
|
||||
<p id="hints-status" class="settings-row__hint" style="margin-top:0.35rem;"></p>
|
||||
|
||||
<div class="settings-row settings-row--block">
|
||||
<span class="settings-row__label">Шаблон структуры вопросов</span>
|
||||
<p class="settings-row__hint" style="margin-bottom:0.5rem;">
|
||||
Задаёт сетку для автосборки пустых вопросов и для генерации через ИИ. При смене чисел список вопросов пересобирается (с подтверждением, если уже есть текст).
|
||||
</p>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="block">
|
||||
<span class="form-label">Вопросов</span>
|
||||
<input id="ai-q-count" type="number" min="1" max="30" step="1" value="7"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">Вариантов</span>
|
||||
<input id="ai-o-count" type="number" min="2" max="8" step="1" value="3"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-3 items-end">
|
||||
<label class="inline-flex items-center gap-2 min-h-9">
|
||||
<input id="template-global-multi" type="checkbox"
|
||||
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
<span class="text-sm">Несколько правильных ответов (все вопросы)</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">Правильных: от</span>
|
||||
<input id="template-min-correct" type="number" min="1" max="8" step="1" value="1"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">до</span>
|
||||
<input id="template-max-correct" type="number" min="1" max="8" step="1" value="1"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row settings-row--block" style="padding-top:0.75rem; border-top:1px solid var(--outline-variant); margin-top:0.25rem;">
|
||||
@@ -108,60 +143,18 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="cabinet-disclosure cabinet-brick" open>
|
||||
<details class="cabinet-disclosure cabinet-brick">
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Вопросы</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Тексты, варианты и при необходимости загрузка из файла</span>
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Инструменты генерации</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Генерация через ИИ, проверка, улучшение и импорт документа</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5 test-detail-ai-panel">
|
||||
|
||||
{# ── Создать шаблон ──────────────────────────────────────── #}
|
||||
<div class="question-editor-block question-editor-block--first">
|
||||
<h3 class="test-detail-subsection__title">Структура теста</h3>
|
||||
<p class="muted text-xs mb-3">
|
||||
Укажите количество вопросов и вариантов — создайте шаблон и заполните его вручную или через ИИ.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="block">
|
||||
<span class="form-label">Вопросов</span>
|
||||
<input id="ai-q-count" type="number" min="1" max="30" step="1" value="7"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">Вариантов</span>
|
||||
<input id="ai-o-count" type="number" min="2" max="8" step="1" value="3"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
<button id="create-template"
|
||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">grid_view</span>
|
||||
Создать шаблон
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-3 items-end">
|
||||
<label class="inline-flex items-center gap-2 min-h-9">
|
||||
<input id="template-global-multi" type="checkbox"
|
||||
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
<span class="text-sm">Несколько правильных ответов (все вопросы)</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">Правильных: от</span>
|
||||
<input id="template-min-correct" type="number" min="1" max="8" step="1" value="1"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">до</span>
|
||||
<input id="template-max-correct" type="number" min="1" max="8" step="1" value="1"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Заполнить через ИИ по теме ──────────────────────────── #}
|
||||
<div class="question-editor-block">
|
||||
<div class="question-editor-block question-editor-block--first">
|
||||
<h3 class="test-detail-subsection__title">Заполнить через ИИ</h3>
|
||||
<label class="block">
|
||||
<span class="form-label">Тема / промпт</span>
|
||||
@@ -233,7 +226,17 @@
|
||||
<p id="ai-status" class="mt-3 text-sm text-ink-500 min-h-[1.25rem]"></p>
|
||||
</section>
|
||||
|
||||
{# ── 3. Вопросы ─────────────────────────────────────────────── #}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="cabinet-disclosure cabinet-brick" open>
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Вопросы</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Тексты, варианты ответов и подсказки по каждому вопросу</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<section class="mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-ink-900">Вопросы (<span id="q-count-mirror">0</span>)</h2>
|
||||
@@ -249,20 +252,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Кнопка «Сохранить» под вопросами #}
|
||||
<div class="mt-5 flex items-center gap-3">
|
||||
<button id="save-draft-inline"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11 btn btn-primary">
|
||||
<span class="material-symbols-outlined text-base">save</span>
|
||||
Сохранить
|
||||
</button>
|
||||
<button id="btn-cancel-inline"
|
||||
class="inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
|
||||
Отмена
|
||||
</button>
|
||||
<p id="save-status-inline" class="text-xs text-ink-500"></p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Тесты — каталог{% endblock %}
|
||||
|
||||
{% macro catalog_test_params_line(t) -%}
|
||||
{%- set tl = t.time_limit -%}
|
||||
{%- set timestr = 'без ограничения' if tl is none or tl == 0 else (tl|string ~ ' мин') -%}
|
||||
{%- set rm = t.result_mode or 'end' -%}
|
||||
{%- set res = 'сразу' if rm == 'immediate' else 'в конце' -%}
|
||||
{%- if rm != 'immediate' -%}
|
||||
{%- set hint = 'недоступны' -%}
|
||||
{%- elif t.hints_enabled -%}
|
||||
{%- set hint = 'вкл' -%}
|
||||
{%- else -%}
|
||||
{%- set hint = 'выкл' -%}
|
||||
{%- endif -%}
|
||||
Порог: {{ t.passing_threshold }}% · Вопросов: {{ t.questions_count }} · Время: {{ timestr }} · Результат: {{ res }} · Подсказки: {{ hint }}
|
||||
{%- endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% if ui_variant == 'legacy' %}
|
||||
<section class="legacy-list-shell">
|
||||
@@ -22,6 +37,7 @@
|
||||
{{ t.author_full_name or '—' }}
|
||||
<span class="list-row__meta-tail"> · Версия {{ t.version }}</span>
|
||||
</span>
|
||||
<span class="list-row__params muted" style="display:block;font-size:0.78rem;margin-top:0.2rem;line-height:1.35;">{{ catalog_test_params_line(t) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="list-row__side">
|
||||
@@ -48,6 +64,7 @@
|
||||
{{ t.author_full_name or '—' }}
|
||||
<span class="list-row__meta-tail"> · Версия {{ t.version }} · скрыт</span>
|
||||
</span>
|
||||
<span class="list-row__params muted" style="display:block;font-size:0.78rem;margin-top:0.2rem;line-height:1.35;">{{ catalog_test_params_line(t) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="list-row__side">
|
||||
@@ -96,6 +113,7 @@
|
||||
Открыть
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-ink-500 leading-snug">{{ catalog_test_params_line(t) }}</p>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -111,10 +129,12 @@
|
||||
</summary>
|
||||
<ul class="mt-3 space-y-2">
|
||||
{% for t in hidden %}
|
||||
<li class="flex items-center justify-between gap-2 text-sm">
|
||||
<span>{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span></span>
|
||||
<li class="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between text-sm">
|
||||
<span class="min-w-0">{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span>
|
||||
<span class="block text-xs text-ink-500 mt-0.5 leading-snug">{{ catalog_test_params_line(t) }}</span>
|
||||
</span>
|
||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
|
||||
class="text-brand-700 hover:underline">Открыть</a>
|
||||
class="text-brand-700 hover:underline shrink-0">Открыть</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user