блоки 2 и 3 доработки интерфейса системы тестирования
This commit is contained in:
@@ -290,13 +290,80 @@ def improve_test_full(test_title: str, test_description: str, questions: list[di
|
||||
return {'items': items}
|
||||
|
||||
|
||||
def generate_question_hint(
|
||||
*,
|
||||
question_text: str,
|
||||
options: list[dict],
|
||||
) -> str:
|
||||
"""Универсальная подсказка к вопросу: 2–4 предложения, объясняет правильный ответ."""
|
||||
cfg = get_llm_config()
|
||||
if cfg is None:
|
||||
return ''
|
||||
correct_list = '; '.join(o['text'] for o in options if o.get('isCorrect'))
|
||||
all_list = '; '.join(o['text'] for o in options)
|
||||
system = (
|
||||
'Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения), '
|
||||
'без markdown и без вступлений. Объясни почему правильный вариант — правильный.'
|
||||
)
|
||||
user = (
|
||||
f'Вопрос: {question_text}\n'
|
||||
f'Варианты: {all_list}\n'
|
||||
f'Правильный ответ: {correct_list or "—"}\n\n'
|
||||
'Дай краткое объяснение для подсказки во всплывающем окне.'
|
||||
)
|
||||
try:
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.3, as_json=False)
|
||||
return (raw or '').strip()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning('generate_question_hint failed: %s', e)
|
||||
return ''
|
||||
|
||||
|
||||
def explain_answer(
|
||||
*,
|
||||
question_text: str,
|
||||
options: list[dict],
|
||||
selected_texts: list[str],
|
||||
is_correct: bool,
|
||||
) -> str:
|
||||
"""Генерирует короткое объяснение результата ответа на вопрос (для попапа подсказки)."""
|
||||
cfg = get_llm_config()
|
||||
if cfg is None:
|
||||
return ''
|
||||
correct_list = '; '.join(o['text'] for o in options if o.get('isCorrect'))
|
||||
sel_list = '; '.join(selected_texts) if selected_texts else '(ничего не выбрано)'
|
||||
verdict = 'верно' if is_correct else 'неверно'
|
||||
system = (
|
||||
'Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения). '
|
||||
'Объясни почему правильный ответ именно такой, без лишней воды и без markdown.'
|
||||
)
|
||||
user = (
|
||||
f'Вопрос: {question_text}\n'
|
||||
f'Правильный ответ: {correct_list or "—"}\n'
|
||||
f'Ответ ученика ({verdict}): {sel_list}\n\n'
|
||||
'Дай краткое объяснение для подсказки во всплывающем окне.'
|
||||
)
|
||||
try:
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.3, as_json=False)
|
||||
return (raw or '').strip()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning('explain_answer failed: %s', e)
|
||||
return ''
|
||||
|
||||
|
||||
def generate_or_rephrase_question(
|
||||
test_title: str,
|
||||
test_description: str,
|
||||
question_text: str,
|
||||
options_count: Any,
|
||||
has_multiple_answers: bool,
|
||||
mode: str | None = None,
|
||||
existing_options: list[dict] | None = None,
|
||||
) -> dict:
|
||||
import json as _json
|
||||
|
||||
cfg = _require_cfg()
|
||||
try:
|
||||
n = int(float(options_count))
|
||||
@@ -308,6 +375,43 @@ def generate_or_rephrase_question(
|
||||
topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip()
|
||||
qt = (question_text or '').strip()
|
||||
|
||||
# ── Режим дистракторов: есть вопрос + часть вариантов пуста ─────────────
|
||||
if qt and mode == 'distractors' and existing_options:
|
||||
filled = [o for o in existing_options if (o.get('text') or '').strip()]
|
||||
empty_count = len([o for o in existing_options if not (o.get('text') or '').strip()] )
|
||||
if empty_count > 0:
|
||||
filled_lines = '\n'.join(
|
||||
f'- {"✓" if o.get("isCorrect") else "✗"} {o["text"]}'
|
||||
for o in filled
|
||||
) or '(нет)'
|
||||
system = (
|
||||
'Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: '
|
||||
f'{{"options": [{{"text": string, "isCorrect": false}}, ...]}} — '
|
||||
f'ровно {empty_count} объекта в массиве. '
|
||||
'Все тексты на русском, без нумерации, без кавычек.'
|
||||
)
|
||||
user = (
|
||||
f'Тема теста: {topic}\n\n'
|
||||
f'Вопрос: {qt}\n\n'
|
||||
f'Уже заполненные варианты:\n{filled_lines}\n\n'
|
||||
f'Придумай ровно {empty_count} правдоподобных, но НЕВЕРНЫХ дистракторов '
|
||||
f'(isCorrect: false), которые не повторяют уже существующие варианты '
|
||||
f'и выглядят похоже на реальные ответы.'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
opts = []
|
||||
if isinstance(parsed, dict):
|
||||
opts = parsed.get('options') or []
|
||||
elif isinstance(parsed, list):
|
||||
opts = parsed
|
||||
opts = [
|
||||
{'text': str(o.get('text') or '').strip(), 'isCorrect': False}
|
||||
for o in opts if (o.get('text') or '').strip()
|
||||
][:empty_count]
|
||||
return {'mode': 'distractors', 'text': qt, 'options': opts}
|
||||
|
||||
# ── Режим улучшения: вопрос есть → только переформулировать текст ────────
|
||||
if qt:
|
||||
system = (
|
||||
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — '
|
||||
@@ -325,6 +429,7 @@ def generate_or_rephrase_question(
|
||||
raise LlmError('Пустой text в ответе модели.', code='llm_shape')
|
||||
return {'mode': 'rephrase', 'text': text}
|
||||
|
||||
# ── Полная генерация: вопрос пуст ────────────────────────────────────────
|
||||
system = (
|
||||
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", '
|
||||
'"options": [{ "text", "isCorrect" }]}. Все на русском.'
|
||||
|
||||
@@ -11,7 +11,7 @@ from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
||||
MAX_EXTRACT_CHARS = 14000
|
||||
|
||||
|
||||
def generation_for_import_document(extracted_text: str) -> dict:
|
||||
def generation_for_import_document(extracted_text: str, user_hint: str = '') -> dict:
|
||||
text = (extracted_text or '').strip()
|
||||
if not text:
|
||||
return {
|
||||
@@ -41,9 +41,10 @@ def generation_for_import_document(extracted_text: str) -> dict:
|
||||
'Для одиночного выбора ровно один isCorrect: true. '
|
||||
'Текст и формулировки — на русском, по содержанию входного материала.'
|
||||
)
|
||||
hint_block = f'\n\nДополнительные инструкции от автора теста:\n{user_hint.strip()}' if user_hint and user_hint.strip() else ''
|
||||
user = (
|
||||
'Составь тест с вопросами с одним или несколькими правильными ответами '
|
||||
'на основе текста:\n\n' + slice_
|
||||
'на основе текста:\n\n' + slice_ + hint_block
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.25)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами.
|
||||
|
||||
Порт `getEditorContent` + `loadQuestionsForVersion` (только includeCorrect=true вариант)
|
||||
из `services/testAttemptService.js`.
|
||||
"""
|
||||
"""Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
import uuid as _uuid
|
||||
|
||||
from ..db import get_engine
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..db import get_session
|
||||
from ..messages import RU
|
||||
from ..models import AnswerOption, Question, Test, TestVersion
|
||||
from .test_access import is_test_author
|
||||
from .test_chain import has_any_attempt_for_test
|
||||
|
||||
@@ -20,86 +20,85 @@ class HttpError(Exception):
|
||||
self.message = message
|
||||
|
||||
|
||||
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 load_questions_for_version(session, test_version_id, *, include_correct: bool) -> list[dict]:
|
||||
if not isinstance(test_version_id, _uuid.UUID):
|
||||
try:
|
||||
test_version_id = _uuid.UUID(str(test_version_id))
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
questions = (
|
||||
session.query(Question)
|
||||
.options(selectinload(Question.options))
|
||||
.filter(Question.test_version_id == test_version_id)
|
||||
.order_by(Question.question_order)
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for r 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': r['id']},
|
||||
).mappings().all()
|
||||
for q in questions:
|
||||
options = []
|
||||
for o in orows:
|
||||
for o in sorted(q.options, key=lambda x: x.option_order):
|
||||
base = {
|
||||
'id': str(o['id']),
|
||||
'text': o['text'],
|
||||
'optionOrder': o['option_order'],
|
||||
'id': str(o.id),
|
||||
'text': o.text,
|
||||
'optionOrder': o.option_order,
|
||||
}
|
||||
if include_correct:
|
||||
base['isCorrect'] = bool(o['is_correct'])
|
||||
base['isCorrect'] = bool(o.is_correct)
|
||||
options.append(base)
|
||||
out.append(
|
||||
{
|
||||
'id': str(r['id']),
|
||||
'text': r['text'],
|
||||
'questionOrder': r['question_order'],
|
||||
'hasMultipleAnswers': bool(r['has_multiple_answers']),
|
||||
'options': options,
|
||||
}
|
||||
)
|
||||
out.append({
|
||||
'id': str(q.id),
|
||||
'text': q.text,
|
||||
'questionOrder': q.question_order,
|
||||
'hasMultipleAnswers': bool(q.has_multiple_answers),
|
||||
'options': options,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def get_editor_content(user_id: str, test_id: str) -> dict:
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
tr = conn.execute(
|
||||
text(
|
||||
'SELECT id, title, description, passing_threshold, created_by '
|
||||
'FROM tests WHERE id = :id'
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not tr:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(tr['created_by'], user_id):
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
tv = conn.execute(
|
||||
text(
|
||||
'SELECT id FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1'
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
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)
|
||||
session = get_session()
|
||||
try:
|
||||
tid = _uuid.UUID(test_id)
|
||||
except (ValueError, AttributeError):
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, user_id):
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
active_version = (
|
||||
session.query(TestVersion)
|
||||
.filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not active_version:
|
||||
raise HttpError(400, 'Нет активной версии теста.')
|
||||
|
||||
version_count = (
|
||||
session.query(func.count(TestVersion.id))
|
||||
.filter(TestVersion.test_id == tid)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
questions = load_questions_for_version(session, active_version.id, include_correct=True)
|
||||
has_attempts = has_any_attempt_for_test(session, tid)
|
||||
|
||||
return {
|
||||
'test': {
|
||||
'id': str(tr['id']),
|
||||
'title': tr['title'],
|
||||
'description': tr['description'],
|
||||
'passingThreshold': tr['passing_threshold'],
|
||||
'id': str(test.id),
|
||||
'title': test.title,
|
||||
'description': test.description,
|
||||
'passingThreshold': test.passing_threshold,
|
||||
'timeLimit': test.time_limit,
|
||||
'hintsEnabled': bool(test.hints_enabled),
|
||||
'resultMode': test.result_mode or 'end',
|
||||
'hasAttempts': bool(has_attempts),
|
||||
'versionCount': version_count,
|
||||
'hasForkRisk': bool(has_attempts) or version_count > 1,
|
||||
},
|
||||
'activeVersionId': str(version_id),
|
||||
'activeVersionId': str(active_version.id),
|
||||
'questions': questions,
|
||||
}
|
||||
|
||||
@@ -51,8 +51,13 @@ def chat_completion_text_content(
|
||||
user: str,
|
||||
temperature: float = 0.25,
|
||||
timeout: int = 120,
|
||||
as_json: bool = True,
|
||||
) -> str:
|
||||
"""Возвращает `assistant.message.content` (строку)."""
|
||||
"""Возвращает `assistant.message.content` (строку).
|
||||
|
||||
`as_json=True` (по умолчанию) включает `response_format: json_object`. Для свободного
|
||||
текста (например, объяснения к вопросу) передайте `as_json=False`.
|
||||
"""
|
||||
body: dict = {
|
||||
'model': cfg.model,
|
||||
'messages': [
|
||||
@@ -61,7 +66,7 @@ def chat_completion_text_content(
|
||||
],
|
||||
'temperature': temperature,
|
||||
}
|
||||
if (os.environ.get('LLM_NO_JSON') or '').strip() != '1':
|
||||
if as_json and (os.environ.get('LLM_NO_JSON') or '').strip() != '1':
|
||||
body['response_format'] = {'type': 'json_object'}
|
||||
|
||||
req = urllib.request.Request(
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Хранилище редактируемых промптов для AI-функций.
|
||||
|
||||
Промпты хранятся в app/data/prompts.json и загружаются при старте.
|
||||
Если файл не существует или повреждён — используются встроенные defaults.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
_lock = threading.Lock()
|
||||
|
||||
_DATA_FILE = Path(__file__).parent.parent / 'data' / 'prompts.json'
|
||||
|
||||
_store: dict[str, dict] | None = None
|
||||
|
||||
|
||||
def _load() -> dict[str, dict]:
|
||||
if _DATA_FILE.exists():
|
||||
try:
|
||||
with _DATA_FILE.open('r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as e:
|
||||
_log.warning('prompt_store: не удалось загрузить %s: %s', _DATA_FILE, e)
|
||||
return {}
|
||||
|
||||
|
||||
def _get_store() -> dict[str, dict]:
|
||||
global _store
|
||||
if _store is None:
|
||||
with _lock:
|
||||
if _store is None:
|
||||
_store = _load()
|
||||
return _store
|
||||
|
||||
|
||||
def get_all() -> dict[str, dict]:
|
||||
"""Вернуть все промпты (id → {label, description, system, user, vars})."""
|
||||
return dict(_get_store())
|
||||
|
||||
|
||||
def get_prompt(prompt_id: str) -> dict[str, Any] | None:
|
||||
return _get_store().get(prompt_id)
|
||||
|
||||
|
||||
def get_system(prompt_id: str, default: str = '') -> str:
|
||||
p = get_prompt(prompt_id)
|
||||
return p.get('system', default) if p else default
|
||||
|
||||
|
||||
def get_user(prompt_id: str, default: str = '') -> str:
|
||||
p = get_prompt(prompt_id)
|
||||
return p.get('user', default) if p else default
|
||||
|
||||
|
||||
def save_all(data: dict[str, dict]) -> None:
|
||||
"""Сохранить все промпты в JSON-файл и обновить кеш."""
|
||||
global _store
|
||||
_DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _lock:
|
||||
with _DATA_FILE.open('w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
_store = data
|
||||
|
||||
|
||||
def save_prompt(prompt_id: str, system: str, user: str) -> None:
|
||||
"""Обновить system/user для одного промпта."""
|
||||
store = dict(_get_store())
|
||||
if prompt_id not in store:
|
||||
raise KeyError(f'Промпт {prompt_id!r} не найден.')
|
||||
store[prompt_id] = {**store[prompt_id], 'system': system, 'user': user}
|
||||
save_all(store)
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Кто видит тест: автор + назначенные пользователи (порт `services/testAccessService.js`)."""
|
||||
"""Кто видит тест: автор + назначенные пользователи."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import exists, select
|
||||
|
||||
from ..db import get_engine
|
||||
from ..db import get_session
|
||||
from ..models import Test, TestAssignment, TestAssignmentTarget, TestVersion, User
|
||||
|
||||
|
||||
def is_test_author(created_by, user_id) -> bool:
|
||||
"""`tests.created_by` — UUID. Сравниваем по строковому представлению."""
|
||||
if created_by is None or user_id is None:
|
||||
return False
|
||||
return str(created_by) == str(user_id)
|
||||
@@ -23,86 +23,93 @@ class AccessResult:
|
||||
|
||||
|
||||
def user_has_test_access(user_id: str, test_id: str) -> AccessResult:
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text('SELECT created_by FROM tests WHERE id = :id'),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
return AccessResult(ok=False, is_author=False, not_found=True)
|
||||
if is_test_author(row['created_by'], user_id):
|
||||
return AccessResult(ok=True, is_author=True, not_found=False)
|
||||
ar = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM test_assignments ta
|
||||
INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id
|
||||
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id
|
||||
WHERE tv_a.test_id = :test_id
|
||||
AND tat.target_type = 'user'
|
||||
AND tat.target_id = :user_id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{'test_id': test_id, 'user_id': user_id},
|
||||
).first()
|
||||
return AccessResult(ok=ar is not None, is_author=False, not_found=False)
|
||||
import uuid as _uuid
|
||||
session = get_session()
|
||||
try:
|
||||
tid = _uuid.UUID(test_id)
|
||||
uid = _uuid.UUID(user_id)
|
||||
except (ValueError, AttributeError):
|
||||
return AccessResult(ok=False, is_author=False, not_found=True)
|
||||
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
return AccessResult(ok=False, is_author=False, not_found=True)
|
||||
|
||||
if is_test_author(test.created_by, uid):
|
||||
return AccessResult(ok=True, is_author=True, not_found=False)
|
||||
|
||||
assigned = session.query(
|
||||
exists().where(
|
||||
TestAssignmentTarget.target_type == 'user',
|
||||
TestAssignmentTarget.target_id == uid,
|
||||
TestAssignmentTarget.assignment_id == TestAssignment.id,
|
||||
TestAssignment.test_version_id == TestVersion.id,
|
||||
TestVersion.test_id == tid,
|
||||
)
|
||||
).scalar()
|
||||
|
||||
return AccessResult(ok=bool(assigned), is_author=False, not_found=False)
|
||||
|
||||
|
||||
def list_visible_tests(user_id: str) -> list[dict]:
|
||||
"""Каталог: только активная цепочка + (автор OR назначен)."""
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT t.id, t.title, t.description, t.is_active AS chain_active,
|
||||
t.created_at, t.updated_at,
|
||||
tv.id AS active_version_id, tv.version,
|
||||
t.created_by, u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
||||
INNER JOIN users u ON u.id = t.created_by
|
||||
WHERE t.is_active = true
|
||||
AND (
|
||||
t.created_by = :uid
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM test_assignments ta
|
||||
INNER JOIN test_versions tv2 ON tv2.id = ta.test_version_id
|
||||
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id
|
||||
WHERE tv2.test_id = t.id
|
||||
AND tat.target_type = 'user'
|
||||
AND tat.target_id = :uid
|
||||
)
|
||||
)
|
||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC
|
||||
"""
|
||||
),
|
||||
{'uid': user_id},
|
||||
).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
"""В dev-режиме возвращает все активные тесты независимо от назначения."""
|
||||
session = get_session()
|
||||
|
||||
rows = (
|
||||
session.query(Test, TestVersion, User)
|
||||
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
||||
.outerjoin(User, User.id == Test.created_by)
|
||||
.filter(Test.is_active.is_(True))
|
||||
.order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'id': str(t.id),
|
||||
'title': t.title,
|
||||
'description': t.description,
|
||||
'chain_active': t.is_active,
|
||||
'created_at': t.created_at,
|
||||
'updated_at': t.updated_at,
|
||||
'active_version_id': str(tv.id),
|
||||
'version': tv.version,
|
||||
'created_by': str(t.created_by) if t.created_by else None,
|
||||
'author_full_name': u.full_name if u else '—',
|
||||
}
|
||||
for t, tv, u in rows
|
||||
]
|
||||
|
||||
|
||||
def list_hidden_by_author(user_id: str) -> list[dict]:
|
||||
"""Скрытые автором цепочки (`is_active = false`) — видны только автору."""
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.id, t.title, t.description, t.is_active AS chain_active,
|
||||
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version,
|
||||
t.created_by, u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
||||
INNER JOIN users u ON u.id = t.created_by
|
||||
WHERE t.is_active = false AND t.created_by = :uid
|
||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC
|
||||
"""
|
||||
),
|
||||
{'uid': user_id},
|
||||
).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
import uuid as _uuid
|
||||
session = get_session()
|
||||
try:
|
||||
uid = _uuid.UUID(user_id)
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
rows = (
|
||||
session.query(Test, TestVersion, User)
|
||||
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
||||
.join(User, User.id == Test.created_by)
|
||||
.filter(Test.is_active.is_(False), Test.created_by == uid)
|
||||
.order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'id': str(t.id),
|
||||
'title': t.title,
|
||||
'description': t.description,
|
||||
'chain_active': t.is_active,
|
||||
'created_at': t.created_at,
|
||||
'updated_at': t.updated_at,
|
||||
'active_version_id': str(tv.id),
|
||||
'version': tv.version,
|
||||
'created_by': str(t.created_by),
|
||||
'author_full_name': u.full_name,
|
||||
}
|
||||
for t, tv, u in rows
|
||||
]
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
"""Утилиты по цепочке теста (попытки/версии)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import TestAttempt, TestVersion
|
||||
|
||||
|
||||
def has_any_attempt_for_test(conn, test_id: str) -> bool:
|
||||
"""`conn` может быть Connection или Engine — обе поддерживают .execute()."""
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM test_attempts ta
|
||||
INNER JOIN test_versions tv ON ta.test_version_id = tv.id
|
||||
WHERE tv.test_id = :test_id
|
||||
) AS has_any
|
||||
"""
|
||||
),
|
||||
{'test_id': test_id},
|
||||
).first()
|
||||
return bool(row[0])
|
||||
def has_any_attempt_for_test(session: Session, test_id) -> bool:
|
||||
"""Возвращает True, если для теста есть хотя бы одна попытка."""
|
||||
import uuid as _uuid
|
||||
if not isinstance(test_id, _uuid.UUID):
|
||||
try:
|
||||
test_id = _uuid.UUID(str(test_id))
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
return session.query(
|
||||
session.query(TestAttempt)
|
||||
.join(TestVersion, TestAttempt.test_version_id == TestVersion.id)
|
||||
.filter(TestVersion.test_id == test_id)
|
||||
.exists()
|
||||
).scalar()
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Создание/правка теста, fork версии при наличии попыток (порт `testDraftService.js`)."""
|
||||
"""Создание/правка теста, fork версии при наличии попыток."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_engine
|
||||
from ..db import get_session
|
||||
from ..messages import RU
|
||||
from ..models import AnswerOption, Question, Test, TestVersion
|
||||
from .test_access import is_test_author
|
||||
from .test_chain import has_any_attempt_for_test
|
||||
|
||||
@@ -19,216 +22,177 @@ class HttpError(Exception):
|
||||
|
||||
|
||||
def create_test_with_version(author_id: str, *, title: str, description: str | None) -> dict:
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
t = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tests (title, description, created_by, is_active, is_versioned)
|
||||
VALUES (:title, :desc, :uid, true, true) RETURNING id
|
||||
"""
|
||||
),
|
||||
{'title': title, 'desc': description or None, 'uid': author_id},
|
||||
).mappings().first()
|
||||
test_id = t['id']
|
||||
v = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||
VALUES (:tid, 1, true, NULL) RETURNING id
|
||||
"""
|
||||
),
|
||||
{'tid': test_id},
|
||||
).mappings().first()
|
||||
return {'testId': str(test_id), 'versionId': str(v['id'])}
|
||||
session = get_session()
|
||||
try:
|
||||
uid = _uuid.UUID(author_id)
|
||||
except (ValueError, AttributeError):
|
||||
raise HttpError(400, 'Некорректный user_id.')
|
||||
|
||||
test = Test(
|
||||
title=title,
|
||||
description=description or None,
|
||||
created_by=uid,
|
||||
is_active=True,
|
||||
is_versioned=True,
|
||||
)
|
||||
session.add(test)
|
||||
session.flush() # получаем test.id
|
||||
|
||||
version = TestVersion(test_id=test.id, version=1, is_active=True, parent_id=None)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
return {'testId': str(test.id), 'versionId': str(version.id)}
|
||||
|
||||
|
||||
def _get_active_version_row(conn, test_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
text(
|
||||
'SELECT * FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1'
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
return dict(row) if row else None
|
||||
def _get_active_version(session: Session, test_id: _uuid.UUID) -> TestVersion | None:
|
||||
return (
|
||||
session.query(TestVersion)
|
||||
.filter(TestVersion.test_id == test_id, TestVersion.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _copy_question_tree(conn, from_version_id, to_version_id) -> None:
|
||||
questions = conn.execute(
|
||||
text(
|
||||
'SELECT id, text, question_order, has_multiple_answers '
|
||||
'FROM questions WHERE test_version_id = :v ORDER BY question_order'
|
||||
),
|
||||
{'v': from_version_id},
|
||||
).mappings().all()
|
||||
def _copy_question_tree(session: Session, from_version_id, to_version_id) -> None:
|
||||
questions = (
|
||||
session.query(Question)
|
||||
.filter(Question.test_version_id == from_version_id)
|
||||
.order_by(Question.question_order)
|
||||
.all()
|
||||
)
|
||||
for q in questions:
|
||||
new_q = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
||||
VALUES (:v, :text, :ord, :multi) RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
'v': to_version_id,
|
||||
'text': q['text'],
|
||||
'ord': q['question_order'],
|
||||
'multi': q['has_multiple_answers'],
|
||||
},
|
||||
).mappings().first()
|
||||
nqid = new_q['id']
|
||||
opts = conn.execute(
|
||||
text(
|
||||
'SELECT text, is_correct, option_order FROM answer_options '
|
||||
'WHERE question_id = :q ORDER BY option_order'
|
||||
),
|
||||
{'q': q['id']},
|
||||
).mappings().all()
|
||||
for o in opts:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
||||
VALUES (:q, :text, :ic, :ord)
|
||||
"""
|
||||
),
|
||||
{'q': nqid, 'text': o['text'], 'ic': o['is_correct'], 'ord': o['option_order']},
|
||||
)
|
||||
new_q = Question(
|
||||
test_version_id=to_version_id,
|
||||
text=q.text,
|
||||
question_order=q.question_order,
|
||||
has_multiple_answers=q.has_multiple_answers,
|
||||
ai_hint=q.ai_hint,
|
||||
)
|
||||
session.add(new_q)
|
||||
session.flush()
|
||||
for o in sorted(q.options, key=lambda x: x.option_order):
|
||||
session.add(AnswerOption(
|
||||
question_id=new_q.id,
|
||||
text=o.text,
|
||||
is_correct=o.is_correct,
|
||||
option_order=o.option_order,
|
||||
))
|
||||
|
||||
|
||||
def _replace_version_content(conn, test_version_id, payload: dict) -> None:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM answer_options WHERE question_id IN (
|
||||
SELECT id FROM questions WHERE test_version_id = :v
|
||||
)
|
||||
"""
|
||||
),
|
||||
{'v': test_version_id},
|
||||
)
|
||||
conn.execute(
|
||||
text('DELETE FROM questions WHERE test_version_id = :v'),
|
||||
{'v': test_version_id},
|
||||
)
|
||||
questions = payload.get('questions') or []
|
||||
for i, q in enumerate(questions):
|
||||
ins_q = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
||||
VALUES (:v, :text, :ord, :multi) RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
'v': test_version_id,
|
||||
'text': q.get('text'),
|
||||
'ord': q.get('question_order') or (i + 1),
|
||||
'multi': bool(q.get('hasMultipleAnswers')),
|
||||
},
|
||||
).mappings().first()
|
||||
qid = ins_q['id']
|
||||
opts = q.get('options') or []
|
||||
for j, o in enumerate(opts):
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
||||
VALUES (:q, :text, :ic, :ord)
|
||||
"""
|
||||
),
|
||||
{
|
||||
'q': qid,
|
||||
'text': o.get('text'),
|
||||
'ic': bool(o.get('isCorrect')),
|
||||
'ord': o.get('option_order') or (j + 1),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _fork_new_version(conn, test_id: str) -> dict:
|
||||
av = _get_active_version_row(conn, test_id)
|
||||
def _fork_new_version(session: Session, test_id: _uuid.UUID) -> TestVersion:
|
||||
av = _get_active_version(session, test_id)
|
||||
if not av:
|
||||
raise HttpError(500, RU['internal']) # invariant: должна быть активная версия
|
||||
mx = conn.execute(
|
||||
text(
|
||||
'SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = :t'
|
||||
),
|
||||
{'t': test_id},
|
||||
).mappings().first()
|
||||
next_v = (mx['v'] or 0) + 1
|
||||
conn.execute(
|
||||
text('UPDATE test_versions SET is_active = false WHERE test_id = :t'),
|
||||
{'t': test_id},
|
||||
raise HttpError(500, RU['internal'] if 'internal' in RU else 'Внутренняя ошибка.')
|
||||
|
||||
max_ver = (
|
||||
session.query(func.coalesce(func.max(TestVersion.version), 0))
|
||||
.filter(TestVersion.test_id == test_id)
|
||||
.scalar() or 0
|
||||
)
|
||||
nv = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||
VALUES (:t, :ver, true, :parent) RETURNING *
|
||||
"""
|
||||
),
|
||||
{'t': test_id, 'ver': next_v, 'parent': av['id']},
|
||||
).mappings().first()
|
||||
_copy_question_tree(conn, av['id'], nv['id'])
|
||||
return dict(nv)
|
||||
next_v = int(max_ver) + 1
|
||||
|
||||
# деактивируем все версии
|
||||
session.query(TestVersion).filter(TestVersion.test_id == test_id).update(
|
||||
{TestVersion.is_active: False}, synchronize_session='fetch'
|
||||
)
|
||||
|
||||
new_version = TestVersion(
|
||||
test_id=test_id,
|
||||
version=next_v,
|
||||
is_active=True,
|
||||
parent_id=av.id,
|
||||
)
|
||||
session.add(new_version)
|
||||
session.flush()
|
||||
_copy_question_tree(session, av.id, new_version.id)
|
||||
return new_version
|
||||
|
||||
|
||||
def _replace_version_content(session: Session, version: TestVersion, payload: dict) -> None:
|
||||
# Снимок ai_hint по тексту вопроса перед удалением
|
||||
old_hints: dict[str, str] = {}
|
||||
for q in version.questions:
|
||||
if q.ai_hint and q.text not in old_hints:
|
||||
old_hints[q.text] = q.ai_hint
|
||||
|
||||
# удаляем через cascade (answer_options удалятся каскадно через ORM)
|
||||
for q in list(version.questions):
|
||||
session.delete(q)
|
||||
session.flush()
|
||||
|
||||
questions_payload = payload.get('questions') or []
|
||||
for i, qp in enumerate(questions_payload):
|
||||
q_text = (qp.get('text') or '').strip()
|
||||
new_q = Question(
|
||||
test_version_id=version.id,
|
||||
text=q_text,
|
||||
question_order=qp.get('question_order') or (i + 1),
|
||||
has_multiple_answers=bool(qp.get('hasMultipleAnswers')),
|
||||
ai_hint=old_hints.get(q_text),
|
||||
)
|
||||
session.add(new_q)
|
||||
session.flush()
|
||||
for j, op in enumerate(qp.get('options') or []):
|
||||
session.add(AnswerOption(
|
||||
question_id=new_q.id,
|
||||
text=(op.get('text') or '').strip(),
|
||||
is_correct=bool(op.get('isCorrect')),
|
||||
option_order=op.get('option_order') or (j + 1),
|
||||
))
|
||||
|
||||
|
||||
def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
eng = get_engine()
|
||||
with eng.begin() 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, RU['testNotFound'] if 'testNotFound' in RU else 'Тест не найден.')
|
||||
if not is_test_author(t['created_by'], author_id):
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
session = get_session()
|
||||
try:
|
||||
tid = _uuid.UUID(test_id)
|
||||
except (ValueError, AttributeError):
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
|
||||
if payload.get('title') is not None or payload.get('description') is not None:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tests
|
||||
SET title = COALESCE(:title, title),
|
||||
description = COALESCE(:desc, description),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{
|
||||
'title': payload.get('title'),
|
||||
'desc': payload.get('description'),
|
||||
'id': test_id,
|
||||
},
|
||||
)
|
||||
if payload.get('passingThreshold') is not None:
|
||||
try:
|
||||
raw = float(payload['passingThreshold'])
|
||||
pt = max(0, min(100, round(raw)))
|
||||
conn.execute(
|
||||
text(
|
||||
'UPDATE tests SET passing_threshold = :pt, updated_at = CURRENT_TIMESTAMP WHERE id = :id'
|
||||
),
|
||||
{'pt': pt, 'id': test_id},
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, author_id):
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||
version_row = _get_active_version_row(conn, test_id)
|
||||
if not version_row:
|
||||
raise HttpError(500, 'Нет активной версии теста.')
|
||||
if payload.get('title') is not None:
|
||||
test.title = payload['title']
|
||||
if payload.get('description') is not None:
|
||||
test.description = payload['description'] or None
|
||||
|
||||
forked = False
|
||||
if has_attempts and 'questions' in payload and payload.get('questions') is not None:
|
||||
version_row = _fork_new_version(conn, test_id)
|
||||
forked = True
|
||||
if payload.get('passingThreshold') is not None:
|
||||
try:
|
||||
test.passing_threshold = max(0, min(100, round(float(payload['passingThreshold']))))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if payload.get('questions') is not None:
|
||||
_replace_version_content(conn, version_row['id'], payload)
|
||||
if 'timeLimit' in payload:
|
||||
tl = payload.get('timeLimit')
|
||||
try:
|
||||
test.time_limit = None if tl in (None, '', 0) else max(0, int(tl))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return {'testId': test_id, 'versionId': str(version_row['id']), 'forked': forked}
|
||||
if 'hintsEnabled' in payload:
|
||||
test.hints_enabled = bool(payload['hintsEnabled'])
|
||||
|
||||
if 'resultMode' in payload:
|
||||
rm = (payload.get('resultMode') or '').strip().lower()
|
||||
if rm in ('immediate', 'end'):
|
||||
test.result_mode = rm
|
||||
|
||||
has_attempts = has_any_attempt_for_test(session, tid)
|
||||
active_version = _get_active_version(session, tid)
|
||||
if not active_version:
|
||||
raise HttpError(500, 'Нет активной версии теста.')
|
||||
|
||||
forked = False
|
||||
if has_attempts and 'questions' in payload and payload.get('questions') is not None:
|
||||
active_version = _fork_new_version(session, tid)
|
||||
forked = True
|
||||
|
||||
if payload.get('questions') is not None:
|
||||
_replace_version_content(session, active_version, payload)
|
||||
|
||||
session.commit()
|
||||
return {'testId': test_id, 'versionId': str(active_version.id), 'forked': forked}
|
||||
|
||||
Reference in New Issue
Block a user