feat(flask): E1.0–E1.3, E1.8 — миграция на Python/Flask + AI v2

Этап 1 миграции TestingWebApp на целевой стек (Python/Flask/Jinja),
БД остаётся clinic_tests.

E1.0 — База Flask-приложения: SQLAlchemy/psycopg2 пул, Flask sessions,
фабрика create_app, blueprint main с / и /health, base.html в стиле
кабинета HR (Tailwind CDN + Manrope + Material Symbols), 404/500.

E1.1 — Auth + /api/me: Flask sessions (signed cookie) вместо JWT,
bcrypt + Werkzeug, опц. HR_AUTH=1 с UPSERT в clinic_tests.users по
staff_id. UI /login, JSON /api/auth/{login,logout,me}, декораторы
@login_required / @require_role.

E1.2 — Тесты: список + редактор. 10 эндпоинтов, сервисы test_draft,
test_access, test_chain, ai_editor, llm_client, draft_validator,
editor_content. UI /tests (каталог + создание) и /tests/<id>/edit
(редактор с AI). Полный мобильный UX (аккордеоны/drag-n-drop) — в E1.7.

E1.3 — Импорт документов: pypdf + python-docx, эндпоинт
POST /api/tests/import/document, кнопка «Импорт документа» в
AI-панели редактора, лимит 16 МБ.

E1.8 — AI v2: страница /settings (статус ENV-ключа + ping),
ai/generate-by-title (без сетки), ai/check (рецензия), ai/improve
(массовое было→стало с чекбоксами). Унифицированный ответ AI-ошибок:
{ error, code, settingsUrl }.

Docker:
- docker-compose.dev.yml: добавлены DATABASE_URL, HR_AUTH/HR_DATABASE_URL,
  DEEPSEEK_API_KEY/OPENAI_API_KEY/LLM_BASE_URL/LLM_MODEL и сеть postgres
  для testing-flask.

Документация:
- docs/migration-final.md — двух-этапный план (Этап 1: унификация
  стека внутри TestingWebApp; Этап 2: слияние с tgFlaskForm).
- docs/migration-final-inventory.md — карта 22 эндпоинтов Express.

Made-with: Cursor
This commit is contained in:
Константин Лебединский
2026-04-27 23:29:26 +05:00
parent 31b51b7768
commit 4b0d56ff0e
48 changed files with 4170 additions and 203 deletions
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}404 — страница не найдена{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center">
<span class="material-symbols-outlined text-5xl text-brand-600">search_off</span>
<h1 class="mt-2 text-xl font-semibold">Страница не найдена</h1>
<p class="mt-1 text-ink-500">Проверьте адрес или вернитесь на <a class="text-brand-600 hover:underline" href="{{ url_for('main.index') }}">главную</a>.</p>
</section>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}500 — внутренняя ошибка{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center">
<span class="material-symbols-outlined text-5xl text-red-600">error</span>
<h1 class="mt-2 text-xl font-semibold">Что-то пошло не так</h1>
<p class="mt-1 text-ink-500">Попробуйте обновить страницу. Если ошибка повторяется — посмотрите логи сервера.</p>
</section>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% 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>
</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>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{% block title %}Тестирование персонала{% endblock %}</title>
{# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #}
<script src="https://cdn.tailwindcss.com"></script>
<script>
// Минимальная палитра в стиле кабинета HR. Без зависимостей от HR-репо.
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Manrope', 'Inter', 'system-ui', 'sans-serif'],
},
colors: {
brand: {
50: '#eef2ff',
100: '#e0e7ff',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
},
ink: {
900: '#0f172a',
700: '#334155',
500: '#64748b',
300: '#cbd5e1',
100: '#f1f5f9',
},
},
},
},
};
</script>
<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"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined"
/>
<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-2 text-sm">
{% if current_user %}
<a href="{{ url_for('tests.tests_list_page') }}"
class="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-lg
text-ink-700 hover:bg-ink-100">
<span class="material-symbols-outlined text-base">list_alt</span>
Тесты
</a>
<a href="{{ url_for('settings.settings_page') }}"
class="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-lg
text-ink-700 hover:bg-ink-100">
<span class="material-symbols-outlined text-base">settings</span>
Настройки
</a>
{% endif %}
{% if current_user %}
<span class="hidden sm: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 gap-1 px-2 py-1 rounded-lg
text-ink-700 hover:bg-ink-100 transition">
<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-2 py-1 rounded-lg
text-brand-700 hover:bg-brand-50 transition">
<span class="material-symbols-outlined text-base">login</span>
Войти
</a>
{% endif %}
</nav>
</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>
{% block scripts %}{% endblock %}
</body>
</html>
+45 -9
View File
@@ -1,9 +1,45 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Тестирование</title>
</head>
<body></body>
</html>
{% extends "base.html" %}
{% block title %}Тестирование — главная{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
<h1 class="text-2xl font-semibold text-ink-900">Сервис тестирования персонала</h1>
<p class="mt-2 text-ink-500">
Этап 1 миграции: переход на единый стек (Flask + Jinja). Бизнес-функции
переносятся последовательно — авторизация, каталог тестов, редактор,
назначения, прохождение, импорт/AI.
</p>
<div class="mt-5 flex flex-wrap gap-2 text-sm">
<a href="{{ url_for('tests.tests_list_page') }}"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white transition">
<span class="material-symbols-outlined text-base">list_alt</span>
Каталог тестов
</a>
<a href="{{ url_for('main.health') }}"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-50 text-brand-700 hover:bg-brand-100 transition">
<span class="material-symbols-outlined text-base">monitoring</span>
Health-check
</a>
</div>
</section>
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mt-4">
{% for title, descr, icon in [
('Авторизация', 'E1.1 — логин по HR/локальному пользователю.', 'login'),
('Тесты', 'E1.2 — каталог, фильтры, карточки.', 'list_alt'),
('Редактор', 'E1.3 — создание/правка теста, AI-помощник.', 'edit_note'),
('Назначения', 'E1.4 — назначить сотрудникам, отслеживать.', 'assignment'),
('Прохождение', 'E1.5 — UI прохождения теста сотрудником.', 'fact_check'),
('Импорт/AI', 'E1.6 — генерация черновиков из документов.', 'auto_awesome'),
] %}
<article class="rounded-xl bg-white border border-ink-300/60 p-4">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-brand-600">{{ icon }}</span>
<h3 class="font-semibold">{{ title }}</h3>
</div>
<p class="mt-1 text-sm text-ink-500">{{ descr }}</p>
</article>
{% endfor %}
</section>
{% endblock %}
+101
View File
@@ -0,0 +1,101 @@
{% extends "base.html" %}
{% block title %}Настройки — LLM{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 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>
<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.
После изменения <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> нужен рестарт процесса.
</p>
<dl class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<dt class="text-ink-500">Статус ключа</dt>
<dd>
{% if configured %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-green-50 text-green-700 border border-green-200">
<span class="material-symbols-outlined text-base">check_circle</span> Задан
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-50 text-red-700 border border-red-200">
<span class="material-symbols-outlined text-base">error</span> Не задан
</span>
{% endif %}
</dd>
<dt class="text-ink-500">Провайдер</dt>
<dd>{{ provider or '—' }}</dd>
<dt class="text-ink-500">Модель</dt>
<dd>{{ model or '—' }}</dd>
<dt class="text-ink-500">Base URL</dt>
<dd class="break-all">{{ base_url or '—' }}</dd>
</dl>
{% if not configured %}
<div class="mt-5 rounded-lg bg-ink-100/60 border border-ink-300/60 p-4 text-sm">
<p class="font-medium">Как задать ключ</p>
<pre class="mt-2 text-xs whitespace-pre-wrap font-mono">DEEPSEEK_API_KEY=sk-...
# либо
OPENAI_API_KEY=sk-...
# опционально:
# LLM_BASE_URL=https://api.deepseek.com/v1
# LLM_MODEL=deepseek-chat</pre>
<p class="mt-2 text-ink-500">
Файл: <code>flask_app/.env</code>. После сохранения — рестарт процесса.
</p>
</div>
{% endif %}
<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">
<span class="material-symbols-outlined text-base">cable</span>
Проверить подключение
</button>
<span id="ping-status" class="text-sm text-ink-500"></span>
</div>
<div id="ping-result" class="mt-4 hidden text-sm rounded-lg p-3 border"></div>
</section>
{% endblock %}
{% block scripts %}
<script>
(() => {
const btn = document.getElementById('btn-ping');
const status = document.getElementById('ping-status');
const result = document.getElementById('ping-result');
btn.addEventListener('click', async () => {
status.textContent = 'Запрос…';
btn.disabled = true;
try {
const r = await fetch('/api/llm/ping', { method: 'POST' });
const d = await r.json();
result.classList.remove('hidden', 'bg-green-50', 'border-green-200', 'text-green-800',
'bg-red-50', 'border-red-200', 'text-red-800');
if (d.ok) {
result.classList.add('bg-green-50', 'border-green-200', 'text-green-800');
result.innerHTML = `<b>OK</b> · ${d.provider} / ${d.model} · ${d.latencyMs} мс`
+ (d.sample ? `<br><span class="text-xs opacity-80">Ответ: ${d.sample.replace(/</g,'&lt;')}</span>` : '');
} else {
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800');
result.innerHTML = `<b>Ошибка</b> · ${d.code || ''}<br>${(d.error || '').replace(/</g,'&lt;')}`;
}
} catch (e) {
result.classList.remove('hidden');
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800');
result.textContent = e.message || 'Сбой запроса.';
} finally {
btn.disabled = false;
status.textContent = '';
}
});
})();
</script>
{% endblock %}
+191
View File
@@ -0,0 +1,191 @@
{% extends "base.html" %}
{% block title %}{{ content.test.title }} — редактор{% endblock %}
{% block content %}
<div id="editor-root"
data-test-id="{{ test_id }}"
data-initial='{{ content | tojson | safe }}'>
<!-- Шапка: название/описание/проходной балл -->
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-5">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="flex-1 min-w-[260px]">
<label class="block">
<span class="text-xs font-medium text-ink-500 uppercase">Название</span>
<input id="test-title" type="text" maxlength="200"
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 text-lg font-semibold
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>
<label class="block mt-3">
<span class="text-xs font-medium text-ink-500 uppercase">Описание</span>
<textarea id="test-description" rows="2"
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>
</div>
<div class="w-44">
<label class="block">
<span class="text-xs font-medium text-ink-500 uppercase">Проходной балл, %</span>
<input id="test-threshold" type="number" min="0" max="100" step="1"
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" />
</label>
</div>
</div>
</section>
<!-- AI-панель -->
<section class="mt-4 rounded-2xl bg-brand-50/60 border border-brand-100 p-4">
<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>
<p class="mt-1 text-sm text-ink-700">
Сгенерировать вопросы по текущей сетке (число вопросов и вариантов берётся из таблицы ниже).
</p>
<div class="mt-3 flex flex-wrap gap-2 items-center">
<button id="ai-generate-test"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white text-sm">
<span class="material-symbols-outlined text-base">stars</span>
Сгенерировать по сетке
</button>
<button id="ai-generate-by-title"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg
bg-white border border-brand-300/60 text-brand-700 hover:bg-brand-50 text-sm">
<span class="material-symbols-outlined text-base">edit_note</span>
Сгенерировать по названию
</button>
<button id="ai-check"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300 text-sm">
<span class="material-symbols-outlined text-base">fact_check</span>
Проверить тест
</button>
<button id="ai-improve"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300 text-sm">
<span class="material-symbols-outlined text-base">tune</span>
Улучшить тест
</button>
<label class="inline-flex items-center gap-2 px-3 py-2 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300 text-sm cursor-pointer">
<span class="material-symbols-outlined text-base text-brand-600">upload_file</span>
<span>Импорт документа</span>
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" />
</label>
<span id="ai-status" class="text-sm text-ink-500"></span>
</div>
<p class="mt-2 text-xs text-ink-500">
Поддерживаются PDF, DOCX, TXT, MD (до 16 МБ). AI извлечёт текст и предложит черновик теста.
</p>
</section>
<!-- Список вопросов -->
<section class="mt-4">
<div class="flex items-center justify-between gap-2 px-1">
<h2 class="font-semibold">Вопросы (<span id="q-count">0</span>)</h2>
<button id="add-question"
class="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg
bg-white border border-ink-300/60 hover:border-brand-300 text-sm">
<span class="material-symbols-outlined text-base">add</span>
Добавить вопрос
</button>
</div>
<ol id="questions" class="mt-3 space-y-3"></ol>
</section>
<!-- Footer: сохранение / активность цепочки -->
<section class="sticky bottom-0 z-20 mt-6 -mx-4 px-4 py-3
bg-white/90 backdrop-blur border-t border-ink-300/60
flex items-center justify-between gap-2 flex-wrap">
<label class="inline-flex items-center gap-2 text-sm">
<input id="chain-active" type="checkbox"
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
<span>Цепочка активна (виден в каталоге)</span>
</label>
<div class="flex items-center gap-2">
<span id="save-status" class="text-sm text-ink-500"></span>
<a href="{{ url_for('tests.tests_list_page') }}"
class="px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm">К каталогу</a>
<button id="save-draft"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white">
<span class="material-symbols-outlined text-base">save</span>
Сохранить
</button>
</div>
</section>
</div>
<!-- Шаблон вопроса -->
<template id="tpl-question">
<li class="rounded-xl bg-white border border-ink-300/60 p-4 q-item">
<div class="flex items-start justify-between gap-2">
<span class="text-xs uppercase tracking-wide text-ink-500 q-num">Вопрос #</span>
<div class="flex items-center gap-1">
<button class="q-up p-1 rounded hover:bg-ink-100" title="Выше">
<span class="material-symbols-outlined text-base">arrow_upward</span>
</button>
<button class="q-down p-1 rounded hover:bg-ink-100" title="Ниже">
<span class="material-symbols-outlined text-base">arrow_downward</span>
</button>
<button class="q-delete p-1 rounded hover:bg-red-50 text-red-600" title="Удалить">
<span class="material-symbols-outlined text-base">delete</span>
</button>
</div>
</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>
<div class="mt-2 flex items-center justify-between gap-2 flex-wrap text-sm">
<label class="inline-flex items-center gap-2">
<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 gap-1 px-2 py-1 rounded-lg
bg-brand-50 hover:bg-brand-100 text-brand-700">
<span class="material-symbols-outlined text-base">auto_awesome</span>
AI: вопрос/переформулировать
</button>
</div>
<ul class="q-options mt-3 space-y-2"></ul>
<div class="mt-2">
<button class="q-add-option inline-flex items-center gap-1 text-sm text-brand-700 hover:underline">
<span class="material-symbols-outlined text-base">add</span> Добавить вариант
</button>
</div>
</li>
</template>
<!-- Модалка результата AI-проверки/улучшения -->
<dialog id="ai-modal" class="rounded-2xl p-0 max-w-3xl w-full backdrop:bg-black/40">
<div class="p-5">
<div class="flex items-center justify-between gap-3">
<h3 id="ai-modal-title" class="text-lg font-semibold">AI</h3>
<button id="ai-modal-close" class="p-1 rounded hover:bg-ink-100">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div id="ai-modal-body" class="mt-3 max-h-[70vh] overflow-y-auto"></div>
<div id="ai-modal-actions" class="mt-4 flex items-center justify-end gap-2"></div>
</div>
</dialog>
<template id="tpl-option">
<li class="flex items-center gap-2 opt-item">
<input type="checkbox" class="opt-correct rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
<input type="text" class="opt-text flex-1 rounded-lg border border-ink-300 px-3 py-1.5
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
placeholder="Вариант ответа" />
<button class="opt-delete p-1 rounded hover:bg-red-50 text-red-600" title="Удалить">
<span class="material-symbols-outlined text-base">close</span>
</button>
</li>
</template>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/editor.js') }}"></script>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}Тесты — каталог{% endblock %}
{% block content %}
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 class="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 gap-2 px-4 py-2 rounded-lg
bg-brand-600 hover:bg-brand-700 text-white font-medium transition">
<span class="material-symbols-outlined text-base">add</span>
Создать тест
</button>
</div>
{% if visible %}
<ul class="mt-5 grid gap-3 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 p-4 bg-white">
<div class="flex items-start justify-between gap-2">
<h3 class="font-semibold text-ink-900 line-clamp-2">{{ t.title }}</h3>
<span class="text-xs text-ink-500 shrink-0">v{{ t.version }}</span>
</div>
{% if t.description %}
<p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p>
{% endif %}
<p class="mt-2 text-xs text-ink-500">Автор: {{ t.author_full_name or '—' }}</p>
<div class="mt-3 flex items-center gap-2">
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}"
class="inline-flex items-center gap-1 text-sm text-brand-700 hover:underline">
<span class="material-symbols-outlined text-base">edit_note</span>
Открыть редактор
</a>
</div>
</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>
<dialog id="dlg-create" class="rounded-2xl p-0 backdrop:bg-ink-900/40 max-w-md w-full">
<form method="dialog" class="bg-white rounded-2xl">
<div class="p-5 border-b border-ink-300/60">
<h2 class="text-lg font-semibold">Новый тест</h2>
</div>
<div class="p-5 space-y-3">
<label class="block">
<span class="text-sm font-medium text-ink-700">Название</span>
<input id="new-test-title" type="text" required maxlength="200"
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" />
</label>
<label class="block">
<span class="text-sm font-medium text-ink-700">Описание (опц.)</span>
<textarea id="new-test-desc" rows="3"
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>
</div>
<div class="p-4 border-t border-ink-300/60 flex justify-end gap-2 bg-ink-100/40 rounded-b-2xl">
<button type="button" id="dlg-cancel"
class="px-4 py-2 rounded-lg text-ink-700 hover:bg-ink-100">Отмена</button>
<button type="button" id="dlg-submit"
class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white">
Создать
</button>
</div>
</form>
</dialog>
{% endblock %}
{% block scripts %}
<script>
(() => {
const dlg = document.getElementById('dlg-create');
const titleEl = document.getElementById('new-test-title');
const descEl = document.getElementById('new-test-desc');
document.getElementById('btn-create-test').addEventListener('click', () => {
titleEl.value = '';
descEl.value = '';
if (typeof dlg.showModal === 'function') dlg.showModal();
else dlg.setAttribute('open', 'open');
setTimeout(() => titleEl.focus(), 50);
});
document.getElementById('dlg-cancel').addEventListener('click', () => dlg.close());
document.getElementById('dlg-submit').addEventListener('click', async () => {
const title = titleEl.value.trim();
if (!title) { titleEl.focus(); return; }
try {
const r = await fetch('/api/tests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: descEl.value.trim() || null }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось создать тест.');
window.location.href = `/tests/${data.testId}/edit`;
} catch (e) {
alert(e.message || 'Не удалось создать тест.');
}
});
})();
</script>
{% endblock %}