блоки 2 и 3 доработки интерфейса системы тестирования
This commit is contained in:
+166
-143
@@ -19,13 +19,16 @@ UI-страницы:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid as _uuid
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..auth.decorators import current_user, login_required
|
||||
from ..db import get_engine
|
||||
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,
|
||||
@@ -44,6 +47,9 @@ 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,
|
||||
@@ -63,10 +69,9 @@ log = logging.getLogger(__name__)
|
||||
tests_bp = Blueprint('tests', __name__)
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────
|
||||
# ─── 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)):
|
||||
@@ -76,26 +81,29 @@ def _stringify_uuids(d: dict) -> dict:
|
||||
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:
|
||||
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(row['created_by'], user_id):
|
||||
if not is_test_author(test.created_by, user_id):
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
raise Forbidden('Доступ запрещён.')
|
||||
return dict(row)
|
||||
return test
|
||||
|
||||
|
||||
# ─── JSON API ────────────────────────────────────────────────────────
|
||||
# ─── JSON API ────────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/', methods=['GET'])
|
||||
@tests_bp.route('/api/tests', methods=['GET'])
|
||||
@@ -127,58 +135,50 @@ def api_create_test():
|
||||
@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:
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
if not tid:
|
||||
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:
|
||||
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
|
||||
|
||||
has_attempts = False
|
||||
with eng.connect() as conn:
|
||||
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||
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(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'],
|
||||
'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=row['active_version_id'] is not None,
|
||||
hasActiveVersion=active_version is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -186,54 +186,45 @@ def api_test_summary(test_id):
|
||||
@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
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
if not tid:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
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)
|
||||
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(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'],
|
||||
'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(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,
|
||||
'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 r in rows
|
||||
for v in sorted_versions
|
||||
],
|
||||
hasAttempts=has_attempts,
|
||||
)
|
||||
@@ -267,22 +258,22 @@ def api_save_draft(test_id):
|
||||
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},
|
||||
)
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@@ -294,15 +285,10 @@ def api_patch_test(test_id):
|
||||
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},
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@@ -310,9 +296,8 @@ def api_patch_test(test_id):
|
||||
@login_required
|
||||
def api_start_attempt(test_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
out = start_attempt(eng, user.id, test_id)
|
||||
out = start_attempt(None, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out), 201
|
||||
@@ -322,9 +307,8 @@ def api_start_attempt(test_id):
|
||||
@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)
|
||||
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)
|
||||
@@ -334,10 +318,45 @@ def api_attempt_play(test_id, attempt_id):
|
||||
@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'))
|
||||
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/<test_id>/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/<test_id>/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/<test_id>/attempts/<attempt_id>/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)
|
||||
@@ -347,9 +366,8 @@ def api_attempt_submit(test_id, attempt_id):
|
||||
@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)
|
||||
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)
|
||||
@@ -359,9 +377,8 @@ def api_attempt_review(test_id, attempt_id):
|
||||
@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)
|
||||
rows = list_test_attempts_for_author(None, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(
|
||||
@@ -385,7 +402,7 @@ def api_attempts_list(test_id):
|
||||
)
|
||||
|
||||
|
||||
# ─── AI ──────────────────────────────────────────────────────────────
|
||||
# ─── AI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST'])
|
||||
@login_required
|
||||
@@ -418,16 +435,15 @@ def api_ai_generate_question(test_id):
|
||||
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)
|
||||
|
||||
|
||||
# ─── 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):
|
||||
@@ -495,15 +511,12 @@ def api_ai_improve_test(test_id):
|
||||
return jsonify(ok=True, **out)
|
||||
|
||||
|
||||
# ─── Импорт документа (E1.3) ────────────────────────────────────────
|
||||
# ─── Импорт документа (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 МБ` (см. фабрику).
|
||||
"""
|
||||
"""Шаг 1: загрузить файл и извлечь текст. Генерация — отдельным запросом."""
|
||||
f = request.files.get('file')
|
||||
if f is None or not f.filename:
|
||||
return jsonify(error='Прикрепите файл к полю file.'), 400
|
||||
@@ -515,7 +528,6 @@ def api_import_document():
|
||||
log.exception('extract_text_from_file failed')
|
||||
return jsonify(error='Не удалось разобрать файл.'), 500
|
||||
|
||||
generation = generation_for_import_document(extracted)
|
||||
return jsonify(
|
||||
received=True,
|
||||
originalName=f.filename,
|
||||
@@ -523,11 +535,23 @@ def api_import_document():
|
||||
size=len(extracted.encode('utf-8')),
|
||||
extractedText=extracted,
|
||||
textLength=len(extracted),
|
||||
generation=generation,
|
||||
)
|
||||
|
||||
|
||||
# ─── UI (Jinja) ──────────────────────────────────────────────────────
|
||||
@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()
|
||||
if not extracted:
|
||||
return jsonify(error='Нет текста для генерации.'), 400
|
||||
generation = generation_for_import_document(extracted, user_hint=user_hint)
|
||||
return jsonify(generation=generation)
|
||||
|
||||
|
||||
# ─── UI (Jinja) ───────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/tests', methods=['GET'])
|
||||
@login_required
|
||||
@@ -567,9 +591,8 @@ def tests_attempt_page(test_id, attempt_id):
|
||||
@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)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user