блоки 2 и 3 доработки интерфейса системы тестирования

This commit is contained in:
Константин Лебединский
2026-04-29 21:06:17 +05:00
parent eff3fda5b0
commit bba96f8f9f
37 changed files with 4440 additions and 1292 deletions
+449 -281
View File
@@ -1,8 +1,23 @@
"""Сервис прохождения теста."""
from __future__ import annotations
from sqlalchemy import text
import uuid as _uuid
from datetime import datetime, timezone
from ..services.test_access import is_test_author, user_has_test_access
from sqlalchemy import func
from sqlalchemy.orm import Session, selectinload
from ..db import get_session
from ..models import (
AnswerOption,
Question,
Test,
TestAttempt,
TestVersion,
User,
UserAnswer,
)
from .test_access import is_test_author, user_has_test_access
class HttpError(Exception):
@@ -17,212 +32,218 @@ def _sort_uuid_strings(items) -> list[str]:
def _same_selection(selected, correct_ids) -> bool:
a = _sort_uuid_strings(selected)
b = _sort_uuid_strings(correct_ids)
return a == b
return _sort_uuid_strings(selected) == _sort_uuid_strings(correct_ids)
def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]:
qrows = conn.execute(
text(
'SELECT id, text, question_order, has_multiple_answers '
'FROM questions WHERE test_version_id = :v ORDER BY question_order'
),
{'v': test_version_id},
).mappings().all()
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
# ─── load questions (shared) ─────────────────────────────────────────────────
def load_questions_for_version(session: Session, test_version_id, *, include_correct: bool) -> list[dict]:
vid = _to_uuid(test_version_id)
if vid is None:
return []
questions = (
session.query(Question)
.options(selectinload(Question.options))
.filter(Question.test_version_id == vid)
.order_by(Question.question_order)
.all()
)
out = []
for q in qrows:
orows = conn.execute(
text(
'SELECT id, text, is_correct, option_order '
'FROM answer_options WHERE question_id = :q ORDER BY option_order'
),
{'q': q['id']},
).mappings().all()
opts = []
for o in orows:
base = {
'id': str(o['id']),
'text': o['text'],
'optionOrder': o['option_order'],
}
for q in questions:
options = []
for o in sorted(q.options, key=lambda x: x.option_order):
base = {'id': str(o.id), 'text': o.text, 'optionOrder': o.option_order}
if include_correct:
base['isCorrect'] = bool(o['is_correct'])
opts.append(base)
out.append(
{
'id': str(q['id']),
'text': q['text'],
'questionOrder': q['question_order'],
'hasMultipleAnswers': bool(q['has_multiple_answers']),
'options': opts,
}
)
base['isCorrect'] = bool(o.is_correct)
options.append(base)
out.append({
'id': str(q.id),
'text': q.text,
'questionOrder': q.question_order,
'hasMultipleAnswers': bool(q.has_multiple_answers),
'options': options,
})
return out
def start_attempt(eng, user_id: str, test_id: str) -> dict:
# ─── start ───────────────────────────────────────────────────────────────────
def start_attempt(session_or_eng, user_id: str, test_id: str) -> dict:
"""Принимает engine (legacy) или session — для обратной совместимости."""
acc = user_has_test_access(user_id, test_id)
if not acc.ok:
raise HttpError(404, 'Тест не найден.')
with eng.begin() as conn:
tv = conn.execute(
text(
'SELECT id AS test_version_id FROM test_versions '
'WHERE test_id = :id AND is_active = true LIMIT 1'
),
{'id': test_id},
).mappings().first()
if not tv:
raise HttpError(404, 'Нет активной версии теста.')
version_id = tv['test_version_id']
mx = conn.execute(
text(
'SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts '
'WHERE test_version_id = :v AND user_id = :u'
),
{'v': version_id, 'u': user_id},
).mappings().first()
next_n = int(mx['n'] or 0) + 1
a = conn.execute(
text(
"INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status) "
"VALUES (:v, :u, :n, 'in_progress') "
'RETURNING id, test_version_id, user_id, attempt_number, status, started_at'
),
{'v': version_id, 'u': user_id, 'n': next_n},
).mappings().first()
return {'attempt': dict(a)}
session = get_session()
tid = _to_uuid(test_id)
uid = _to_uuid(user_id)
def get_play_content(eng, user_id: str, test_id: str, attempt_id: str) -> dict:
with eng.connect() as conn:
a = conn.execute(
text(
'SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, '
't.title, t.passing_threshold '
'FROM test_attempts ta '
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
'INNER JOIN tests t ON t.id = tv.test_id '
'WHERE ta.id = :a'
),
{'a': attempt_id},
).mappings().first()
if not a:
raise HttpError(404, 'Попытка не найдена.')
if str(a['test_id']) != str(test_id):
raise HttpError(404, 'Попытка не найдена.')
if str(a['user_id']) != str(user_id):
raise HttpError(403, 'Доступ запрещён.')
if a['status'] != 'in_progress':
raise HttpError(400, 'Попытка уже завершена.')
qs = load_questions_for_version(conn, a['test_version_id'], include_correct=False)
active_version = (
session.query(TestVersion)
.filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
.first()
)
if not active_version:
raise HttpError(404, 'Нет активной версии теста.')
max_n = (
session.query(func.coalesce(func.max(TestAttempt.attempt_number), 0))
.filter(
TestAttempt.test_version_id == active_version.id,
TestAttempt.user_id == uid,
)
.scalar() or 0
)
attempt = TestAttempt(
test_version_id=active_version.id,
user_id=uid,
attempt_number=int(max_n) + 1,
status='in_progress',
)
session.add(attempt)
session.commit()
session.refresh(attempt)
return {
'testTitle': a['title'],
'passingThreshold': a['passing_threshold'],
'attemptId': str(a['id']),
'attempt': {
'id': str(attempt.id),
'test_version_id': str(attempt.test_version_id),
'user_id': str(attempt.user_id),
'attempt_number': attempt.attempt_number,
'status': attempt.status,
'started_at': attempt.started_at.isoformat() if attempt.started_at else None,
}
}
# ─── play ────────────────────────────────────────────────────────────────────
def get_play_content(session_or_eng, user_id: str, test_id: str, attempt_id: str) -> dict:
session = get_session()
aid = _to_uuid(attempt_id)
uid = _to_uuid(user_id)
tid = _to_uuid(test_id)
attempt = (
session.query(TestAttempt)
.options(
selectinload(TestAttempt.test_version).selectinload(TestVersion.test)
)
.filter(TestAttempt.id == aid)
.first()
)
if not attempt:
raise HttpError(404, 'Попытка не найдена.')
if attempt.test_version.test_id != tid:
raise HttpError(404, 'Попытка не найдена.')
if attempt.user_id != uid:
raise HttpError(403, 'Доступ запрещён.')
if attempt.status != 'in_progress':
raise HttpError(400, 'Попытка уже завершена.')
test = attempt.test_version.test
qs = load_questions_for_version(session, attempt.test_version_id, include_correct=False)
return {
'testTitle': test.title,
'passingThreshold': test.passing_threshold,
'timeLimit': test.time_limit,
'hintsEnabled': bool(test.hints_enabled),
'resultMode': test.result_mode or 'end',
'attemptId': str(attempt.id),
'questions': qs,
}
def submit_attempt(eng, user_id: str, test_id: str, attempt_id: str, raw_answers: dict | None) -> dict:
# ─── submit ──────────────────────────────────────────────────────────────────
def submit_attempt(session_or_eng, user_id: str, test_id: str, attempt_id: str,
raw_answers: dict | None) -> dict:
answers = raw_answers if isinstance(raw_answers, dict) else {}
with eng.begin() as conn:
a = conn.execute(
text(
'SELECT id, user_id, status, test_version_id '
'FROM test_attempts WHERE id = :a FOR UPDATE'
),
{'a': attempt_id},
).mappings().first()
if not a:
raise HttpError(404, 'Попытка не найдена.')
link = conn.execute(
text(
'SELECT t.passing_threshold, tv.test_id '
'FROM test_versions tv '
'INNER JOIN tests t ON t.id = tv.test_id '
'WHERE tv.id = :v'
),
{'v': a['test_version_id']},
).mappings().first()
if not link:
raise HttpError(404, 'Тест не найден.')
if str(link['test_id']) != str(test_id):
raise HttpError(404, 'Попытка не найдена.')
if str(a['user_id']) != str(user_id):
raise HttpError(403, 'Доступ запрещён.')
if a['status'] != 'in_progress':
raise HttpError(400, 'Попытка уже завершена.')
session = get_session()
aid = _to_uuid(attempt_id)
uid = _to_uuid(user_id)
tid = _to_uuid(test_id)
qrows = conn.execute(
text('SELECT id FROM questions WHERE test_version_id = :v'),
{'v': a['test_version_id']},
).mappings().all()
if not qrows:
raise HttpError(400, 'В тесте нет вопросов.')
attempt = (
session.query(TestAttempt)
.options(selectinload(TestAttempt.test_version).selectinload(TestVersion.test))
.filter(TestAttempt.id == aid)
.with_for_update()
.first()
)
if not attempt:
raise HttpError(404, 'Попытка не найдена.')
if attempt.test_version.test_id != tid:
raise HttpError(404, 'Попытка не найдена.')
if attempt.user_id != uid:
raise HttpError(403, 'Доступ запрещён.')
if attempt.status != 'in_progress':
raise HttpError(400, 'Попытка уже завершена.')
opts = conn.execute(
text(
'SELECT a.id, a.question_id, a.is_correct '
'FROM answer_options a '
'INNER JOIN questions q ON q.id = a.question_id '
'WHERE q.test_version_id = :v'
),
{'v': a['test_version_id']},
).mappings().all()
test = attempt.test_version.test
questions = (
session.query(Question)
.options(selectinload(Question.options))
.filter(Question.test_version_id == attempt.test_version_id)
.all()
)
if not questions:
raise HttpError(400, 'В тесте нет вопросов.')
by_q = {}
for o in opts:
qid = str(o['question_id'])
if qid not in by_q:
by_q[qid] = {'all': set(), 'correct': []}
by_q[qid]['all'].add(str(o['id']))
if o['is_correct']:
by_q[qid]['correct'].append(str(o['id']))
by_q: dict[str, dict] = {}
for q in questions:
qid = str(q.id)
by_q[qid] = {'all': {str(o.id) for o in q.options}, 'correct': [str(o.id) for o in q.options if o.is_correct]}
correct_count = 0
for q in qrows:
qid = str(q['id'])
selected = answers.get(qid, [])
if not isinstance(selected, list):
selected = [str(selected)]
selected = [str(x) for x in selected]
g = by_q.get(qid, {'all': set(), 'correct': []})
for sid in selected:
if sid not in g['all']:
raise HttpError(400, 'Некорректный вариант ответа.')
if _same_selection(selected, g['correct']):
correct_count += 1
correct_count = 0
for q in questions:
qid = str(q.id)
selected = answers.get(qid, [])
if not isinstance(selected, list):
selected = [str(selected)]
selected = [str(x) for x in selected]
g = by_q[qid]
for sid in selected:
if sid not in g['all']:
raise HttpError(400, 'Некорректный вариант ответа.')
if _same_selection(selected, g['correct']):
correct_count += 1
total = len(qrows)
percent = (correct_count / total) * 100 if total else 0
threshold = int(link['passing_threshold'] or 0)
passed = percent + 1e-9 >= threshold
total = len(questions)
percent = (correct_count / total) * 100 if total else 0
threshold = int(test.passing_threshold or 0)
passed = percent + 1e-9 >= threshold
conn.execute(text('DELETE FROM user_answers WHERE attempt_id = :a'), {'a': attempt_id})
for q in qrows:
qid = str(q['id'])
selected = answers.get(qid, [])
if not isinstance(selected, list):
selected = [str(selected)]
selected = [str(x) for x in selected]
conn.execute(
text(
'INSERT INTO user_answers (attempt_id, question_id, selected_options) '
'VALUES (:a, :q, :s::uuid[])'
),
{'a': attempt_id, 'q': q['id'], 's': selected},
)
conn.execute(
text(
"UPDATE test_attempts SET status = 'completed', completed_at = CURRENT_TIMESTAMP, "
'correct_count = :c, total_questions = :t, passed = :p WHERE id = :a'
),
{'a': attempt_id, 'c': correct_count, 't': total, 'p': passed},
)
# удаляем старые ответы и записываем новые
session.query(UserAnswer).filter(UserAnswer.attempt_id == aid).delete(synchronize_session='fetch')
for q in questions:
qid = str(q.id)
selected = answers.get(qid, [])
if not isinstance(selected, list):
selected = [str(selected)]
selected_uuids = [_to_uuid(x) for x in selected if _to_uuid(x) is not None]
session.add(UserAnswer(
attempt_id=aid,
question_id=q.id,
selected_options=selected_uuids,
))
review = build_review_from_db(eng, attempt_id)
attempt.status = 'completed'
attempt.completed_at = datetime.now(timezone.utc)
attempt.correct_count = correct_count
attempt.total_questions = total
attempt.passed = passed
session.commit()
review = build_review_from_db(session, attempt_id)
return {
'attemptId': attempt_id,
'correctCount': correct_count,
@@ -234,121 +255,268 @@ def submit_attempt(eng, user_id: str, test_id: str, attempt_id: str, raw_answers
}
def build_review_from_db(eng, attempt_id: str) -> dict:
with eng.connect() as conn:
a = conn.execute(
text(
'SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions, '
'ta.passed, ta.started_at, ta.completed_at, '
't.id AS test_id, t.title, t.passing_threshold, '
'u.full_name AS attempter_name, u.login AS attempter_login '
'FROM test_attempts ta '
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
'INNER JOIN tests t ON t.id = tv.test_id '
'INNER JOIN users u ON u.id = ta.user_id '
'WHERE ta.id = :a'
),
{'a': attempt_id},
).mappings().first()
if not a:
raise HttpError(404, 'Попытка не найдена.')
if a['status'] != 'completed':
raise HttpError(400, 'Попытка не завершена.')
questions = load_questions_for_version(conn, a['test_version_id'], include_correct=True)
uans = conn.execute(
text('SELECT question_id, selected_options FROM user_answers WHERE attempt_id = :a'),
{'a': attempt_id},
).mappings().all()
# ─── review ──────────────────────────────────────────────────────────────────
sel_by_q = {str(r['question_id']): [str(x) for x in (r['selected_options'] or [])] for r in uans}
total = int(a['total_questions'] or len(questions))
percent = round(((a['correct_count'] or 0) / total) * 100, 1) if total else 0
def build_review_from_db(session: Session, attempt_id: str) -> dict:
aid = _to_uuid(attempt_id)
attempt = (
session.query(TestAttempt)
.options(
selectinload(TestAttempt.test_version).selectinload(TestVersion.test),
selectinload(TestAttempt.user),
selectinload(TestAttempt.user_answers),
)
.filter(TestAttempt.id == aid)
.first()
)
if not attempt:
raise HttpError(404, 'Попытка не найдена.')
if attempt.status != 'completed':
raise HttpError(400, 'Попытка не завершена.')
test = attempt.test_version.test
questions = load_questions_for_version(session, attempt.test_version_id, include_correct=True)
sel_by_q: dict[str, list[str]] = {
str(ua.question_id): [str(x) for x in (ua.selected_options or [])]
for ua in attempt.user_answers
}
total = int(attempt.total_questions or len(questions))
percent = round(((attempt.correct_count or 0) / total) * 100, 1) if total else 0
q_out = []
for q in questions:
selected = _sort_uuid_strings(sel_by_q.get(str(q['id']), []))
selected = _sort_uuid_strings(sel_by_q.get(q['id'], []))
correct = _sort_uuid_strings([o['id'] for o in q['options'] if o.get('isCorrect')])
selected_set = set(selected)
q_out.append(
{
'id': q['id'],
'text': q['text'],
'hasMultipleAnswers': q['hasMultipleAnswers'],
'isUserCorrect': _same_selection(selected, correct),
'options': [
{
'id': o['id'],
'text': o['text'],
'isCorrect': o.get('isCorrect', False),
'selected': o['id'] in selected_set,
}
for o in q['options']
],
}
)
q_out.append({
'id': q['id'],
'text': q['text'],
'hasMultipleAnswers': q['hasMultipleAnswers'],
'isUserCorrect': _same_selection(selected, correct),
'options': [
{
'id': o['id'],
'text': o['text'],
'isCorrect': o.get('isCorrect', False),
'selected': o['id'] in selected_set,
}
for o in q['options']
],
})
return {
'attemptId': str(a['id']),
'testId': str(a['test_id']),
'testTitle': a['title'],
'passingThreshold': int(a['passing_threshold'] or 0),
'correctCount': int(a['correct_count'] or 0),
'attemptId': str(attempt.id),
'testId': str(test.id),
'testTitle': test.title,
'passingThreshold': int(test.passing_threshold or 0),
'correctCount': int(attempt.correct_count or 0),
'totalQuestions': total,
'percent': percent,
'passed': bool(a['passed']),
'startedAt': a['started_at'].isoformat() if a['started_at'] else None,
'completedAt': a['completed_at'].isoformat() if a['completed_at'] else None,
'attempterUserId': str(a['user_id']),
'attempterName': a['attempter_name'],
'attempterLogin': a['attempter_login'],
'passed': bool(attempt.passed),
'startedAt': attempt.started_at.isoformat() if attempt.started_at else None,
'completedAt': attempt.completed_at.isoformat() if attempt.completed_at else None,
'attempterUserId': str(attempt.user_id),
'attempterName': attempt.user.full_name,
'attempterLogin': attempt.user.login,
'questions': q_out,
}
def get_attempt_review_for_user(eng, current_user_id: str, test_id: str, attempt_id: str) -> dict:
with eng.connect() as conn:
row = conn.execute(
text(
'SELECT ta.user_id, t.created_by, tv.test_id '
'FROM test_attempts ta '
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
'INNER JOIN tests t ON t.id = tv.test_id '
'WHERE ta.id = :a'
),
{'a': attempt_id},
).mappings().first()
if not row:
def get_attempt_review_for_user(session_or_eng, current_user_id: str, test_id: str,
attempt_id: str) -> dict:
session = get_session()
aid = _to_uuid(attempt_id)
tid = _to_uuid(test_id)
attempt = (
session.query(TestAttempt)
.options(selectinload(TestAttempt.test_version).selectinload(TestVersion.test))
.filter(TestAttempt.id == aid)
.first()
)
if not attempt:
raise HttpError(404, 'Попытка не найдена.')
if str(row['test_id']) != str(test_id):
if attempt.test_version.test_id != tid:
raise HttpError(404, 'Попытка не найдена.')
is_owner = str(row['user_id']) == str(current_user_id)
is_author = is_test_author(row['created_by'], current_user_id)
is_owner = str(attempt.user_id) == str(current_user_id)
is_author = is_test_author(attempt.test_version.test.created_by, current_user_id)
if not is_owner and not is_author:
raise HttpError(403, 'Доступ запрещён.')
return build_review_from_db(eng, attempt_id)
return build_review_from_db(session, attempt_id)
def list_test_attempts_for_author(eng, author_id: str, test_id: str) -> list[dict]:
with eng.connect() as conn:
t = conn.execute(
text('SELECT id, created_by FROM tests WHERE id = :id'),
{'id': test_id},
).mappings().first()
if not t:
raise HttpError(404, 'Тест не найден.')
if not is_test_author(t['created_by'], author_id):
raise HttpError(403, 'Доступ запрещён.')
rows = conn.execute(
text(
'SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at, '
'ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version, '
'u.full_name AS attempter_name, u.login AS attempter_login '
'FROM test_attempts ta '
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id '
'INNER JOIN users u ON u.id = ta.user_id '
'WHERE tv.test_id = :id '
'ORDER BY ta.started_at DESC NULLS LAST LIMIT 200'
),
{'id': test_id},
).mappings().all()
return [dict(r) for r in rows]
# ─── hints ───────────────────────────────────────────────────────────────────
def count_missing_hints(session_or_eng, test_id: str) -> dict:
session = get_session()
tid = _to_uuid(test_id)
if tid is None:
return {'total': 0, 'missing': 0}
active_version = (
session.query(TestVersion)
.filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
.first()
)
if not active_version:
return {'total': 0, 'missing': 0}
all_qs = session.query(Question).filter(Question.test_version_id == active_version.id).all()
total = len(all_qs)
missing = sum(1 for q in all_qs if not q.ai_hint)
return {'total': total, 'missing': missing}
def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str) -> dict:
from .ai_editor import generate_question_hint
session = get_session()
tid = _to_uuid(test_id)
test = session.get(Test, tid)
if not test:
raise HttpError(404, 'Тест не найден.')
if not is_test_author(test.created_by, author_id):
raise HttpError(403, 'Доступ запрещён.')
active_version = (
session.query(TestVersion)
.filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
.first()
)
if not active_version:
return {'generated': 0, 'failed': 0, 'total': 0}
missing_qs = (
session.query(Question)
.options(selectinload(Question.options))
.filter(
Question.test_version_id == active_version.id,
(Question.ai_hint == None) | (Question.ai_hint == ''), # noqa: E711
)
.order_by(Question.question_order)
.all()
)
generated = failed = 0
for q in missing_qs:
opt_payload = [{'text': o.text, 'isCorrect': bool(o.is_correct)} for o in q.options]
hint = generate_question_hint(question_text=q.text, options=opt_payload)
if hint:
q.ai_hint = hint
generated += 1
else:
failed += 1
session.commit()
return {'generated': generated, 'failed': failed, 'total': len(missing_qs)}
def check_question_for_attempt(session_or_eng, user_id: str, test_id: str, attempt_id: str,
question_id: str, selected_option_ids: list[str]) -> dict:
session = get_session()
aid = _to_uuid(attempt_id)
uid = _to_uuid(user_id)
tid = _to_uuid(test_id)
qid = _to_uuid(question_id)
attempt = (
session.query(TestAttempt)
.options(
selectinload(TestAttempt.test_version).selectinload(TestVersion.test)
)
.filter(TestAttempt.id == aid)
.first()
)
if not attempt:
raise HttpError(404, 'Попытка не найдена.')
if attempt.test_version.test_id != tid:
raise HttpError(404, 'Попытка не найдена.')
if attempt.user_id != uid:
raise HttpError(403, 'Доступ запрещён.')
if attempt.status != 'in_progress':
raise HttpError(400, 'Попытка уже завершена.')
question = (
session.query(Question)
.options(selectinload(Question.options))
.filter(
Question.id == qid,
Question.test_version_id == attempt.test_version_id,
)
.first()
)
if not question:
raise HttpError(404, 'Вопрос не найден.')
correct_ids = [str(o.id) for o in question.options if o.is_correct]
is_correct = _same_selection(selected_option_ids, correct_ids)
selected_set = {str(x) for x in (selected_option_ids or [])}
selected_texts = [o.text for o in question.options if str(o.id) in selected_set]
correct_texts = [o.text for o in question.options if o.is_correct]
test = attempt.test_version.test
explanation = ''
if test.hints_enabled:
if question.ai_hint:
explanation = question.ai_hint
else:
try:
from .ai_editor import explain_answer
explanation = explain_answer(
question_text=question.text,
options=[{'text': o.text, 'isCorrect': bool(o.is_correct)} for o in question.options],
selected_texts=selected_texts,
is_correct=is_correct,
)
except Exception:
explanation = ''
return {
'questionId': str(question.id),
'isCorrect': is_correct,
'correctOptionIds': correct_ids,
'correctOptionTexts': correct_texts,
'explanation': explanation,
}
def list_test_attempts_for_author(session_or_eng, author_id: str, test_id: str) -> list[dict]:
session = get_session()
tid = _to_uuid(test_id)
test = session.get(Test, tid)
if not test:
raise HttpError(404, 'Тест не найден.')
if not is_test_author(test.created_by, author_id):
raise HttpError(403, 'Доступ запрещён.')
rows = (
session.query(TestAttempt, TestVersion, User)
.join(TestVersion, TestAttempt.test_version_id == TestVersion.id)
.join(User, TestAttempt.user_id == User.id)
.filter(TestVersion.test_id == tid)
.order_by(TestAttempt.started_at.desc().nullslast())
.limit(200)
.all()
)
return [
{
'id': str(a.id),
'user_id': str(a.user_id),
'status': a.status,
'attempt_number': a.attempt_number,
'started_at': a.started_at,
'completed_at': a.completed_at,
'correct_count': a.correct_count,
'total_questions': a.total_questions,
'passed': a.passed,
'test_version': tv.version,
'attempter_name': u.full_name,
'attempter_login': u.login,
}
for a, tv, u in rows
]