4b0d56ff0e
Этап 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
129 lines
5.4 KiB
HTML
129 lines
5.4 KiB
HTML
{% 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 %}
|