Browse Source

UI bugfixes with boss

dev
Константин Лебединский 1 week ago
parent
commit
b72b485fce
  1. BIN
      .DS_Store
  2. 5
      flask_app/.env.example
  3. 11
      flask_app/app/services/ai_editor.py
  4. 2
      flask_app/app/services/document_gen.py
  5. 13
      flask_app/app/services/draft_validator.py
  6. 4
      flask_app/app/services/editor_content.py
  7. 66
      flask_app/app/services/test_access.py
  8. 11
      flask_app/app/services/test_attempt.py
  9. 4
      flask_app/app/services/test_draft.py
  10. 48
      flask_app/app/static/css/app.css
  11. 79
      flask_app/app/static/js/attempt.js
  12. 229
      flask_app/app/static/js/editor.js
  13. 12
      flask_app/app/templates/tests/attempt.html
  14. 8
      flask_app/app/templates/tests/attempt_review.html
  15. 101
      flask_app/app/templates/tests/editor.html
  16. 26
      flask_app/app/templates/tests/list.html
  17. 18
      flask_app/app/tests/routes.py

BIN
.DS_Store vendored

Binary file not shown.

5
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

11
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'],
}

2
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': (

13
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

4
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 = (

66
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
]

11
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 = (

4
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:

48
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);
}

79
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;

229
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,13 +663,18 @@
const sr = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`);
const st = await sr.json().catch(() => ({}));
if (sr.ok && Number(st.missing) > 0) {
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 (saveMsg) saveMsg.textContent = msg + ' (подсказки не созданы)';
if (saveModal) saveModal.showModal();
return;
}
@ -602,8 +683,16 @@
? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.`
: ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`;
if (saveMsg) saveMsg.textContent = msg + tail;
} else {
if (saveMsg) saveMsg.textContent = msg;
try {
await refreshHintsInForm();
} catch (_) {
/* не блокируем успех сохранения */
}
} 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,22 +1598,34 @@
});
}
// ─── Создать шаблон ────────────────────────────────────────────
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)));
// ─── Автосоздание шаблона ──────────────────────────────────────
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());
});
}
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 existing = $$('#questions .q-item').length;
if (existing > 0) {
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` +
`Обновить шаблон: ${qCount} вопросов × ${oCount} вариантов?\n` +
'Текущие вопросы будут заменены.'
);
if (!ok) return;
}
questionsEl.innerHTML = '';
for (let qi = 0; qi < qCount; qi++) {
const opts = [];
@ -1533,47 +1636,31 @@
}
renumber();
scheduleDirtyCheck();
// Прокручиваем к первому вопросу
questionsEl.firstElementChild?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
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;
}
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([

12
flask_app/app/templates/tests/attempt.html

@ -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 %}

8
flask_app/app/templates/tests/attempt_review.html

@ -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 }}%.

101
flask_app/app/templates/tests/editor.html

@ -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,50 +82,26 @@
<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>
<div class="settings-row settings-row--block" style="padding-top:0.75rem; border-top:1px solid var(--outline-variant); margin-top:0.25rem;">
<span class="settings-row__label">Видимость в каталоге</span>
<p class="settings-row__hint" style="margin-bottom:0.5rem;">Скрытые тесты не показываются в общем списке; ссылку по-прежнему можно открыть.</p>
<button id="btn-toggle-visibility" class="btn btn-ghost btn--sm" type="button">Скрыть из списка</button>
</div>
</div>
</div>
</details>
<details class="cabinet-disclosure cabinet-brick" open>
<summary class="cabinet-disclosure__summary">
<span class="cabinet-disclosure__summary-text">
<span class="cabinet-disclosure__summary-title font-headline">Вопросы</span>
<span class="cabinet-disclosure__summary-sub">Тексты, варианты и при необходимости загрузка из файла</span>
</span>
</summary>
<div class="cabinet-disclosure__body">
<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">
Укажите количество вопросов и вариантов — создайте шаблон и заполните его вручную или через ИИ.
<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">
@ -135,11 +114,6 @@
<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">
@ -160,8 +134,27 @@
</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;">
<span class="settings-row__label">Видимость в каталоге</span>
<p class="settings-row__hint" style="margin-bottom:0.5rem;">Скрытые тесты не показываются в общем списке; ссылку по-прежнему можно открыть.</p>
<button id="btn-toggle-visibility" class="btn btn-ghost btn--sm" type="button">Скрыть из списка</button>
</div>
</div>
</div>
</details>
<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>
</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">
<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>

26
flask_app/app/templates/tests/list.html

@ -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>

18
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)

Loading…
Cancel
Save