"""Маршруты тестов (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 import uuid as _uuid from flask import Blueprint, jsonify, render_template, request from sqlalchemy import func from sqlalchemy.orm import selectinload from ..auth.decorators import current_user, login_required from ..db import get_session from ..messages import RU from ..models import Test, TestVersion, User 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, check_question_for_attempt, count_missing_hints, generate_missing_hints_for_test, 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: 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 _to_uuid(val) -> _uuid.UUID | None: if isinstance(val, _uuid.UUID): return val try: return _uuid.UUID(str(val)) except (ValueError, AttributeError): return None def _check_test_author_or_404(test_id: str, user_id: str) -> Test: session = get_session() tid = _to_uuid(test_id) test = session.get(Test, tid) if tid else None if not test: from werkzeug.exceptions import NotFound raise NotFound(RU['notFound']) if not is_test_author(test.created_by, user_id): from werkzeug.exceptions import Forbidden raise Forbidden('Доступ запрещён.') return test # ─── 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() session = get_session() tid = _to_uuid(test_id) if not tid: return jsonify(error=RU['notFound']), 404 test = ( session.query(Test) .options(selectinload(Test.author), selectinload(Test.versions)) .filter(Test.id == tid) .first() ) if not test: return jsonify(error=RU['notFound']), 404 is_author = is_test_author(test.created_by, user.id) if not test.is_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 active_version = next((v for v in test.versions if v.is_active), None) has_attempts = has_any_attempt_for_test(session, tid) return jsonify( test={ 'id': str(test.id), 'title': test.title, 'description': test.description, 'passingThreshold': test.passing_threshold, 'chainActive': test.is_active, 'activeVersionId': str(active_version.id) if active_version else None, 'version': active_version.version if active_version else None, 'createdAt': test.created_at.isoformat() if test.created_at else None, 'updatedAt': test.updated_at.isoformat() if test.updated_at else None, 'createdBy': str(test.created_by) if test.created_by else None, 'authorFullName': test.author.full_name if test.author else None, 'hasAttempts': bool(has_attempts), }, isAuthor=is_author, hasActiveVersion=active_version is not None, ) @tests_bp.route('/api/tests//versions', methods=['GET']) @login_required def api_test_versions(test_id): user = current_user() session = get_session() tid = _to_uuid(test_id) if not tid: return jsonify(error=RU['notFound']), 404 test = ( session.query(Test) .options(selectinload(Test.author), selectinload(Test.versions)) .filter(Test.id == tid) .first() ) if not test: return jsonify(error=RU['notFound']), 404 if not is_test_author(test.created_by, user.id): return jsonify(error='Доступ запрещён.'), 403 has_attempts = has_any_attempt_for_test(session, tid) sorted_versions = sorted(test.versions, key=lambda v: v.version) return jsonify( test={ 'id': str(test.id), 'title': test.title, 'description': test.description, 'chainActive': test.is_active, 'createdAt': test.created_at.isoformat() if test.created_at else None, 'updatedAt': test.updated_at.isoformat() if test.updated_at else None, 'createdBy': str(test.created_by) if test.created_by else None, 'authorFullName': test.author.full_name if test.author else None, }, versions=[ { 'id': str(v.id), 'version': v.version, 'is_active': v.is_active, 'parent_id': str(v.parent_id) if v.parent_id else None, 'created_at': v.created_at.isoformat() if v.created_at else None, } for v in sorted_versions ], 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) session = get_session() tid = _to_uuid(test_id) vid = _to_uuid(version_id) version = session.query(TestVersion).filter( TestVersion.test_id == tid, TestVersion.id == vid ).first() if not version: return jsonify(error='Версия не найдена.'), 404 session.query(TestVersion).filter(TestVersion.test_id == tid).update( {TestVersion.is_active: False}, synchronize_session='fetch' ) version.is_active = True session.commit() 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 test = _check_test_author_or_404(test_id, user.id) session = get_session() test.is_active = chain session.commit() 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() try: out = start_attempt(None, 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() try: out = get_play_content(None, 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() body = request.get_json(silent=True) or {} try: out = submit_attempt(None, 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//ai/hints/status', methods=['GET']) @login_required def api_test_hints_status(test_id): out = count_missing_hints(None, test_id) return jsonify(out) @tests_bp.route('/api/tests//ai/hints/generate', methods=['POST']) @login_required def api_test_hints_generate(test_id): user = current_user() try: out = generate_missing_hints_for_test(None, user.id, test_id) except AttemptHttpError as e: return jsonify(error=e.message), e.status return jsonify(out) @tests_bp.route('/api/tests//attempts//check', methods=['POST']) @login_required def api_attempt_check_question(test_id, attempt_id): user = current_user() body = request.get_json(silent=True) or {} qid = body.get('questionId') sel = body.get('selectedOptionIds') or [] if not qid: return jsonify(error='questionId обязателен.'), 400 if not isinstance(sel, list): return jsonify(error='selectedOptionIds должен быть массивом.'), 400 try: out = check_question_for_attempt(None, user.id, test_id, attempt_id, qid, sel) 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() try: out = get_attempt_review_for_user(None, 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() try: rows = list_test_attempts_for_author(None, 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')), mode=body.get('mode'), existing_options=body.get('existingOptions'), ) except (AiHttpError, LlmError) as e: return _ai_error_response(e) return jsonify(ok=True, **out) def _ai_error_response(e): 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(): """Шаг 1: загрузить файл и извлечь текст. Генерация — отдельным запросом.""" 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 return jsonify( received=True, originalName=f.filename, mime=f.mimetype, size=len(extracted.encode('utf-8')), extractedText=extracted, textLength=len(extracted), ) @tests_bp.route('/api/tests/generate-from-extracted', methods=['POST']) @login_required def api_generate_from_extracted(): """Шаг 2: сгенерировать черновик теста из ранее извлечённого текста + подсказки автора.""" body = request.get_json(silent=True) or {} extracted = (body.get('extractedText') or '').strip() user_hint = (body.get('userHint') or '').strip() shape_raw = body.get('shape') if not extracted: return jsonify(error='Нет текста для генерации.'), 400 shape = None if shape_raw: try: shape = parse_and_validate_shape(shape_raw) except (AiHttpError, LlmError) as e: return _ai_error_response(e) generation = generation_for_import_document(extracted, user_hint=user_hint, shape=shape) return jsonify(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() try: review = get_attempt_review_for_user(None, 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)