Дорабоки интерфейса системы тестирования. Раздел 1 Шапка+Верхний brick

This commit is contained in:
Константин Лебединский
2026-04-29 14:55:43 +05:00
parent 1c4dacbc85
commit eff3fda5b0
34 changed files with 3339 additions and 576 deletions
+29
View File
@@ -13,6 +13,31 @@ from datetime import timedelta
from flask import Flask, jsonify, render_template, request
_ROLE_LABELS = {
'employee': 'Сотрудник',
'manager': 'Руководитель',
'hr': 'HR',
}
def _format_role(role: str | None) -> str:
return _ROLE_LABELS.get((role or '').strip().lower(), '')
def _format_surname_with_initials(full_name: str | None, fallback: str | None = None) -> str:
name = (full_name or '').strip()
if not name:
return (fallback or '').strip() or ''
parts = [p for p in name.replace('\xa0', ' ').split(' ') if p]
if len(parts) < 2:
return name
surname = parts[0]
initials = []
for p in parts[1:3]:
initials.append(f'{p[0].upper()}.')
return f"{surname} {' '.join(initials)}".strip()
def create_app() -> Flask:
app = Flask(
__name__,
@@ -49,11 +74,15 @@ def create_app() -> Flask:
@app.context_processor
def _inject_globals():
ui_variant = (os.environ.get('UI_VARIANT') or 'modern').strip().lower() or 'modern'
return {
'current_user': _current_user(),
'hr_auth_enabled': is_hr_auth_enabled(),
'dev_ui': is_dev_ui(),
'assignment_ui': is_assignment_feature_enabled(),
'ui_variant': ui_variant,
'format_name_short': _format_surname_with_initials,
'format_role': _format_role,
}
@app.errorhandler(404)
+10
View File
@@ -10,6 +10,7 @@ from sqlalchemy import text
from ..db import get_engine
from ..messages import RU
from .test_access import is_test_author
from .test_chain import has_any_attempt_for_test
class HttpError(Exception):
@@ -81,7 +82,13 @@ def get_editor_content(user_id: str, test_id: str) -> dict:
if not tv:
raise HttpError(400, 'Нет активной версии теста.')
version_id = tv['id']
version_count_row = conn.execute(
text('SELECT COUNT(*) AS n FROM test_versions WHERE test_id = :id'),
{'id': test_id},
).mappings().first()
version_count = int(version_count_row['n'] or 0)
questions = load_questions_for_version(conn, version_id, include_correct=True)
has_attempts = has_any_attempt_for_test(conn, test_id)
return {
'test': {
@@ -89,6 +96,9 @@ def get_editor_content(user_id: str, test_id: str) -> dict:
'title': tr['title'],
'description': tr['description'],
'passingThreshold': tr['passing_threshold'],
'hasAttempts': bool(has_attempts),
'versionCount': version_count,
'hasForkRisk': bool(has_attempts) or version_count > 1,
},
'activeVersionId': str(version_id),
'questions': questions,
+354
View File
@@ -0,0 +1,354 @@
from __future__ import annotations
from sqlalchemy import text
from ..services.test_access import is_test_author, user_has_test_access
class HttpError(Exception):
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
self.message = message
def _sort_uuid_strings(items) -> list[str]:
return sorted({str(x) for x in (items or []) if x is not None})
def _same_selection(selected, correct_ids) -> bool:
a = _sort_uuid_strings(selected)
b = _sort_uuid_strings(correct_ids)
return a == b
def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]:
qrows = conn.execute(
text(
'SELECT id, text, question_order, has_multiple_answers '
'FROM questions WHERE test_version_id = :v ORDER BY question_order'
),
{'v': test_version_id},
).mappings().all()
out = []
for q in qrows:
orows = conn.execute(
text(
'SELECT id, text, is_correct, option_order '
'FROM answer_options WHERE question_id = :q ORDER BY option_order'
),
{'q': q['id']},
).mappings().all()
opts = []
for o in orows:
base = {
'id': str(o['id']),
'text': o['text'],
'optionOrder': o['option_order'],
}
if include_correct:
base['isCorrect'] = bool(o['is_correct'])
opts.append(base)
out.append(
{
'id': str(q['id']),
'text': q['text'],
'questionOrder': q['question_order'],
'hasMultipleAnswers': bool(q['has_multiple_answers']),
'options': opts,
}
)
return out
def start_attempt(eng, user_id: str, test_id: str) -> dict:
acc = user_has_test_access(user_id, test_id)
if not acc.ok:
raise HttpError(404, 'Тест не найден.')
with eng.begin() as conn:
tv = conn.execute(
text(
'SELECT id AS test_version_id FROM test_versions '
'WHERE test_id = :id AND is_active = true LIMIT 1'
),
{'id': test_id},
).mappings().first()
if not tv:
raise HttpError(404, 'Нет активной версии теста.')
version_id = tv['test_version_id']
mx = conn.execute(
text(
'SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts '
'WHERE test_version_id = :v AND user_id = :u'
),
{'v': version_id, 'u': user_id},
).mappings().first()
next_n = int(mx['n'] or 0) + 1
a = conn.execute(
text(
"INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status) "
"VALUES (:v, :u, :n, 'in_progress') "
'RETURNING id, test_version_id, user_id, attempt_number, status, started_at'
),
{'v': version_id, 'u': user_id, 'n': next_n},
).mappings().first()
return {'attempt': dict(a)}
def get_play_content(eng, user_id: str, test_id: str, attempt_id: str) -> dict:
with eng.connect() as conn:
a = conn.execute(
text(
'SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, '
't.title, t.passing_threshold '
'FROM test_attempts ta '
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
'INNER JOIN tests t ON t.id = tv.test_id '
'WHERE ta.id = :a'
),
{'a': attempt_id},
).mappings().first()
if not a:
raise HttpError(404, 'Попытка не найдена.')
if str(a['test_id']) != str(test_id):
raise HttpError(404, 'Попытка не найдена.')
if str(a['user_id']) != str(user_id):
raise HttpError(403, 'Доступ запрещён.')
if a['status'] != 'in_progress':
raise HttpError(400, 'Попытка уже завершена.')
qs = load_questions_for_version(conn, a['test_version_id'], include_correct=False)
return {
'testTitle': a['title'],
'passingThreshold': a['passing_threshold'],
'attemptId': str(a['id']),
'questions': qs,
}
def submit_attempt(eng, user_id: str, test_id: str, attempt_id: str, raw_answers: dict | None) -> dict:
answers = raw_answers if isinstance(raw_answers, dict) else {}
with eng.begin() as conn:
a = conn.execute(
text(
'SELECT id, user_id, status, test_version_id '
'FROM test_attempts WHERE id = :a FOR UPDATE'
),
{'a': attempt_id},
).mappings().first()
if not a:
raise HttpError(404, 'Попытка не найдена.')
link = conn.execute(
text(
'SELECT t.passing_threshold, tv.test_id '
'FROM test_versions tv '
'INNER JOIN tests t ON t.id = tv.test_id '
'WHERE tv.id = :v'
),
{'v': a['test_version_id']},
).mappings().first()
if not link:
raise HttpError(404, 'Тест не найден.')
if str(link['test_id']) != str(test_id):
raise HttpError(404, 'Попытка не найдена.')
if str(a['user_id']) != str(user_id):
raise HttpError(403, 'Доступ запрещён.')
if a['status'] != 'in_progress':
raise HttpError(400, 'Попытка уже завершена.')
qrows = conn.execute(
text('SELECT id FROM questions WHERE test_version_id = :v'),
{'v': a['test_version_id']},
).mappings().all()
if not qrows:
raise HttpError(400, 'В тесте нет вопросов.')
opts = conn.execute(
text(
'SELECT a.id, a.question_id, a.is_correct '
'FROM answer_options a '
'INNER JOIN questions q ON q.id = a.question_id '
'WHERE q.test_version_id = :v'
),
{'v': a['test_version_id']},
).mappings().all()
by_q = {}
for o in opts:
qid = str(o['question_id'])
if qid not in by_q:
by_q[qid] = {'all': set(), 'correct': []}
by_q[qid]['all'].add(str(o['id']))
if o['is_correct']:
by_q[qid]['correct'].append(str(o['id']))
correct_count = 0
for q in qrows:
qid = str(q['id'])
selected = answers.get(qid, [])
if not isinstance(selected, list):
selected = [str(selected)]
selected = [str(x) for x in selected]
g = by_q.get(qid, {'all': set(), 'correct': []})
for sid in selected:
if sid not in g['all']:
raise HttpError(400, 'Некорректный вариант ответа.')
if _same_selection(selected, g['correct']):
correct_count += 1
total = len(qrows)
percent = (correct_count / total) * 100 if total else 0
threshold = int(link['passing_threshold'] or 0)
passed = percent + 1e-9 >= threshold
conn.execute(text('DELETE FROM user_answers WHERE attempt_id = :a'), {'a': attempt_id})
for q in qrows:
qid = str(q['id'])
selected = answers.get(qid, [])
if not isinstance(selected, list):
selected = [str(selected)]
selected = [str(x) for x in selected]
conn.execute(
text(
'INSERT INTO user_answers (attempt_id, question_id, selected_options) '
'VALUES (:a, :q, :s::uuid[])'
),
{'a': attempt_id, 'q': q['id'], 's': selected},
)
conn.execute(
text(
"UPDATE test_attempts SET status = 'completed', completed_at = CURRENT_TIMESTAMP, "
'correct_count = :c, total_questions = :t, passed = :p WHERE id = :a'
),
{'a': attempt_id, 'c': correct_count, 't': total, 'p': passed},
)
review = build_review_from_db(eng, attempt_id)
return {
'attemptId': attempt_id,
'correctCount': correct_count,
'totalQuestions': total,
'percent': round(percent, 1),
'passed': passed,
'passingThreshold': threshold,
'review': review,
}
def build_review_from_db(eng, attempt_id: str) -> dict:
with eng.connect() as conn:
a = conn.execute(
text(
'SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions, '
'ta.passed, ta.started_at, ta.completed_at, '
't.id AS test_id, t.title, t.passing_threshold, '
'u.full_name AS attempter_name, u.login AS attempter_login '
'FROM test_attempts ta '
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
'INNER JOIN tests t ON t.id = tv.test_id '
'INNER JOIN users u ON u.id = ta.user_id '
'WHERE ta.id = :a'
),
{'a': attempt_id},
).mappings().first()
if not a:
raise HttpError(404, 'Попытка не найдена.')
if a['status'] != 'completed':
raise HttpError(400, 'Попытка не завершена.')
questions = load_questions_for_version(conn, a['test_version_id'], include_correct=True)
uans = conn.execute(
text('SELECT question_id, selected_options FROM user_answers WHERE attempt_id = :a'),
{'a': attempt_id},
).mappings().all()
sel_by_q = {str(r['question_id']): [str(x) for x in (r['selected_options'] or [])] for r in uans}
total = int(a['total_questions'] or len(questions))
percent = round(((a['correct_count'] or 0) / total) * 100, 1) if total else 0
q_out = []
for q in questions:
selected = _sort_uuid_strings(sel_by_q.get(str(q['id']), []))
correct = _sort_uuid_strings([o['id'] for o in q['options'] if o.get('isCorrect')])
selected_set = set(selected)
q_out.append(
{
'id': q['id'],
'text': q['text'],
'hasMultipleAnswers': q['hasMultipleAnswers'],
'isUserCorrect': _same_selection(selected, correct),
'options': [
{
'id': o['id'],
'text': o['text'],
'isCorrect': o.get('isCorrect', False),
'selected': o['id'] in selected_set,
}
for o in q['options']
],
}
)
return {
'attemptId': str(a['id']),
'testId': str(a['test_id']),
'testTitle': a['title'],
'passingThreshold': int(a['passing_threshold'] or 0),
'correctCount': int(a['correct_count'] or 0),
'totalQuestions': total,
'percent': percent,
'passed': bool(a['passed']),
'startedAt': a['started_at'].isoformat() if a['started_at'] else None,
'completedAt': a['completed_at'].isoformat() if a['completed_at'] else None,
'attempterUserId': str(a['user_id']),
'attempterName': a['attempter_name'],
'attempterLogin': a['attempter_login'],
'questions': q_out,
}
def get_attempt_review_for_user(eng, current_user_id: str, test_id: str, attempt_id: str) -> dict:
with eng.connect() as conn:
row = conn.execute(
text(
'SELECT ta.user_id, t.created_by, tv.test_id '
'FROM test_attempts ta '
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
'INNER JOIN tests t ON t.id = tv.test_id '
'WHERE ta.id = :a'
),
{'a': attempt_id},
).mappings().first()
if not row:
raise HttpError(404, 'Попытка не найдена.')
if str(row['test_id']) != str(test_id):
raise HttpError(404, 'Попытка не найдена.')
is_owner = str(row['user_id']) == str(current_user_id)
is_author = is_test_author(row['created_by'], current_user_id)
if not is_owner and not is_author:
raise HttpError(403, 'Доступ запрещён.')
return build_review_from_db(eng, attempt_id)
def list_test_attempts_for_author(eng, author_id: str, test_id: str) -> list[dict]:
with eng.connect() as conn:
t = conn.execute(
text('SELECT id, created_by FROM tests WHERE id = :id'),
{'id': test_id},
).mappings().first()
if not t:
raise HttpError(404, 'Тест не найден.')
if not is_test_author(t['created_by'], author_id):
raise HttpError(403, 'Доступ запрещён.')
rows = conn.execute(
text(
'SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at, '
'ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version, '
'u.full_name AS attempter_name, u.login AS attempter_login '
'FROM test_attempts ta '
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
'INNER JOIN users u ON u.id = ta.user_id '
'WHERE tv.test_id = :id '
'ORDER BY ta.started_at DESC NULLS LAST LIMIT 200'
),
{'id': test_id},
).mappings().all()
return [dict(r) for r in rows]
+656 -5
View File
@@ -1,17 +1,668 @@
/* Точечные стили поверх Tailwind CDN.
В E1.0 файл почти пустой — задаёт только сглаживание иконок и базовый focus-ring,
чтобы кнопки/ссылки были консистентны со стилем кабинета HR. */
/* Базовые токены и точечные стили в духе webapp-nginx/cabinet-theme. */
:root {
--surface: #ffffff;
--surface-container-low: #f3f8f9;
--surface-container: #eaf3f5;
--on-surface: #0d1b1d;
--on-surface-variant: #3d5357;
--primary: #007168;
--primary-hover: #00645b;
--outline-variant: #b9bc94;
--shadow-card: 0 8px 40px rgba(0, 0, 0, 0.08);
--radius-card: 2rem;
--max-content: 42rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100dvh;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--surface-container-low);
color: var(--on-surface);
-webkit-tap-highlight-color: transparent;
line-height: 1.45;
}
h1,
h2,
h3 {
letter-spacing: -0.02em;
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined', sans-serif;
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 20;
'opsz' 24;
direction: ltr;
-webkit-font-feature-settings: 'liga';
font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
:focus-visible {
outline: 2px solid #6366f1; /* brand-500 */
outline: 2px solid var(--primary);
outline-offset: 2px;
border-radius: 6px;
}
/* Небольшой "cabinet" акцент карточек/кнопок без переписывания шаблонов. */
.rounded-2xl.bg-white,
.rounded-xl.bg-white {
border-color: color-mix(in srgb, var(--outline-variant) 38%, transparent);
}
.bg-brand-600 {
background-color: var(--primary) !important;
}
.hover\:bg-brand-700:hover {
background-color: var(--primary-hover) !important;
}
/* ------------------------------------------------------------------ */
/* UI variants (оба режима на Flask, отличие только в компоновке UI). */
/* ------------------------------------------------------------------ */
/* Modern: плотная колонка и акцент на карточный контент. */
body.ui-modern .max-w-2xl {
max-width: 42rem !important;
}
body.ui-modern main {
padding-top: 1.25rem;
}
/* Legacy: идентичный cabinet layout. */
body.ui-legacy .max-w-2xl {
max-width: 42rem !important;
}
body.ui-legacy .cabinet-app {
min-height: 100dvh;
display: flex;
flex-direction: column;
background: var(--surface);
}
body.ui-legacy .cabinet-header {
position: sticky;
top: 0;
z-index: 20;
background: color-mix(in srgb, var(--surface) 88%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
}
body.ui-legacy .cabinet-header__inner {
max-width: var(--max-content);
margin: 0 auto;
padding-top: max(0.75rem, env(safe-area-inset-top, 0px));
padding-bottom: 0.75rem;
padding-left: max(1.25rem, env(safe-area-inset-left, 0px) + 0.5rem);
padding-right: max(1.25rem, env(safe-area-inset-right, 0px) + 0.5rem);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
body.ui-legacy .cabinet-brand {
display: flex;
align-items: center;
gap: 0.65rem;
color: var(--on-surface);
text-decoration: none;
min-width: 0;
}
body.ui-legacy .cabinet-brand:hover {
text-decoration: none;
color: var(--on-surface);
}
body.ui-legacy .cabinet-brand__logo {
width: 2rem;
height: 2rem;
object-fit: contain;
display: block;
}
body.ui-legacy .login-logo__img {
width: 96px;
height: 96px;
object-fit: contain;
display: block;
margin: 0 auto 0.5rem;
}
body.ui-legacy .cabinet-brand__icon {
font-size: 1.75rem;
color: var(--primary);
background: var(--surface-container-low);
border-radius: 0.75rem;
padding: 0.35rem;
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
flex-shrink: 0;
}
body.ui-legacy .cabinet-brand__title {
font-family: 'Manrope', 'Inter', sans-serif;
font-weight: 800;
font-size: 1rem;
line-height: 1.2;
letter-spacing: -0.02em;
}
body.ui-legacy .cabinet-header__actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
body.ui-legacy .cabinet-user {
font-size: 0.8rem;
color: var(--on-surface-variant);
text-align: right;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: none;
}
@media (min-width: 480px) {
body.ui-legacy .cabinet-user {
display: inline;
}
}
body.ui-legacy .cabinet-user__role {
color: var(--secondary, #506965);
font-weight: 500;
}
body.ui-legacy .cabinet-main {
flex: 1;
max-width: var(--max-content);
width: 100%;
margin: 0 auto;
padding: 1.25rem 1.25rem calc(2.5rem + env(safe-area-inset-bottom, 0px));
}
body.ui-legacy main {
padding-top: 0;
}
body.ui-legacy .rounded-2xl.bg-white,
body.ui-legacy .rounded-xl.bg-white {
border-radius: 0.85rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
/* Legacy catalog (портировано из старого webapp) */
body.ui-legacy .legacy-list-shell {
max-width: 42rem;
margin: 0 auto;
}
body.ui-legacy .legacy-list-title {
font-size: 1.5rem;
margin: 0 0 0.75rem;
}
body.ui-legacy .legacy-list-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1rem;
}
body.ui-legacy .legacy-list-subtitle {
font-size: 1.1rem;
margin: 1.5rem 0 0.5rem;
}
body.ui-legacy .btn {
font-family: inherit;
font-size: 0.9375rem;
font-weight: 600;
padding: 0.55rem 1.1rem;
border-radius: 0.75rem;
border: 1.5px solid transparent;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s;
text-decoration: none;
}
body.ui-legacy .btn-ghost {
background: transparent;
color: var(--primary);
border-color: color-mix(in srgb, var(--outline-variant) 70%, transparent);
}
body.ui-legacy .btn-ghost:hover {
background: var(--surface-container);
border-color: var(--primary);
text-decoration: none;
}
body.ui-legacy .text-muted {
color: var(--on-surface-variant);
font-size: 0.875rem;
}
body.ui-legacy .list-stack {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
body.ui-legacy .list-row {
display: block;
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
border-radius: 1rem;
padding: 0.9rem 1rem;
background: var(--surface);
transition: border-color 0.15s, box-shadow 0.15s;
}
body.ui-legacy .list-row--split {
display: flex;
flex-direction: row;
align-items: stretch;
padding: 0;
overflow: hidden;
gap: 0;
}
body.ui-legacy .list-row__main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
body.ui-legacy .list-row__link {
display: flex;
flex-direction: column;
flex: 1 1 auto;
padding: 0.9rem 1rem;
text-decoration: none;
color: inherit;
}
body.ui-legacy .list-row__title {
display: block;
color: var(--on-surface);
font-weight: 600;
}
body.ui-legacy .list-row__meta {
color: var(--on-surface-variant);
font-size: 0.8rem;
display: block;
margin-top: 0.25rem;
}
body.ui-legacy .list-row__meta-tail {
white-space: nowrap;
}
body.ui-legacy .list-row__side {
display: flex;
align-items: center;
padding: 0.5rem 0.9rem 0.5rem 0;
flex-shrink: 0;
border-left: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
}
body.ui-legacy .list-row--hidden {
border-style: dashed;
opacity: 0.95;
}
body.ui-legacy .link-back {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.9rem;
font-weight: 500;
margin: 0 0 1rem;
}
body.ui-legacy .callout {
border-radius: 1rem;
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-weight: 500;
margin: 0 0 1rem;
}
body.ui-legacy .callout--warning {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
body.ui-legacy .muted,
body.ui-legacy .text-muted,
body.ui-legacy .text-secondary {
color: #506965;
font-size: 0.875rem;
}
body.ui-legacy .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
body.ui-legacy .form-label {
display: block;
font-size: 0.9rem;
font-weight: 500;
color: var(--on-surface);
margin-bottom: 0.35rem;
}
body.ui-legacy .form-input {
width: 100%;
padding: 11px 13px;
border: 1.5px solid var(--outline-variant);
border-radius: 0.75rem;
font-size: 15px;
font-family: inherit;
outline: none;
background: var(--surface-container-low);
color: var(--on-surface);
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
body.ui-legacy .form-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 113, 104, 0.12);
background: #fff;
}
body.ui-legacy .surface-card {
background: var(--surface);
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
border-radius: 1rem;
padding: 1rem 1.1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
body.ui-legacy .cabinet-brick {
margin-bottom: 1.1rem;
}
body.ui-legacy .cabinet-brick--hero {
padding: 0.1rem 0 0.6rem;
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 45%, transparent);
margin-bottom: 1.25rem;
}
.hero-brick__nav {
display: flex;
justify-content: space-between;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--ink-500, #6b7280);
}
.hero-brick__meta {
display: inline-flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: baseline;
color: var(--ink-500, #6b7280);
}
.hero-brick__sep { opacity: 0.55; }
.hero-brick__title {
display: block;
width: 100%;
margin-top: 0.5rem;
border: 1px solid transparent;
background: transparent;
font-size: 1.65rem;
line-height: 1.2;
font-weight: 700;
padding: 0.3rem 0.4rem;
border-radius: 0.5rem;
outline: none;
resize: none;
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
min-height: 2.4rem;
}
.hero-brick__title:hover { border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); }
.hero-brick__title:focus { border-color: var(--primary, #0d9488); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #0d9488) 18%, transparent); background: #fff; }
.hero-brick__desc {
display: block;
width: 100%;
margin-top: 0.35rem;
border: 1px solid transparent;
background: transparent;
font-size: 0.95rem;
color: var(--ink-700, #374151);
padding: 0.3rem 0.4rem;
border-radius: 0.5rem;
resize: none;
overflow: hidden;
outline: none;
font-family: inherit;
}
.hero-brick__desc:hover { border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); }
.hero-brick__desc:focus { border-color: var(--primary, #0d9488); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #0d9488) 18%, transparent); background: #fff; }
.hero-brick__chips {
margin-top: 0.65rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.hero-brick__chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.55rem;
background: color-mix(in srgb, var(--surface, #fff) 80%, var(--outline-variant, #e5e7eb));
border: 1px solid color-mix(in srgb, var(--outline-variant, #e5e7eb) 70%, transparent);
border-radius: 999px;
font-size: 0.85rem;
color: var(--ink-700, #374151);
cursor: pointer;
}
.hero-brick__chip--readonly { cursor: default; }
.hero-brick__chip input[type="number"] {
width: 3.2rem;
border: none;
background: transparent;
text-align: right;
font: inherit;
outline: none;
padding: 0;
}
.hero-brick__chip input[type="checkbox"] { accent-color: var(--primary, #0d9488); }
body.ui-legacy .cabinet-disclosure {
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
border-radius: 1rem;
background: var(--surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
body.ui-legacy .cabinet-disclosure__summary {
cursor: pointer;
list-style: none;
user-select: none;
padding: 0.85rem 1rem 0.75rem;
font-size: 1.05rem;
border-radius: 1rem 1rem 0 0;
min-height: 2.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
body.ui-legacy .cabinet-disclosure__summary::-webkit-details-marker { display: none; }
body.ui-legacy .cabinet-disclosure__summary::after {
content: 'expand_more';
font-family: 'Material Symbols Outlined', sans-serif;
margin-left: auto;
font-size: 1.25rem;
opacity: 0.55;
transition: transform 0.2s ease;
}
body.ui-legacy .cabinet-disclosure[open] .cabinet-disclosure__summary::after {
transform: rotate(180deg);
}
body.ui-legacy .cabinet-disclosure__summary-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
min-width: 0;
}
body.ui-legacy .cabinet-disclosure__summary-title {
font-size: 1.05rem;
line-height: 1.25;
}
body.ui-legacy .cabinet-disclosure__summary-sub {
display: block;
font-size: 0.8rem;
font-weight: 400;
line-height: 1.3;
color: #506965;
}
body.ui-legacy .cabinet-disclosure__body {
padding: 0.7rem 1rem 1.05rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
}
body.ui-legacy .test-detail-subsection {
margin-top: 1.25rem;
padding-top: 1.15rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
}
body.ui-legacy .test-detail-subsection--tight {
margin-top: 0;
padding-top: 0;
border-top: none;
}
body.ui-legacy .test-detail-subsection__title {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 600;
}
body.ui-legacy .test-detail-hint {
margin: 0 0 0.6rem;
font-size: 0.8rem;
line-height: 1.4;
color: #506965;
}
body.ui-legacy .test-detail-ai-panel {
padding: 0.9rem 1rem;
margin-bottom: 1.15rem;
background: var(--surface-container-low);
border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
border-radius: 0.85rem;
box-shadow: none;
}
body.ui-legacy .assign-toolbar {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
@media (min-width: 520px) {
body.ui-legacy .assign-toolbar {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
}
body.ui-legacy .assign-toolbar__search {
flex: 1 1 200px;
}
body.ui-legacy .assign-list {
max-height: min(40vh, 18rem);
overflow: auto;
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
border-radius: 0.75rem;
background: var(--surface-container-low);
}
body.ui-legacy .assign-row {
display: flex;
gap: 0.5rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 40%, transparent);
cursor: pointer;
align-items: center;
}
body.ui-legacy .assign-row:last-child { border-bottom: none; }
body.ui-legacy .assign-row--selected,
body.ui-legacy .assign-row:hover { background: color-mix(in srgb, var(--primary) 8%, transparent); }
body.ui-legacy .assign-row__text {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
flex: 1;
}
body.ui-legacy .assign-row__fio { font-weight: 600; font-size: 0.95rem; }
body.ui-legacy .assign-row__login { font-size: 0.8rem; color: #506965; font-family: ui-monospace, Menlo, monospace; }
body.ui-legacy .assign-row__meta { font-size: 0.8rem; color: #506965; line-height: 1.35; }
body.ui-legacy .version-card-list,
body.ui-legacy .attempts-card-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+324 -15
View File
@@ -25,14 +25,74 @@
const saveStatusEl = $('#save-status');
const aiStatusEl = $('#ai-status');
const chainActiveEl = $('#chain-active');
const aiTopicEl = $('#ai-topic');
const aiQCountEl = $('#ai-q-count');
const aiOCountEl = $('#ai-o-count');
const introUpdatedEl = $('#intro-updated');
const introForkBannerEl = $('#intro-fork-banner');
const versionsListEl = $('#versions-list');
const attemptsListEl = $('#attempts-list');
const visibilityBtn = $('#btn-toggle-visibility');
const assignSearchEl = $('#assign-search');
const assignDeptEl = $('#assign-dept');
const assignClinicEl = $('#assign-clinic');
const assignListEl = $('#assign-list');
const assignSelectAllBtn = $('#assign-select-all');
const assignSubmitBtn = $('#assign-submit');
const assignStatusEl = $('#assign-status');
const tplQ = $('#tpl-question');
const tplO = $('#tpl-option');
let chainActive = true;
let assignPeople = [];
let assignSelected = new Set();
let hasAnyAttempts = false;
let hasForkRisk = Boolean(initial?.test?.hasForkRisk)
|| (introForkBannerEl && introForkBannerEl.dataset.forkRisk === '1');
let baselineDraftKey = '';
let dirtyCheckQueued = false;
function currentDraftKey() {
return JSON.stringify(collectPayload());
}
function isDirty() {
return baselineDraftKey !== '' && baselineDraftKey !== currentDraftKey();
}
function updateForkBanner() {
if (!introForkBannerEl) return;
introForkBannerEl.style.display = (hasForkRisk && isDirty()) ? '' : 'none';
}
function scheduleDirtyCheck() {
if (dirtyCheckQueued) return;
dirtyCheckQueued = true;
requestAnimationFrame(() => {
dirtyCheckQueued = false;
updateForkBanner();
});
}
function resetBaselineDraft() {
baselineDraftKey = currentDraftKey();
updateForkBanner();
}
// ─── render ─────────────────────────────────────────────────────────
function syncOptionInputTypes(qNode) {
const isMulti = $('.q-multi', qNode).checked;
const qName = `q-correct-${Math.random().toString(36).slice(2)}`;
$$('.opt-correct', qNode).forEach((input) => {
input.type = isMulti ? 'checkbox' : 'radio';
if (isMulti) input.removeAttribute('name');
else input.setAttribute('name', qName);
input.classList.add('question-option-row__mark');
});
}
function renderQuestion(q) {
const node = tplQ.content.firstElementChild.cloneNode(true);
node._q = { id: q.id || null };
@@ -43,6 +103,7 @@
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o)));
bindQuestionEvents(node);
syncOptionInputTypes(node);
return node;
}
@@ -61,41 +122,86 @@
if (!confirm('Удалить вопрос?')) return;
node.remove();
renumber();
scheduleDirtyCheck();
});
$('.q-up', node).addEventListener('click', () => {
if (node.previousElementSibling) {
node.parentNode.insertBefore(node, node.previousElementSibling);
renumber();
scheduleDirtyCheck();
}
});
$('.q-down', node).addEventListener('click', () => {
if (node.nextElementSibling) {
node.parentNode.insertBefore(node.nextElementSibling, node);
renumber();
scheduleDirtyCheck();
}
});
$('.q-add-option', node).addEventListener('click', () => {
$('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false }));
syncOptionInputTypes(node);
scheduleDirtyCheck();
});
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
$('.q-multi', node).addEventListener('change', () => {
syncOptionInputTypes(node);
scheduleDirtyCheck();
});
}
function renumber() {
$$('#questions .q-item').forEach((li, i) => {
$('.q-num', li).textContent = `Вопрос #${i + 1}`;
});
qCountEl.textContent = $$('#questions .q-item').length;
const n = $$('#questions .q-item').length;
if (qCountEl) qCountEl.textContent = n;
const mirror = document.getElementById('q-count-mirror');
if (mirror) mirror.textContent = n;
}
function autoResize(el) {
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}
function loadInitial() {
titleEl.value = initial.test.title || '';
descEl.value = initial.test.description || '';
autoResize(titleEl);
autoResize(descEl);
if (titleEl && titleEl.tagName === 'TEXTAREA') {
titleEl.addEventListener('input', () => autoResize(titleEl));
titleEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') e.preventDefault();
});
}
if (descEl) descEl.addEventListener('input', () => autoResize(descEl));
thresholdEl.value =
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
questionsEl.innerHTML = '';
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
if (aiTopicEl && !aiTopicEl.value.trim()) {
aiTopicEl.value = initial.test.title || '';
}
}
function fmtDt(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
} catch {
return '—';
}
}
function escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ─── collect ───────────────────────────────────────────────────────
@@ -144,6 +250,7 @@
}),
);
renumber();
scheduleDirtyCheck();
});
$('#save-draft').addEventListener('click', async () => {
@@ -167,6 +274,7 @@
saveStatusEl.textContent = data.forked
? 'Сохранено (создана новая версия — есть попытки прохождения).'
: 'Сохранено.';
resetBaselineDraft();
setTimeout(() => (saveStatusEl.textContent = ''), 4000);
} catch (e) {
saveStatusEl.textContent = '';
@@ -175,18 +283,24 @@
});
$('#ai-generate-test').addEventListener('click', async () => {
const shape = collectShape();
if (!shape.length) {
alert('Сначала добавьте хотя бы один вопрос (его настройки определяют сетку).');
const topic = (aiTopicEl?.value || titleEl.value || '').trim();
if (!topic) {
alert('Укажите тему.');
return;
}
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
const shape = Array.from({ length: nQ }, () => ({
optionsCount: nO,
hasMultipleAnswers: false,
}));
aiStatusEl.textContent = 'Генерируем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testTitle: topic,
testDescription: descEl.value,
shape,
}),
@@ -194,11 +308,15 @@
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
const draft = data.draft;
if (draft.title) titleEl.value = draft.title;
if (draft.title) {
titleEl.value = draft.title;
if (aiTopicEl) aiTopicEl.value = draft.title;
}
if (draft.description) descEl.value = draft.description;
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
@@ -242,6 +360,7 @@
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
@@ -263,12 +382,6 @@
alert(msg);
}
function escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const modal = $('#ai-modal');
const modalTitle = $('#ai-modal-title');
const modalBody = $('#ai-modal-body');
@@ -290,7 +403,8 @@
modal.showModal();
}
$('#ai-generate-by-title').addEventListener('click', async () => {
const aiGenerateByTitleBtn = $('#ai-generate-by-title');
if (aiGenerateByTitleBtn) aiGenerateByTitleBtn.addEventListener('click', async () => {
const title = titleEl.value.trim();
if (!title) {
alert('Сначала заполните название теста.');
@@ -334,6 +448,7 @@
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
@@ -342,7 +457,8 @@
}
});
$('#ai-check').addEventListener('click', async () => {
const aiCheckBtn = $('#ai-check');
if (aiCheckBtn) aiCheckBtn.addEventListener('click', async () => {
const payload = collectPayload();
if (!payload.questions.length) {
alert('В тесте нет вопросов — нечего проверять.');
@@ -394,7 +510,8 @@
}
});
$('#ai-improve').addEventListener('click', async () => {
const aiImproveBtn = $('#ai-improve');
if (aiImproveBtn) aiImproveBtn.addEventListener('click', async () => {
const payload = collectPayload();
if (!payload.questions.length) {
alert('В тесте нет вопросов — нечего улучшать.');
@@ -480,6 +597,7 @@
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
});
modal.close();
scheduleDirtyCheck();
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
},
@@ -517,6 +635,7 @@
data.options.forEach((o) => optsEl.appendChild(renderOption(o)));
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
}
scheduleDirtyCheck();
aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.';
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
@@ -542,5 +661,195 @@
chainActiveEl.checked = true;
});
function renderVersions(rows) {
if (!versionsListEl) return;
versionsListEl.innerHTML = '';
(rows || []).forEach((r) => {
const li = document.createElement('li');
li.className = 'surface-card version-card-list__item';
li.innerHTML = `
<div class="version-card-list__row">
<div class="version-card-list__main">
<div class="version-card-list__title-line">
<span class="font-headline" style="font-size:1rem;">v${r.version}</span>
${r.is_active ? '<span class="code-inline" style="font-size:0.7rem;">текущая</span>' : ''}
</div>
<p class="muted mono" style="margin:.4rem 0 0; font-size:.8rem;">${fmtDt(r.created_at)}</p>
<p class="muted" style="margin:.2rem 0 0; font-size:.8rem;">Активна: ${r.is_active ? 'да' : 'нет'}</p>
</div>
</div>`;
versionsListEl.appendChild(li);
});
}
function renderAttempts(rows) {
if (!attemptsListEl) return;
attemptsListEl.innerHTML = '';
(rows || []).forEach((a) => {
const when = a.completedAt ? fmtDt(a.completedAt) : (a.startedAt ? fmtDt(a.startedAt) : '—');
const result = a.status === 'completed' && a.totalQuestions != null
? `${a.correctCount}/${a.totalQuestions}${a.passed ? ' · зачёт' : ' · незачёт'}`
: a.status;
const li = document.createElement('li');
li.className = 'surface-card attempts-card-list__item';
li.innerHTML = `
<div class="attempts-card-list__row">
<div class="attempts-card-list__main">
<p class="muted mono" style="margin:0; font-size:.8rem;">${when}</p>
<p style="margin:.35rem 0 0; font-weight:600;">${escHtml(a.attempterName || '—')}
${a.attempterLogin ? `<span class="code-inline" style="font-size:11px; margin-left:6px;">${escHtml(a.attempterLogin)}</span>` : ''}
</p>
<p class="muted" style="margin:.25rem 0 0; font-size:.85rem;">v${a.testVersion} · ${escHtml(result)}</p>
</div>
${a.status === 'completed'
? `<a class="btn btn-ghost btn--sm attempts-card-list__action" href="/tests/${TEST_ID}/attempts/${a.id}/review">Разбор</a>`
: ''}
</div>`;
attemptsListEl.appendChild(li);
});
}
function renderAssignList() {
if (!assignListEl) return;
assignListEl.innerHTML = '';
assignPeople.forEach((p) => {
const row = document.createElement('label');
row.className = `assign-row${assignSelected.has(String(p.staffId)) ? ' assign-row--selected' : ''}`;
row.innerHTML = `
<input type="checkbox" ${assignSelected.has(String(p.staffId)) ? 'checked' : ''} />
<span class="assign-row__text">
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span>
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''}
<span class="assign-row__meta">${escHtml(p.department || '—')}${p.clinicUserId ? ' · есть учётка' : ' · нет учётки (создадим при назначении)'}</span>
</span>`;
const cb = row.querySelector('input');
cb.addEventListener('change', () => {
const k = String(p.staffId);
if (cb.checked) assignSelected.add(k); else assignSelected.delete(k);
row.classList.toggle('assign-row--selected', cb.checked);
});
assignListEl.appendChild(row);
});
if (!assignPeople.length) assignListEl.innerHTML = '<p class="muted" style="padding:.75rem;">Никого не найдено.</p>';
}
async function loadDirectory() {
if (!assignListEl) return;
assignStatusEl.textContent = 'Загружаем…';
try {
const params = new URLSearchParams();
if (assignSearchEl.value.trim()) params.set('q', assignSearchEl.value.trim());
if (assignDeptEl.value && assignDeptEl.value !== '__all__') params.set('department', assignDeptEl.value);
params.set('clinic', assignClinicEl.value || 'all');
const r = await fetch(`/api/auth/dev/assignment-directory?${params.toString()}`);
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить сотрудников');
assignPeople = data.people || [];
const depts = data.departments || [];
if (assignDeptEl.options.length <= 1) {
depts.forEach((d) => {
const o = document.createElement('option');
o.value = d;
o.textContent = d;
assignDeptEl.appendChild(o);
});
}
assignSelected = new Set();
renderAssignList();
assignStatusEl.textContent = '';
} catch (e) {
assignStatusEl.textContent = e.message || 'Ошибка загрузки';
}
}
if (assignSearchEl) {
let t = null;
assignSearchEl.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(loadDirectory, 350);
});
assignDeptEl.addEventListener('change', loadDirectory);
assignClinicEl.addEventListener('change', loadDirectory);
assignSelectAllBtn.addEventListener('click', () => {
assignPeople.forEach((p) => assignSelected.add(String(p.staffId)));
renderAssignList();
});
assignSubmitBtn.addEventListener('click', async () => {
const selectedRows = assignPeople.filter((p) => assignSelected.has(String(p.staffId)));
const userIds = selectedRows.filter((p) => p.clinicUserId).map((p) => p.clinicUserId);
const staffIds = selectedRows.filter((p) => !p.clinicUserId && p.staffId != null).map((p) => p.staffId);
if (!userIds.length && !staffIds.length) return;
assignStatusEl.textContent = 'Назначаем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userIds, staffIds }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка назначения');
assignStatusEl.textContent = `Назначено: ${data.count ?? selectedRows.length}`;
} catch (e) {
assignStatusEl.textContent = e.message || 'Ошибка назначения';
}
});
loadDirectory();
}
if (visibilityBtn) {
visibilityBtn.addEventListener('click', async () => {
const next = !chainActiveEl.checked;
try {
const r = await fetch(`/api/tests/${TEST_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chainActive: next }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка изменения видимости');
chainActiveEl.checked = !!next;
chainActive = !!next;
visibilityBtn.textContent = chainActive ? 'Скрыть из списка' : 'Снова показать в списке';
} catch (e) {
alert(e.message || 'Ошибка изменения видимости');
}
});
}
Promise.all([
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/summary`).then((r) => r.json()).catch(() => null),
]).then(([v, a, s]) => {
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 (a && Array.isArray(a.attempts)) {
renderAttempts(a.attempts);
hasAnyAttempts = a.attempts.length > 0;
}
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 (typeof s.test.versionCount === 'number') {
hasForkRisk = hasForkRisk || s.test.versionCount > 1;
}
}
updateForkBanner();
});
loadInitial();
resetBaselineDraft();
root.addEventListener('input', scheduleDirtyCheck);
root.addEventListener('change', scheduleDirtyCheck);
})();
+93 -51
View File
@@ -2,57 +2,99 @@
{% block title %}Вход — Тестирование{% endblock %}
{% block content %}
<section class="mx-auto max-w-md mt-8">
<div class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-600">login</span>
<h1 class="text-xl font-semibold">Вход в систему</h1>
{% if ui_variant == 'legacy' %}
<div class="login-page">
<div class="login-shell">
<div class="login-logo">
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
alt="Логотип клиники" class="login-logo__img" />
<h1 class="font-headline">Тестирование</h1>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="callout callout--error" style="margin-bottom: 1rem;">
{% for category, msg in messages %}
{% if category == 'error' %}{{ msg }}{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="login-card">
<form method="post" action="{{ url_for('auth.login_submit') }}" novalidate>
<input type="hidden" name="next" value="{{ next or '/' }}">
<div class="form-field">
<label class="form-label" for="login-username">Логин</label>
<input id="login-username" class="form-input" type="text" name="login"
value="{{ login or '' }}" required autofocus autocomplete="username" />
</div>
<div class="form-field">
<label class="form-label" for="login-password">Пароль</label>
<input id="login-password" class="form-input" type="password" name="password"
required autocomplete="current-password" />
</div>
<button type="submit" class="btn btn-primary">Войти</button>
</form>
</div>
</div>
<p class="mt-1 text-sm text-ink-500">
Используйте логин и пароль.
{% if hr_auth_enabled %}
Учётка кадровой системы (HR).
{% endif %}
</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mt-4 space-y-2">
{% for category, msg in messages %}
<div class="px-3 py-2 rounded-lg text-sm
{% if category == 'error' %}bg-red-50 text-red-700 border border-red-200
{% else %}bg-brand-50 text-brand-700 border border-brand-100{% endif %}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('auth.login_submit') }}" class="mt-5 space-y-4" novalidate>
<input type="hidden" name="next" value="{{ next or '/' }}">
<label class="block">
<span class="text-sm font-medium text-ink-700">Логин</span>
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<label class="block">
<span class="text-sm font-medium text-ink-700">Пароль</span>
<input type="password" name="password" required autocomplete="current-password"
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<button type="submit"
class="w-full inline-flex items-center justify-center gap-2 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white font-medium px-4 py-2 transition">
<span class="material-symbols-outlined text-base">login</span>
Войти
</button>
</form>
</div>
</section>
{% else %}
<section class="mx-auto max-w-md mt-8">
<div class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-600">login</span>
<h1 class="text-xl font-semibold">Вход в систему</h1>
</div>
<p class="mt-1 text-sm text-ink-500">
Используйте логин и пароль.
{% if hr_auth_enabled %}
Учётка кадровой системы (HR).
{% endif %}
</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mt-4 space-y-2">
{% for category, msg in messages %}
<div class="px-3 py-2 rounded-lg text-sm
{% if category == 'error' %}bg-red-50 text-red-700 border border-red-200
{% else %}bg-brand-50 text-brand-700 border border-brand-100{% endif %}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('auth.login_submit') }}" class="mt-5 space-y-4" novalidate>
<input type="hidden" name="next" value="{{ next or '/' }}">
<label class="block">
<span class="text-sm font-medium text-ink-700">Логин</span>
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<label class="block">
<span class="text-sm font-medium text-ink-700">Пароль</span>
<input type="password" name="password" required autocomplete="current-password"
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<button type="submit"
class="w-full inline-flex items-center justify-center gap-2 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white font-medium px-4 py-2 transition">
<span class="material-symbols-outlined text-base">login</span>
Войти
</button>
</form>
</div>
</section>
{% endif %}
{% endblock %}
+102 -69
View File
@@ -8,7 +8,7 @@
{# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #}
<script src="https://cdn.tailwindcss.com"></script>
<script>
// Минимальная палитра в стиле кабинета HR. Без зависимостей от HR-репо.
// Палитра/типографика в стиле webapp-nginx (cabinet-theme).
tailwind.config = {
theme: {
extend: {
@@ -17,18 +17,19 @@
},
colors: {
brand: {
50: '#eef2ff',
100: '#e0e7ff',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
50: '#ecf7f6',
100: '#d9efec',
300: '#9bd7d0',
500: '#007168',
600: '#00645b',
700: '#00574f',
},
ink: {
900: '#0f172a',
700: '#334155',
500: '#64748b',
300: '#cbd5e1',
100: '#f1f5f9',
900: '#0d1b1d',
700: '#3d5357',
500: '#506965',
300: '#b9bc94',
100: '#f3f8f9',
},
},
},
@@ -39,7 +40,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@600;700;800&display=swap"
rel="stylesheet"
/>
<link
@@ -50,65 +51,97 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
{% block head %}{% endblock %}
</head>
<body class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased">
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/60">
<div class="mx-auto max-w-6xl px-4 h-14 flex items-center justify-between">
<a href="{{ url_for('main.index') }}" class="flex items-center gap-2 font-semibold text-ink-900">
<span class="material-symbols-outlined text-brand-600">quiz</span>
<span>Тестирование</span>
</a>
<nav class="flex items-center gap-1 sm:gap-2 text-sm">
{% if current_user %}
<a href="{{ url_for('tests.tests_list_page') }}"
class="inline-flex items-center justify-center gap-1
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
text-ink-700 hover:bg-ink-100"
title="Каталог тестов" aria-label="Каталог тестов">
<span class="material-symbols-outlined text-base">list_alt</span>
<span class="hidden sm:inline">Тесты</span>
<body data-ui-variant="{{ ui_variant }}"
class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased ui-{{ ui_variant }}">
{% if ui_variant == 'legacy' %}
<div class="cabinet-app">
<header class="cabinet-header">
<div class="cabinet-header__inner">
<a href="{{ url_for('tests.tests_list_page') }}" class="cabinet-brand">
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
alt="Логотип клиники" class="cabinet-brand__logo" />
<div>
<div class="cabinet-brand__title">Тестирование</div>
</div>
</a>
<a href="{{ url_for('settings.settings_page') }}"
class="inline-flex items-center justify-center gap-1
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
text-ink-700 hover:bg-ink-100"
title="Настройки" aria-label="Настройки">
<span class="material-symbols-outlined text-base">settings</span>
<span class="hidden sm:inline">Настройки</span>
</a>
<span class="hidden md:inline text-ink-500">
{{ current_user.full_name or current_user.login }}
<span class="text-ink-300">·</span>
<span class="text-brand-700">{{ current_user.role }}</span>
</span>
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
<button type="submit"
class="inline-flex items-center justify-center gap-1
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
text-ink-700 hover:bg-ink-100 transition"
title="Выйти" aria-label="Выйти">
<span class="material-symbols-outlined text-base">logout</span>
<span class="hidden sm:inline">Выйти</span>
</button>
</form>
{% else %}
<a href="{{ url_for('auth.login_page') }}"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
text-brand-700 hover:bg-brand-50 transition min-h-10">
<span class="material-symbols-outlined text-base">login</span>
Войти
</a>
{% endif %}
</nav>
<div class="cabinet-header__actions">
{% if current_user %}
<span class="cabinet-user" title="{{ (current_user.full_name or current_user.login) ~ (' · ' ~ format_role(current_user.role) if format_role(current_user.role) else '') }}">
{{ format_name_short(current_user.full_name, current_user.login) }}
{% if format_role(current_user.role) %}<span class="cabinet-user__role"> · {{ format_role(current_user.role) }}</span>{% endif %}
</span>
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
<button type="submit" class="btn btn-ghost">Выйти</button>
</form>
{% else %}
<a href="{{ url_for('auth.login_page') }}" class="btn btn-ghost">Войти</a>
{% endif %}
</div>
</div>
</header>
<main class="cabinet-main">
{% block content scoped %}{% endblock %}
</main>
</div>
</header>
<main class="mx-auto max-w-6xl px-4 py-6">
{% block content %}{% endblock %}
</main>
<footer class="mx-auto max-w-6xl px-4 py-8 text-xs text-ink-500">
{% block footer %}testing-flask-app · Этап 1{% endblock %}
</footer>
{% else %}
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/50">
<div class="mx-auto max-w-2xl px-4 h-14 flex items-center justify-between">
<a href="{{ url_for('main.index') }}" class="flex items-center gap-2 font-semibold text-ink-900">
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
alt="Логотип клиники" class="h-7 w-7 object-contain" />
<span>Тестирование</span>
</a>
<nav class="flex items-center gap-1 sm:gap-2 text-sm">
{% if current_user %}
<a href="{{ url_for('tests.tests_list_page') }}"
class="inline-flex items-center justify-center gap-1
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
text-ink-700 hover:bg-ink-100"
title="Каталог тестов" aria-label="Каталог тестов">
<span class="material-symbols-outlined text-base">list_alt</span>
<span class="hidden sm:inline">Тесты</span>
</a>
<a href="{{ url_for('settings.settings_page') }}"
class="inline-flex items-center justify-center gap-1
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
text-ink-700 hover:bg-ink-100"
title="Настройки" aria-label="Настройки">
<span class="material-symbols-outlined text-base">settings</span>
<span class="hidden sm:inline">Настройки</span>
</a>
<span class="hidden md:inline text-ink-500">
{{ current_user.full_name or current_user.login }}
<span class="text-ink-300">·</span>
<span class="text-brand-700">{{ format_role(current_user.role) }}</span>
</span>
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
<button type="submit"
class="inline-flex items-center justify-center gap-1
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg
text-ink-700 hover:bg-ink-100 transition"
title="Выйти" aria-label="Выйти">
<span class="material-symbols-outlined text-base">logout</span>
<span class="hidden sm:inline">Выйти</span>
</button>
</form>
{% else %}
<a href="{{ url_for('auth.login_page') }}"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
text-brand-700 hover:bg-brand-50 transition min-h-10">
<span class="material-symbols-outlined text-base">login</span>
Войти
</a>
{% endif %}
</nav>
</div>
</header>
<main class="mx-auto max-w-2xl px-4 py-6">
{{ self.content() }}
</main>
<footer class="mx-auto max-w-2xl px-4 py-8 text-xs text-ink-500">
{% block footer %}testing-flask-app · Этап 1{% endblock %}
</footer>
{% endif %}
{% block scripts %}{% endblock %}
</body>
+3 -4
View File
@@ -2,13 +2,13 @@
{% block title %}Настройки — LLM{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6 max-w-2xl">
<section class="{% if ui_variant == 'legacy' %}surface-card{% else %}rounded-2xl bg-white shadow-sm border border-ink-300/60{% endif %} p-6 max-w-2xl">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-600">settings</span>
<h1 class="text-2xl font-semibold">Настройки</h1>
</div>
<h2 class="mt-5 font-semibold">Подключение к LLM</h2>
<h2 class="mt-5 font-semibold {% if ui_variant == 'legacy' %}font-headline{% endif %}">Подключение к LLM</h2>
<p class="mt-1 text-sm text-ink-500">
Ключ задаётся в <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> сервера
(общий, не на пользователя). Поддерживаются DeepSeek и OpenAI-совместимые API.
@@ -53,8 +53,7 @@ OPENAI_API_KEY=sk-...
<div class="mt-5 flex items-center gap-3">
<button id="btn-ping"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white text-sm">
class="{% if ui_variant == 'legacy' %}btn btn-primary{% else %}inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm{% endif %}">
<span class="material-symbols-outlined text-base">cable</span>
Проверить подключение
</button>
+136
View File
@@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block title %}Прохождение теста{% endblock %}
{% block content %}
<div class="test-detail-page" id="attempt-root" data-test-id="{{ test_id }}" data-attempt-id="{{ attempt_id }}">
<p class="link-back"><a href="/tests">← к списку тестов</a></p>
<h1 class="font-headline" style="font-size:1.35rem;margin-top:0;" id="attempt-title">Загрузка…</h1>
<p class="text-muted" style="margin-top:0;" id="attempt-subtitle"></p>
<p class="error-text" id="attempt-error" style="display:none;"></p>
<ol id="questions-list" style="padding-left:1.25rem;"></ol>
<div class="inline-actions" style="margin-top:1rem;">
<button type="button" class="btn btn-primary" id="submit-attempt-btn">Завершить тест</button>
</div>
<div id="attempt-result" class="surface-card" style="display:none;margin-top:1rem;padding:1rem;"></div>
</div>
<script>
(() => {
const root = document.getElementById('attempt-root');
const testId = root.dataset.testId;
const attemptId = root.dataset.attemptId;
const titleEl = document.getElementById('attempt-title');
const subEl = document.getElementById('attempt-subtitle');
const errEl = document.getElementById('attempt-error');
const listEl = document.getElementById('questions-list');
const resultEl = document.getElementById('attempt-result');
const submitBtn = document.getElementById('submit-attempt-btn');
let playData = null;
const selections = {};
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (m) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
function setErr(msg) {
errEl.textContent = msg || 'Ошибка.';
errEl.style.display = '';
}
function isSelected(qid, oid) {
return (selections[String(qid)] || []).includes(String(oid));
}
function toggle(qid, oid, multi) {
const k = String(qid);
const cur = selections[k] || [];
const id = String(oid);
if (multi) {
selections[k] = cur.includes(id) ? cur.filter(x => x !== id) : [...cur, id];
return;
}
selections[k] = [id];
}
function renderQuestions() {
listEl.innerHTML = '';
for (const q of (playData.questions || [])) {
const li = document.createElement('li');
li.style.marginBottom = '1.5rem';
li.innerHTML = '<p style="margin-top:0;margin-bottom:0.5rem;">' + esc(q.text) + '</p>';
const ul = document.createElement('ul');
ul.style.listStyle = 'none';
ul.style.padding = '0';
ul.style.margin = '0';
for (const o of (q.options || [])) {
const row = document.createElement('li');
row.style.marginBottom = '6px';
const type = q.hasMultipleAnswers ? 'checkbox' : 'radio';
const name = 'q-' + q.id;
row.innerHTML =
'<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">' +
'<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' + (isSelected(q.id, o.id) ? 'checked' : '') + ' />' +
'<span>' + esc(o.text) + '</span>' +
'</label>';
const input = row.querySelector('input');
input.addEventListener('change', () => {
toggle(q.id, o.id, q.hasMultipleAnswers);
renderQuestions();
});
ul.appendChild(row);
}
li.appendChild(ul);
listEl.appendChild(li);
}
}
async function load() {
try {
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/play');
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
playData = data;
titleEl.textContent = data.testTitle || 'Прохождение теста';
subEl.textContent = 'Порог зачёта: ' + (data.passingThreshold ?? 0) + '%.';
if (!Array.isArray(data.questions) || !data.questions.length) {
setErr('В активной версии нет вопросов.');
submitBtn.disabled = true;
return;
}
renderQuestions();
} catch (e) {
setErr(e.message);
submitBtn.disabled = true;
}
}
async function submit() {
submitBtn.disabled = true;
submitBtn.textContent = 'Отправка…';
try {
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: selections }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось завершить попытку.');
resultEl.style.display = '';
resultEl.innerHTML =
'<h3 style="margin-top:0;">Результат</h3>' +
'<p>Правильно: <strong>' + data.correctCount + '</strong> из ' + data.totalQuestions +
' (' + data.percent + '%). Порог: ' + data.passingThreshold + '%.</p>' +
'<p class="' + (data.passed ? 'text-muted' : 'error-text') + '">' + (data.passed ? 'Зачёт.' : 'Незачёт.') + '</p>' +
'<p><a href="/tests/' + testId + '/attempts/' + data.attemptId + '/review">Разбор попытки</a></p>';
submitBtn.style.display = 'none';
} catch (e) {
setErr(e.message);
submitBtn.disabled = false;
submitBtn.textContent = 'Завершить тест';
}
}
submitBtn.addEventListener('click', submit);
load();
})();
</script>
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Разбор попытки{% endblock %}
{% block content %}
<div class="test-detail-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>
<p>
Правильно: <strong>{{ review.correctCount }}</strong> из {{ review.totalQuestions }}
({{ review.percent }}%). Порог: {{ review.passingThreshold }}%.
{% if review.passed %}
<span class="text-muted">Зачёт.</span>
{% else %}
<span class="error-text">Незачёт.</span>
{% endif %}
</p>
<div class="attempts-card-list">
{% for q in review.questions %}
<article class="attempt-card">
<div class="attempt-card__meta">
<span>{{ 'Верно' if q.isUserCorrect else 'Ошибка' }}</span>
</div>
<p style="margin-top:.25rem;"><strong>{{ loop.index }}.</strong> {{ q.text }}</p>
<ul style="list-style:none;padding-left:0;margin:0;">
{% for o in q.options %}
<li style="margin:.25rem 0;">
<span>
{% if o.selected %}☑{% else %}☐{% endif %}
{{ o.text }}
{% if o.isCorrect %}<strong> (правильный)</strong>{% endif %}
</span>
</li>
{% endfor %}
</ul>
</article>
{% endfor %}
</div>
</div>
{% endblock %}
+147 -93
View File
@@ -3,88 +3,86 @@
{% block content %}
<div id="editor-root"
class="space-y-4 sm:space-y-5 pb-24"
class="space-y-4 sm:space-y-5 pb-24 {% if ui_variant == 'legacy' %}test-detail-page test-detail-page--with-fixed-actions{% endif %}"
data-test-id="{{ test_id }}"
data-initial='{{ content | tojson | safe }}'>
{# ── 1. Шапка теста ─────────────────────────────────────────── #}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-5">
<h2 class="text-xs font-medium text-ink-500 uppercase tracking-wide">Тест</h2>
<section class="cabinet-brick cabinet-brick--hero hero-brick">
<div class="hero-brick__nav">
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back">← к списку</a>
<span class="hero-brick__meta">
<span>Автор: <b id="intro-author">Вы</b></span>
<span class="hero-brick__sep">·</span>
<span>Обновлён: <span id="intro-updated"></span></span>
<span class="hero-brick__sep">·</span>
<span>Версия <span id="intro-version"></span></span>
</span>
</div>
<label class="mt-2 block">
<span class="sr-only">Название</span>
<input id="test-title" type="text" maxlength="200" placeholder="Название теста"
class="w-full rounded-lg border border-ink-300 px-3 py-3 text-lg font-semibold
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
class="hero-brick__title font-headline"></textarea>
<label class="mt-3 block">
<span class="text-xs font-medium text-ink-500">Описание</span>
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea>
</label>
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
class="hero-brick__desc"></textarea>
<label class="mt-3 flex items-center justify-between gap-3">
<span class="text-xs font-medium text-ink-500">Проходной балл, %</span>
<input id="test-threshold" type="number" min="0" max="100" step="1"
inputmode="numeric"
class="w-24 text-right rounded-lg border border-ink-300 px-3 py-2
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<div class="hero-brick__chips">
<label class="hero-brick__chip">
<span>Порог зачёта</span>
<input id="test-threshold" type="number" min="0" max="100" step="1" inputmode="numeric" />
<span>%</span>
</label>
<span class="hero-brick__chip hero-brick__chip--readonly">
Вопросов: <b id="q-count">0</b>
</span>
<label class="hero-brick__chip">
<input id="chain-active" type="checkbox" />
<span>Активна в каталоге</span>
</label>
</div>
<div id="intro-fork-banner" class="callout callout--warning" data-fork-risk="{{ '1' if content.test.hasForkRisk else '0' }}" style="margin-top:0.85rem; display:none;">
При сохранении будет создана новая версия теста.
</div>
</section>
{# ── 2. AI-помощник ─────────────────────────────────────────── #}
<section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-600">auto_awesome</span>
<h2 class="font-semibold text-brand-700">AI-помощник</h2>
</div>
{# Группа A — генерация. Главные действия. На sm+ — в одну строку. #}
<div class="mt-3">
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Создать вопросы</p>
<div class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
<button id="ai-generate-by-title"
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium min-h-11">
<span class="material-symbols-outlined text-base">edit_note</span>
По названию
</button>
<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" style="margin-top:0;">Генерация сетки вопросов (ИИ)</h3>
<label class="block">
<span class="form-label">Тема</span>
<input id="ai-topic" type="text" class="form-input" placeholder="Например: Введение про LLM" />
</label>
<div class="mt-3 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="ai-generate-test"
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
bg-white border border-brand-300/60 text-brand-700 hover:bg-brand-50
text-sm font-medium min-h-11">
<span class="material-symbols-outlined text-base">stars</span>
По текущей сетке
class="btn btn-ghost" type="button" style="min-height:43px;">
Сгенерировать тест (ИИ)
</button>
</div>
</div>
{# Группа B — анализ существующего. #}
<div class="mt-4">
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Улучшить существующее</p>
<div class="mt-2 grid grid-cols-2 gap-2">
<button id="ai-check"
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300
text-sm min-h-11">
<span class="material-symbols-outlined text-base">fact_check</span>
Проверить
</button>
<button id="ai-improve"
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300
text-sm min-h-11">
<span class="material-symbols-outlined text-base">tune</span>
Улучшить
</button>
</div>
</div>
{# Группа C — импорт. #}
<div class="mt-4">
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Импортировать</p>
<div class="question-editor-block test-detail-subsection test-detail-subsection--import">
<h3 class="test-detail-subsection__title">Документ в вопросы</h3>
<p class="muted test-detail-hint" style="margin-top:0;">
PDF, Word или текст — вставьте в черновик вопросов.
</p>
<label class="mt-2 inline-flex w-full items-center justify-center gap-2 px-3 py-3
rounded-lg bg-white border border-ink-300/60 hover:border-brand-300
text-sm cursor-pointer min-h-11">
@@ -103,10 +101,11 @@
{# ── 3. Вопросы ─────────────────────────────────────────────── #}
<section>
<div class="flex items-center justify-between gap-2 px-1">
<h2 class="font-semibold">Вопросы (<span id="q-count">0</span>)</h2>
<h2 class="font-semibold">Вопросы (<span id="q-count-mirror">0</span>)</h2>
<button id="add-question"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10">
bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10
btn btn-ghost btn--sm question-editor__add-question">
<span class="material-symbols-outlined text-base">add</span>
<span class="hidden sm:inline">Добавить вопрос</span>
<span class="sm:hidden">Добавить</span>
@@ -114,37 +113,91 @@
</div>
<ol id="questions" class="mt-3 space-y-3"></ol>
</section>
</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">
<div class="test-detail-subsection test-detail-subsection--tight">
<h3 class="test-detail-subsection__title">Версии</h3>
<ul id="versions-list" class="version-card-list"></ul>
</div>
<div class="test-detail-subsection">
<h3 class="test-detail-subsection__title">Прохождения</h3>
<ul id="attempts-list" class="attempts-card-list"></ul>
</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">
<div class="test-detail-subsection test-detail-subsection--tight">
<h3 class="test-detail-subsection__title">Видимость</h3>
<p class="test-detail-hint">Скрытые тесты в общем списке не показываются; ссылку на тест по-прежнему можно открыть.</p>
<div class="publication-visibility__actions">
<button id="btn-toggle-visibility" class="btn btn-ghost" type="button">Скрыть из списка</button>
</div>
</div>
<div class="test-detail-subsection">
<h3 class="test-detail-subsection__title">Кому выдать</h3>
<p class="test-detail-hint">Список с учётом поиска и фильтров; можно отметить всех на экране.</p>
<div class="assign-toolbar">
<input id="assign-search" class="form-input assign-toolbar__search" type="text" placeholder="Поиск: ФИО, логин" />
<select id="assign-dept" class="form-input"><option value="__all__">Все отделы</option></select>
<select id="assign-clinic" class="form-input">
<option value="all">Все</option>
<option value="with">С учёткой в модуле</option>
<option value="without">Без учётки (создадим при назначении)</option>
</select>
<button id="assign-select-all" class="btn btn-ghost btn--sm" type="button">Выбрать всех</button>
</div>
<div id="assign-list" class="assign-list"></div>
<div class="inline-actions" style="margin-top:0.75rem;">
<button id="assign-submit" class="btn btn-primary" type="button">Назначить выбранных</button>
<span id="assign-status" class="muted"></span>
</div>
</div>
</div>
</details>
</div>
{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #}
<div class="fixed bottom-0 inset-x-0 z-30 bg-white/95 backdrop-blur border-t border-ink-300/60
pb-[env(safe-area-inset-bottom)]">
<div class="mx-auto max-w-6xl px-4 py-3
<div class="mx-auto {% if ui_variant == 'legacy' %}max-w-2xl{% else %}max-w-6xl{% endif %} px-4 py-3
flex items-center justify-between gap-3">
<label class="inline-flex items-center gap-2 text-sm min-w-0">
<input id="chain-active" type="checkbox"
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
<span class="truncate">Цепочка активна</span>
</label>
<span class="text-xs text-ink-500 truncate">Активность цепочки и поля теста — в шапке.</span>
<div class="flex items-center gap-2 shrink-0">
<a href="{{ url_for('tests.tests_list_page') }}"
class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm">
class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
К каталогу
</a>
<button id="save-draft"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11">
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>
</div>
</div>
<p id="save-status" class="mx-auto max-w-6xl px-4 pb-2 text-xs text-ink-500"></p>
<p id="save-status" class="mx-auto {% if ui_variant == 'legacy' %}max-w-2xl{% else %}max-w-6xl{% endif %} px-4 pb-2 text-xs text-ink-500"></p>
</div>
{# ── Шаблон вопроса ─────────────────────────────────────────────── #}
<template id="tpl-question">
<li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item">
<li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item question-editor-block">
{# Шапка карточки вопроса: номер слева, кнопки справа. #}
<div class="flex items-center justify-between gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded-md
@@ -165,27 +218,28 @@
</div>
</div>
<textarea class="q-text mt-3 w-full rounded-lg border border-ink-300 px-3 py-2
<div class="question-editor-block__header">
<h4 class="question-editor-block__title q-num">Вопрос #</h4>
<button class="q-ai btn btn-ghost btn--sm question-editor-block__ai-btn">
Сгенерировать вопрос (ИИ)
</button>
</div>
<textarea class="q-text mt-2 w-full rounded-lg border border-ink-300 px-3 py-2
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
rows="2" placeholder="Формулировка вопроса"></textarea>
{# Тип ответа + AI — две полные строки на мобиле, в строку на sm+. #}
<div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm">
<label class="inline-flex items-center gap-2 min-h-9">
<input type="checkbox"
class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
<span>Несколько правильных ответов</span>
</label>
<button class="q-ai inline-flex items-center justify-center gap-1 px-2.5 py-2 rounded-lg
bg-brand-50 hover:bg-brand-100 text-brand-700 text-sm min-h-10">
<span class="material-symbols-outlined text-base">auto_awesome</span>
AI: вопрос/переформулировать
</button>
</div>
<ul class="q-options mt-3 space-y-2"></ul>
<button class="q-add-option mt-2 inline-flex items-center gap-1 px-2 py-2 rounded
text-sm text-brand-700 hover:bg-brand-50 min-h-10">
text-sm text-brand-700 hover:bg-brand-50 min-h-10 btn btn-ghost btn--sm">
<span class="material-symbols-outlined text-base">add</span>
Добавить вариант
</button>
@@ -194,19 +248,19 @@
{# ── Шаблон варианта ────────────────────────────────────────────── #}
<template id="tpl-option">
<li class="flex items-center gap-2 opt-item">
<li class="flex items-center gap-2 opt-item question-option-row">
{# Чекбокс «Правильный» — обёрнут в большой tap-target. #}
<label class="inline-flex items-center justify-center w-10 h-10 shrink-0 cursor-pointer
rounded hover:bg-ink-100" title="Правильный ответ">
<input type="checkbox"
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500 question-option-row__mark" />
</label>
<input type="text"
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2 question-option-row__text
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
placeholder="Вариант ответа" />
<button class="opt-delete shrink-0 w-10 h-10 inline-flex items-center justify-center
rounded hover:bg-red-50 text-red-600"
rounded hover:bg-red-50 text-red-600 question-option-remove"
title="Удалить" aria-label="Удалить вариант">
<span class="material-symbols-outlined text-base">close</span>
</button>
+139 -53
View File
@@ -2,66 +2,122 @@
{% block title %}Тесты — каталог{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1>
<p class="mt-1 text-sm text-ink-500">Активные тесты, к которым у вас есть доступ.</p>
{% if ui_variant == 'legacy' %}
<section class="legacy-list-shell">
<h1 class="font-headline legacy-list-title">Тесты</h1>
<div class="legacy-list-toolbar">
<button id="btn-create-test" class="btn btn-ghost" type="button">
Создать
</button>
</div>
<button id="btn-create-test"
class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white font-medium transition
min-h-11 w-full sm:w-auto">
<span class="material-symbols-outlined text-base">add</span>
Создать тест
</button>
</div>
{% if visible %}
<ul class="mt-5 grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{% for t in visible %}
<li class="rounded-xl border border-ink-300/60 hover:border-brand-300 hover:shadow-sm transition bg-white">
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
class="block p-4 active:bg-ink-100/40">
<div class="flex items-start justify-between gap-2">
<h3 class="font-semibold text-ink-900 line-clamp-2 min-w-0">{{ t.title }}</h3>
<span class="text-xs text-ink-500 shrink-0 mt-0.5">v{{ t.version }}</span>
{% if visible %}
<ul class="list-stack" aria-label="Тесты в общем списке">
{% for t in visible %}
<li class="list-row list-row--split">
<div class="list-row__main">
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
<span class="list-row__title">{{ t.title }}</span>
<span class="list-row__meta">
{{ t.author_full_name or '—' }}
<span class="list-row__meta-tail"> · v{{ t.version }}</span>
</span>
</a>
</div>
{% if t.description %}
<p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p>
{% endif %}
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500">
<span class="truncate">Автор: {{ t.author_full_name or '—' }}</span>
<span class="inline-flex items-center gap-1 text-brand-700">
<span class="material-symbols-outlined text-sm">edit_note</span>
Открыть
</span>
<div class="list-row__side">
<button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}">Пройти</button>
</div>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-5 text-ink-500 text-sm">Доступных тестов пока нет.</p>
{% endif %}
{% if hidden %}
<details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4">
<summary class="cursor-pointer font-medium text-ink-700">
Скрытые вами цепочки ({{ hidden|length }})
</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>
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
class="text-brand-700 hover:underline">Открыть</a>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</section>
{% else %}
<p class="text-muted">Нет тестов</p>
{% endif %}
{% if hidden %}
<h2 class="font-headline legacy-list-subtitle">Скрытые вами из списка</h2>
<ul class="list-stack" aria-label="Скрытые тесты автора">
{% for t in hidden %}
<li class="list-row list-row--split list-row--hidden">
<div class="list-row__main">
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
<span class="list-row__title">{{ t.title }}</span>
<span class="list-row__meta">
{{ t.author_full_name or '—' }}
<span class="list-row__meta-tail"> · v{{ t.version }} · скрыт</span>
</span>
</a>
</div>
<div class="list-row__side">
<button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}">Пройти</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% else %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1>
<p class="mt-1 text-sm text-ink-500">Активные тесты, к которым у вас есть доступ.</p>
</div>
<button id="btn-create-test"
class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white font-medium transition
min-h-11 w-full sm:w-auto">
<span class="material-symbols-outlined text-base">add</span>
Создать тест
</button>
</div>
{% if visible %}
<ul class="mt-5 grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{% for t in visible %}
<li class="rounded-xl border border-ink-300/60 hover:border-brand-300 hover:shadow-sm transition bg-white">
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
class="block p-4 active:bg-ink-100/40">
<div class="flex items-start justify-between gap-2">
<h3 class="font-semibold text-ink-900 line-clamp-2 min-w-0">{{ t.title }}</h3>
<span class="text-xs text-ink-500 shrink-0 mt-0.5">v{{ t.version }}</span>
</div>
{% if t.description %}
<p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p>
{% endif %}
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500">
<span class="truncate">Автор: {{ t.author_full_name or '—' }}</span>
<span class="inline-flex items-center gap-1 text-brand-700">
<span class="material-symbols-outlined text-sm">edit_note</span>
Открыть
</span>
</div>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-5 text-ink-500 text-sm">Доступных тестов пока нет.</p>
{% endif %}
{% if hidden %}
<details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4">
<summary class="cursor-pointer font-medium text-ink-700">
Скрытые вами цепочки ({{ hidden|length }})
</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>
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
class="text-brand-700 hover:underline">Открыть</a>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</section>
{% endif %}
<dialog id="dlg-create"
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-md sm:w-full sm:m-auto
@@ -136,6 +192,36 @@
alert(e.message || 'Не удалось создать тест.');
}
});
const passButtons = Array.from(document.querySelectorAll('.btn-start-pass'));
passButtons.forEach((btn) => {
btn.addEventListener('click', async () => {
const testId = btn.dataset.testId;
if (!testId) return;
btn.disabled = true;
const oldText = btn.textContent;
btn.textContent = '…';
try {
const r = await fetch(`/api/tests/${testId}/attempts/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
let data = {};
try { data = await r.json(); } catch (_) {}
if (!r.ok || !data.attempt || !data.attempt.id) {
// В Flask legacy контуре пока может отсутствовать отдельная UI-страница попытки.
// Тогда ведём в карточку теста, чтобы пользователь не попадал на not_found.
window.location.href = `/tests/${testId}/edit`;
return;
}
window.location.href = `/tests/${testId}/attempt/${data.attempt.id}`;
} catch (e) {
window.location.href = `/tests/${testId}/edit`;
return;
}
});
});
})();
</script>
{% endblock %}
+112
View File
@@ -42,6 +42,14 @@ from ..services.document_extract import (
from ..services.document_gen import generation_for_import_document
from ..services.draft_validator import LlmError
from ..services.editor_content import HttpError as EditorHttpError, get_editor_content
from ..services.test_attempt import (
HttpError as AttemptHttpError,
get_attempt_review_for_user,
get_play_content,
list_test_attempts_for_author,
start_attempt,
submit_attempt,
)
from ..services.test_access import is_test_author, list_hidden_by_author, list_visible_tests
from ..services.test_chain import has_any_attempt_for_test
from ..services.test_draft import (
@@ -150,6 +158,10 @@ def api_test_summary(test_id):
if not acc.ok:
return jsonify(error=RU['notFound']), 404
has_attempts = False
with eng.connect() as conn:
has_attempts = has_any_attempt_for_test(conn, test_id)
return jsonify(
test={
'id': str(row['id']),
@@ -163,6 +175,7 @@ def api_test_summary(test_id):
'updatedAt': row['updated_at'].isoformat() if row['updated_at'] else None,
'createdBy': str(row['created_by']) if row['created_by'] else None,
'authorFullName': row['author_full_name'],
'hasAttempts': bool(has_attempts),
},
isAuthor=is_author,
hasActiveVersion=row['active_version_id'] is not None,
@@ -293,6 +306,85 @@ def api_patch_test(test_id):
return jsonify(id=test_id, chainActive=chain)
@tests_bp.route('/api/tests/<test_id>/attempts/start', methods=['POST'])
@login_required
def api_start_attempt(test_id):
user = current_user()
eng = get_engine()
try:
out = start_attempt(eng, user.id, test_id)
except AttemptHttpError as e:
return jsonify(error=e.message), e.status
return jsonify(out), 201
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/play', methods=['GET'])
@login_required
def api_attempt_play(test_id, attempt_id):
user = current_user()
eng = get_engine()
try:
out = get_play_content(eng, user.id, test_id, attempt_id)
except AttemptHttpError as e:
return jsonify(error=e.message), e.status
return jsonify(out)
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/submit', methods=['POST'])
@login_required
def api_attempt_submit(test_id, attempt_id):
user = current_user()
eng = get_engine()
body = request.get_json(silent=True) or {}
try:
out = submit_attempt(eng, user.id, test_id, attempt_id, body.get('answers'))
except AttemptHttpError as e:
return jsonify(error=e.message), e.status
return jsonify(out)
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/review', methods=['GET'])
@login_required
def api_attempt_review(test_id, attempt_id):
user = current_user()
eng = get_engine()
try:
out = get_attempt_review_for_user(eng, user.id, test_id, attempt_id)
except AttemptHttpError as e:
return jsonify(error=e.message), e.status
return jsonify(out)
@tests_bp.route('/api/tests/<test_id>/attempts', methods=['GET'])
@login_required
def api_attempts_list(test_id):
user = current_user()
eng = get_engine()
try:
rows = list_test_attempts_for_author(eng, user.id, test_id)
except AttemptHttpError as e:
return jsonify(error=e.message), e.status
return jsonify(
attempts=[
{
'id': str(r['id']),
'userId': str(r['user_id']),
'status': r['status'],
'attemptNumber': r['attempt_number'],
'startedAt': r['started_at'].isoformat() if r['started_at'] else None,
'completedAt': r['completed_at'].isoformat() if r['completed_at'] else None,
'correctCount': r['correct_count'],
'totalQuestions': r['total_questions'],
'passed': r['passed'],
'testVersion': r['test_version'],
'attempterName': r['attempter_name'],
'attempterLogin': r['attempter_login'],
}
for r in rows
]
)
# ─── AI ──────────────────────────────────────────────────────────────
@tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST'])
@@ -463,3 +555,23 @@ def tests_editor_page(test_id):
return ('Доступ запрещён.', 403)
return render_template('500.html'), 500
return render_template('tests/editor.html', content=content, test_id=test_id)
@tests_bp.route('/tests/<test_id>/attempt/<attempt_id>', methods=['GET'])
@login_required
def tests_attempt_page(test_id, attempt_id):
return render_template('tests/attempt.html', test_id=test_id, attempt_id=attempt_id)
@tests_bp.route('/tests/<test_id>/attempts/<attempt_id>/review', methods=['GET'])
@login_required
def tests_attempt_review_page(test_id, attempt_id):
user = current_user()
eng = get_engine()
try:
review = get_attempt_review_for_user(eng, user.id, test_id, attempt_id)
except AttemptHttpError as e:
if e.status == 404:
return render_template('404.html'), 404
return (e.message, e.status)
return render_template('tests/attempt_review.html', test_id=test_id, review=review)