"""Маршруты тестов (E1.2). Покрытие Express → Flask: - GET /api/tests/ — каталог + hidden by you - POST /api/tests/ — создать тест (цепочку с версией 1) - GET /api/tests//summary — краткая карточка - GET /api/tests//versions — список версий + hasAttempts - GET /api/tests//editor — контент редактора - POST /api/tests//draft — saveTestDraft (fork если нужно) - POST /api/tests//versions//activate - PATCH /api/tests/ — chainActive - POST /api/tests//ai/generate-test - POST /api/tests//ai/generate-question UI-страницы: - GET /tests — каталог - GET /tests//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_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 ( 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//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 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']), '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'], 'hasAttempts': bool(has_attempts), }, isAuthor=is_author, hasActiveVersion=row['active_version_id'] is not None, ) @tests_bp.route('/api/tests//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//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//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//versions//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/', 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) @tests_bp.route('/api/tests//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//attempts//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//attempts//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//attempts//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//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//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//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//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//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//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//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) @tests_bp.route('/tests//attempt/', 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//attempts//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)