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:
@@ -0,0 +1,2 @@
|
||||
"""Blueprint `tests`: JSON API (`/api/tests/*`) и UI (`/tests`, `/tests/<id>/edit`)."""
|
||||
from .routes import tests_bp # noqa: F401
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Маршруты тестов (E1.2).
|
||||
|
||||
Покрытие Express → Flask:
|
||||
- GET /api/tests/ — каталог + hidden by you
|
||||
- POST /api/tests/ — создать тест (цепочку с версией 1)
|
||||
- GET /api/tests/<id>/summary — краткая карточка
|
||||
- GET /api/tests/<id>/versions — список версий + hasAttempts
|
||||
- GET /api/tests/<id>/editor — контент редактора
|
||||
- POST /api/tests/<id>/draft — saveTestDraft (fork если нужно)
|
||||
- POST /api/tests/<id>/versions/<vid>/activate
|
||||
- PATCH /api/tests/<id> — chainActive
|
||||
- POST /api/tests/<id>/ai/generate-test
|
||||
- POST /api/tests/<id>/ai/generate-question
|
||||
|
||||
UI-страницы:
|
||||
- GET /tests — каталог
|
||||
- GET /tests/<id>/edit — редактор (вызывает /api/tests/...)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..auth.decorators import current_user, login_required
|
||||
from ..db import get_engine
|
||||
from ..messages import RU
|
||||
from ..services.ai_editor import (
|
||||
HttpError as AiHttpError,
|
||||
check_test_quality,
|
||||
generate_full_test_by_shape,
|
||||
generate_or_rephrase_question,
|
||||
generate_test_by_title,
|
||||
improve_test_full,
|
||||
parse_and_validate_shape,
|
||||
)
|
||||
from ..services.document_extract import (
|
||||
HttpError as DocExtractHttpError,
|
||||
extract_text_from_file,
|
||||
)
|
||||
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_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 (
|
||||
HttpError as DraftHttpError,
|
||||
create_test_with_version,
|
||||
save_test_draft,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
tests_bp = Blueprint('tests', __name__)
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _stringify_uuids(d: dict) -> dict:
|
||||
"""Преобразует UUID-поля в строки для безопасной JSON-сериализации."""
|
||||
out = {}
|
||||
for k, v in d.items():
|
||||
if hasattr(v, 'hex') and not isinstance(v, (str, bytes)):
|
||||
out[k] = str(v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _check_test_author_or_404(test_id: str, user_id: str) -> dict:
|
||||
"""Загружает {id, created_by}; 404 если нет, 403 если не автор."""
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text('SELECT id, created_by FROM tests WHERE id = :id'),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
raise NotFound(RU['notFound'])
|
||||
if not is_test_author(row['created_by'], user_id):
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
raise Forbidden('Доступ запрещён.')
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ─── JSON API ────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/', methods=['GET'])
|
||||
@tests_bp.route('/api/tests', methods=['GET'])
|
||||
@login_required
|
||||
def api_list_tests():
|
||||
user = current_user()
|
||||
visible = list_visible_tests(user.id)
|
||||
hidden = list_hidden_by_author(user.id)
|
||||
return jsonify(
|
||||
tests=[_stringify_uuids(r) for r in visible],
|
||||
hiddenByYou=[_stringify_uuids(r) for r in hidden],
|
||||
)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/', methods=['POST'])
|
||||
@tests_bp.route('/api/tests', methods=['POST'])
|
||||
@login_required
|
||||
def api_create_test():
|
||||
user = current_user()
|
||||
body = request.get_json(silent=True) or {}
|
||||
title = body.get('title')
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
return jsonify(error='Укажите название.'), 400
|
||||
out = create_test_with_version(user.id, title=title.strip(), description=body.get('description'))
|
||||
return jsonify(out), 201
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/summary', methods=['GET'])
|
||||
@login_required
|
||||
def api_test_summary(test_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active,
|
||||
t.created_by, t.created_at, t.updated_at,
|
||||
tv.id AS active_version_id, tv.version,
|
||||
u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
LEFT JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
||||
LEFT JOIN users u ON u.id = t.created_by
|
||||
WHERE t.id = :id
|
||||
"""
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
is_author = is_test_author(row['created_by'], user.id)
|
||||
if not row['chain_active'] and not is_author:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
if not is_author:
|
||||
from ..services.test_access import user_has_test_access
|
||||
|
||||
acc = user_has_test_access(user.id, test_id)
|
||||
if not acc.ok:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
return jsonify(
|
||||
test={
|
||||
'id': str(row['id']),
|
||||
'title': row['title'],
|
||||
'description': row['description'],
|
||||
'passingThreshold': row['passing_threshold'],
|
||||
'chainActive': row['chain_active'],
|
||||
'activeVersionId': str(row['active_version_id']) if row['active_version_id'] else None,
|
||||
'version': row['version'],
|
||||
'createdAt': row['created_at'].isoformat() if row['created_at'] else None,
|
||||
'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'],
|
||||
},
|
||||
isAuthor=is_author,
|
||||
hasActiveVersion=row['active_version_id'] is not None,
|
||||
)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/versions', methods=['GET'])
|
||||
@login_required
|
||||
def api_test_versions(test_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
t = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at,
|
||||
t.description, u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
INNER JOIN users u ON u.id = t.created_by
|
||||
WHERE t.id = :id
|
||||
"""
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not t:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
if not is_test_author(t['created_by'], user.id):
|
||||
return jsonify(error='Доступ запрещён.'), 403
|
||||
|
||||
rows = conn.execute(
|
||||
text(
|
||||
'SELECT id, version, is_active, parent_id, created_at '
|
||||
'FROM test_versions WHERE test_id = :id ORDER BY version'
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().all()
|
||||
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||
|
||||
return jsonify(
|
||||
test={
|
||||
'id': str(t['id']),
|
||||
'title': t['title'],
|
||||
'description': t['description'],
|
||||
'chainActive': t['is_active'],
|
||||
'createdAt': t['created_at'].isoformat() if t['created_at'] else None,
|
||||
'updatedAt': t['updated_at'].isoformat() if t['updated_at'] else None,
|
||||
'createdBy': str(t['created_by']) if t['created_by'] else None,
|
||||
'authorFullName': t['author_full_name'],
|
||||
},
|
||||
versions=[
|
||||
{
|
||||
'id': str(r['id']),
|
||||
'version': r['version'],
|
||||
'is_active': r['is_active'],
|
||||
'parent_id': str(r['parent_id']) if r['parent_id'] else None,
|
||||
'created_at': r['created_at'].isoformat() if r['created_at'] else None,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
hasAttempts=has_attempts,
|
||||
)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/editor', methods=['GET'])
|
||||
@login_required
|
||||
def api_test_editor(test_id):
|
||||
user = current_user()
|
||||
try:
|
||||
out = get_editor_content(user.id, test_id)
|
||||
except EditorHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/draft', methods=['POST'])
|
||||
@login_required
|
||||
def api_save_draft(test_id):
|
||||
user = current_user()
|
||||
payload = request.get_json(silent=True) or {}
|
||||
try:
|
||||
out = save_test_draft(user.id, test_id, payload)
|
||||
except DraftHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/versions/<version_id>/activate', methods=['POST'])
|
||||
@login_required
|
||||
def api_activate_version(test_id, version_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
v = conn.execute(
|
||||
text('SELECT id FROM test_versions WHERE test_id = :t AND id = :v'),
|
||||
{'t': test_id, 'v': version_id},
|
||||
).first()
|
||||
if not v:
|
||||
return jsonify(error='Версия не найдена.'), 404
|
||||
conn.execute(
|
||||
text('UPDATE test_versions SET is_active = false WHERE test_id = :t'),
|
||||
{'t': test_id},
|
||||
)
|
||||
conn.execute(
|
||||
text('UPDATE test_versions SET is_active = true WHERE id = :v'),
|
||||
{'v': version_id},
|
||||
)
|
||||
return jsonify(ok=True, activeVersionId=str(version_id))
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>', methods=['PATCH'])
|
||||
@login_required
|
||||
def api_patch_test(test_id):
|
||||
user = current_user()
|
||||
body = request.get_json(silent=True) or {}
|
||||
chain = body.get('chainActive', body.get('isActive'))
|
||||
if not isinstance(chain, bool):
|
||||
return jsonify(error='Передайте chainActive: true/false в теле запроса.'), 400
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
'UPDATE tests SET is_active = :v, updated_at = CURRENT_TIMESTAMP WHERE id = :id'
|
||||
),
|
||||
{'v': chain, 'id': test_id},
|
||||
)
|
||||
return jsonify(id=test_id, chainActive=chain)
|
||||
|
||||
|
||||
# ─── AI ──────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_generate_test(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
shape = parse_and_validate_shape(body.get('shape'))
|
||||
draft = generate_full_test_by_shape(
|
||||
body.get('testTitle') or '',
|
||||
body.get('testDescription') or '',
|
||||
shape,
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, draft=draft)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-question', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_generate_question(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
out = generate_or_rephrase_question(
|
||||
body.get('testTitle') or '',
|
||||
body.get('testDescription') or '',
|
||||
body.get('questionText') or '',
|
||||
body.get('optionsCount'),
|
||||
bool(body.get('hasMultipleAnswers')),
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, **out)
|
||||
|
||||
|
||||
# ─── AI v2 (E1.8) ────────────────────────────────────────────────────
|
||||
|
||||
def _ai_error_response(e):
|
||||
"""Единый JSON-формат ошибки для AI-эндпоинтов."""
|
||||
if isinstance(e, AiHttpError):
|
||||
return jsonify(error=e.message), e.status
|
||||
if isinstance(e, LlmError):
|
||||
log.warning('LLM error: %s (%s)', e, e.code)
|
||||
return (
|
||||
jsonify(error=str(e), code=e.code, settingsUrl='/settings'),
|
||||
e.status or 502,
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-by-title', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_generate_by_title(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
title = (body.get('testTitle') or '').strip()
|
||||
if not title:
|
||||
return jsonify(error='Заполните название теста.'), 400
|
||||
try:
|
||||
draft = generate_test_by_title(
|
||||
title,
|
||||
body.get('testDescription') or '',
|
||||
int(body.get('questionsCount') or 10),
|
||||
int(body.get('optionsCount') or 4),
|
||||
bool(body.get('hasMultipleAnswers')),
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, draft=draft)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/check', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_check_test(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
review = check_test_quality(
|
||||
body.get('testTitle') or '',
|
||||
body.get('testDescription') or '',
|
||||
body.get('questions') or [],
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, review=review)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/improve', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_improve_test(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
out = improve_test_full(
|
||||
body.get('testTitle') or '',
|
||||
body.get('testDescription') or '',
|
||||
body.get('questions') or [],
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, **out)
|
||||
|
||||
|
||||
# ─── Импорт документа (E1.3) ────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/import/document', methods=['POST'])
|
||||
@login_required
|
||||
def api_import_document():
|
||||
"""PDF/DOCX/TXT/MD → извлечённый текст + AI-черновик (если задан LLM-ключ).
|
||||
|
||||
Ограничения: размер файла — `MAX_CONTENT_LENGTH = 16 МБ` (см. фабрику).
|
||||
"""
|
||||
f = request.files.get('file')
|
||||
if f is None or not f.filename:
|
||||
return jsonify(error='Прикрепите файл к полю file.'), 400
|
||||
try:
|
||||
extracted = extract_text_from_file(f.mimetype, f, f.filename)
|
||||
except DocExtractHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
except Exception:
|
||||
log.exception('extract_text_from_file failed')
|
||||
return jsonify(error='Не удалось разобрать файл.'), 500
|
||||
|
||||
generation = generation_for_import_document(extracted)
|
||||
return jsonify(
|
||||
received=True,
|
||||
originalName=f.filename,
|
||||
mime=f.mimetype,
|
||||
size=len(extracted.encode('utf-8')),
|
||||
extractedText=extracted,
|
||||
textLength=len(extracted),
|
||||
generation=generation,
|
||||
)
|
||||
|
||||
|
||||
# ─── UI (Jinja) ──────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/tests', methods=['GET'])
|
||||
@login_required
|
||||
def tests_list_page():
|
||||
user = current_user()
|
||||
visible = list_visible_tests(user.id)
|
||||
hidden = list_hidden_by_author(user.id)
|
||||
return render_template(
|
||||
'tests/list.html',
|
||||
visible=[_stringify_uuids(r) for r in visible],
|
||||
hidden=[_stringify_uuids(r) for r in hidden],
|
||||
)
|
||||
|
||||
|
||||
@tests_bp.route('/tests/<test_id>/edit', methods=['GET'])
|
||||
@login_required
|
||||
def tests_editor_page(test_id):
|
||||
user = current_user()
|
||||
try:
|
||||
content = get_editor_content(user.id, test_id)
|
||||
except EditorHttpError as e:
|
||||
if e.status == 404:
|
||||
return render_template('404.html'), 404
|
||||
if e.status == 403:
|
||||
return ('Доступ запрещён.', 403)
|
||||
return render_template('500.html'), 500
|
||||
return render_template('tests/editor.html', content=content, test_id=test_id)
|
||||
Reference in New Issue
Block a user