Дорабоки интерфейса системы тестирования. Раздел 1 Шапка+Верхний brick
This commit is contained in:
@@ -10,6 +10,7 @@ from sqlalchemy import text
|
||||
from ..db import get_engine
|
||||
from ..messages import RU
|
||||
from .test_access import is_test_author
|
||||
from .test_chain import has_any_attempt_for_test
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
@@ -81,7 +82,13 @@ def get_editor_content(user_id: str, test_id: str) -> dict:
|
||||
if not tv:
|
||||
raise HttpError(400, 'Нет активной версии теста.')
|
||||
version_id = tv['id']
|
||||
version_count_row = conn.execute(
|
||||
text('SELECT COUNT(*) AS n FROM test_versions WHERE test_id = :id'),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
version_count = int(version_count_row['n'] or 0)
|
||||
questions = load_questions_for_version(conn, version_id, include_correct=True)
|
||||
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||
|
||||
return {
|
||||
'test': {
|
||||
@@ -89,6 +96,9 @@ def get_editor_content(user_id: str, test_id: str) -> dict:
|
||||
'title': tr['title'],
|
||||
'description': tr['description'],
|
||||
'passingThreshold': tr['passing_threshold'],
|
||||
'hasAttempts': bool(has_attempts),
|
||||
'versionCount': version_count,
|
||||
'hasForkRisk': bool(has_attempts) or version_count > 1,
|
||||
},
|
||||
'activeVersionId': str(version_id),
|
||||
'questions': questions,
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..services.test_access import is_test_author, user_has_test_access
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def _sort_uuid_strings(items) -> list[str]:
|
||||
return sorted({str(x) for x in (items or []) if x is not None})
|
||||
|
||||
|
||||
def _same_selection(selected, correct_ids) -> bool:
|
||||
a = _sort_uuid_strings(selected)
|
||||
b = _sort_uuid_strings(correct_ids)
|
||||
return a == b
|
||||
|
||||
|
||||
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()
|
||||
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'],
|
||||
}
|
||||
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,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def start_attempt(eng, user_id: str, test_id: str) -> dict:
|
||||
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)}
|
||||
|
||||
|
||||
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)
|
||||
return {
|
||||
'testTitle': a['title'],
|
||||
'passingThreshold': a['passing_threshold'],
|
||||
'attemptId': str(a['id']),
|
||||
'questions': qs,
|
||||
}
|
||||
|
||||
|
||||
def submit_attempt(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, 'Попытка уже завершена.')
|
||||
|
||||
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, 'В тесте нет вопросов.')
|
||||
|
||||
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()
|
||||
|
||||
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']))
|
||||
|
||||
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
|
||||
|
||||
total = len(qrows)
|
||||
percent = (correct_count / total) * 100 if total else 0
|
||||
threshold = int(link['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},
|
||||
)
|
||||
|
||||
review = build_review_from_db(eng, attempt_id)
|
||||
return {
|
||||
'attemptId': attempt_id,
|
||||
'correctCount': correct_count,
|
||||
'totalQuestions': total,
|
||||
'percent': round(percent, 1),
|
||||
'passed': passed,
|
||||
'passingThreshold': threshold,
|
||||
'review': review,
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
q_out = []
|
||||
for q in questions:
|
||||
selected = _sort_uuid_strings(sel_by_q.get(str(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']
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
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),
|
||||
'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'],
|
||||
'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:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if str(row['test_id']) != str(test_id):
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
is_owner = str(row['user_id']) == str(current_user_id)
|
||||
is_author = is_test_author(row['created_by'], current_user_id)
|
||||
if not is_owner and not is_author:
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
return build_review_from_db(eng, 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]
|
||||
Reference in New Issue
Block a user