UI bugfixes with boss
This commit is contained in:
@@ -6,6 +6,7 @@ from typing import Any
|
||||
from .draft_validator import (
|
||||
assert_draft_matches_shape,
|
||||
parse_json_from_llm_text,
|
||||
shuffle_options_in_questions,
|
||||
validate_and_normalize_draft,
|
||||
)
|
||||
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
||||
@@ -103,6 +104,7 @@ def generate_full_test_by_shape(test_title: str, test_description: str, shape: l
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
draft = validate_and_normalize_draft(parsed)
|
||||
assert_draft_matches_shape({'questions': draft['questions']}, shape)
|
||||
shuffle_options_in_questions(draft['questions'])
|
||||
return {
|
||||
'title': draft['title'],
|
||||
'description': draft['description'],
|
||||
@@ -153,6 +155,7 @@ def generate_test_by_title(
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
draft = validate_and_normalize_draft(parsed)
|
||||
shuffle_options_in_questions(draft['questions'])
|
||||
return {
|
||||
'title': draft['title'],
|
||||
'description': draft['description'],
|
||||
@@ -475,9 +478,11 @@ def generate_or_rephrase_question(
|
||||
shape = [{'optionsCount': n, 'hasMultipleAnswers': bool(has_multiple_answers)}]
|
||||
assert_draft_matches_shape({'questions': [parsed]}, shape)
|
||||
draft = validate_and_normalize_draft({'title': 'временно', 'questions': [parsed]})
|
||||
q0 = draft['questions'][0]
|
||||
shuffle_options_in_questions([q0])
|
||||
return {
|
||||
'mode': 'full',
|
||||
'text': draft['questions'][0]['text'],
|
||||
'hasMultipleAnswers': draft['questions'][0]['hasMultipleAnswers'],
|
||||
'options': draft['questions'][0]['options'],
|
||||
'text': q0['text'],
|
||||
'hasMultipleAnswers': q0['hasMultipleAnswers'],
|
||||
'options': q0['options'],
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
from .draft_validator import (
|
||||
normalize_draft_to_shape,
|
||||
parse_json_from_llm_text,
|
||||
shuffle_options_in_questions,
|
||||
validate_and_normalize_draft,
|
||||
)
|
||||
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
||||
@@ -64,6 +65,7 @@ def generation_for_import_document(extracted_text: str, user_hint: str = '', sha
|
||||
draft = validate_and_normalize_draft(parsed)
|
||||
if shape:
|
||||
draft = normalize_draft_to_shape(draft, shape)
|
||||
shuffle_options_in_questions(draft['questions'])
|
||||
return {
|
||||
'available': True,
|
||||
'message': (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import random
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
@@ -79,6 +80,18 @@ def validate_and_normalize_draft(o: Any) -> dict:
|
||||
return {'title': title, 'description': description, 'questions': questions}
|
||||
|
||||
|
||||
def shuffle_options_in_questions(questions: list[dict] | None) -> None:
|
||||
"""Случайный порядок вариантов в каждом вопросе; isCorrect остаётся у соответствующего текста."""
|
||||
if not questions:
|
||||
return
|
||||
for q in questions:
|
||||
if not isinstance(q, dict):
|
||||
continue
|
||||
opts = q.get('options')
|
||||
if isinstance(opts, list) and len(opts) >= 2:
|
||||
random.shuffle(opts)
|
||||
|
||||
|
||||
def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None:
|
||||
"""Проверяет, что число вопросов и вариантов = ровно как в shape."""
|
||||
qs = o.get('questions') if isinstance(o, dict) else None
|
||||
|
||||
@@ -9,7 +9,7 @@ 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_access import is_test_author, is_test_edit_open
|
||||
from .test_chain import has_any_attempt_for_test
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ def get_editor_content(user_id: str, test_id: str) -> dict:
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, user_id):
|
||||
if not is_test_author(test.created_by, user_id) and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
active_version = (
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
"""Кто видит тест: автор + назначенные пользователи."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy import exists, func
|
||||
|
||||
from ..db import get_session
|
||||
from ..models import Test, TestAssignment, TestAssignmentTarget, TestAttempt, TestVersion, User
|
||||
from ..models import Question, Test, TestAssignment, TestAssignmentTarget, TestAttempt, TestVersion, User
|
||||
|
||||
|
||||
def _truthy_env(val: str | None) -> bool:
|
||||
return (val or '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def is_test_edit_open() -> bool:
|
||||
"""Пока без RBAC: любой залогиненный пользователь может править любой тест.
|
||||
|
||||
Задайте ``CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR=1``, чтобы снова требовать роль автора
|
||||
для редактирования, подсказок, списка попыток и разбора чужих попыток.
|
||||
"""
|
||||
return not _truthy_env(os.environ.get('CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR'))
|
||||
|
||||
|
||||
def is_test_author(created_by, user_id) -> bool:
|
||||
@@ -64,10 +78,20 @@ def list_visible_tests(user_id: str) -> list[dict]:
|
||||
except (ValueError, AttributeError):
|
||||
uid = None
|
||||
|
||||
qcount_sq = (
|
||||
session.query(
|
||||
Question.test_version_id.label('tv_id'),
|
||||
func.count(Question.id).label('qc'),
|
||||
)
|
||||
.group_by(Question.test_version_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
session.query(Test, TestVersion, User)
|
||||
session.query(Test, TestVersion, User, qcount_sq.c.qc)
|
||||
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
||||
.outerjoin(User, User.id == Test.created_by)
|
||||
.outerjoin(qcount_sq, qcount_sq.c.tv_id == TestVersion.id)
|
||||
.filter(Test.is_active.is_(True))
|
||||
.order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
|
||||
.all()
|
||||
@@ -85,6 +109,11 @@ def list_visible_tests(user_id: str) -> list[dict]:
|
||||
'version': tv.version,
|
||||
'created_by': str(t.created_by) if t.created_by else None,
|
||||
'author_full_name': u.full_name if u else '—',
|
||||
'passing_threshold': int(t.passing_threshold or 0),
|
||||
'time_limit': t.time_limit,
|
||||
'result_mode': (t.result_mode or 'end'),
|
||||
'hints_enabled': bool(t.hints_enabled),
|
||||
'questions_count': int(qc or 0),
|
||||
'has_in_progress_attempt': bool(
|
||||
uid and session.query(
|
||||
exists().where(
|
||||
@@ -96,7 +125,7 @@ def list_visible_tests(user_id: str) -> list[dict]:
|
||||
).scalar()
|
||||
),
|
||||
}
|
||||
for t, tv, u in rows
|
||||
for t, tv, u, qc in rows
|
||||
]
|
||||
|
||||
|
||||
@@ -108,10 +137,20 @@ def list_hidden_by_author(user_id: str) -> list[dict]:
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
qcount_sq = (
|
||||
session.query(
|
||||
Question.test_version_id.label('tv_id'),
|
||||
func.count(Question.id).label('qc'),
|
||||
)
|
||||
.group_by(Question.test_version_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
session.query(Test, TestVersion, User)
|
||||
session.query(Test, TestVersion, User, qcount_sq.c.qc)
|
||||
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
||||
.join(User, User.id == Test.created_by)
|
||||
.outerjoin(qcount_sq, qcount_sq.c.tv_id == TestVersion.id)
|
||||
.filter(Test.is_active.is_(False), Test.created_by == uid)
|
||||
.order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
|
||||
.all()
|
||||
@@ -129,6 +168,21 @@ def list_hidden_by_author(user_id: str) -> list[dict]:
|
||||
'version': tv.version,
|
||||
'created_by': str(t.created_by),
|
||||
'author_full_name': u.full_name,
|
||||
'passing_threshold': int(t.passing_threshold or 0),
|
||||
'time_limit': t.time_limit,
|
||||
'result_mode': (t.result_mode or 'end'),
|
||||
'hints_enabled': bool(t.hints_enabled),
|
||||
'questions_count': int(qc or 0),
|
||||
'has_in_progress_attempt': bool(
|
||||
session.query(
|
||||
exists().where(
|
||||
TestAttempt.user_id == uid,
|
||||
TestAttempt.status == 'in_progress',
|
||||
TestAttempt.test_version_id == TestVersion.id,
|
||||
TestVersion.test_id == t.id,
|
||||
)
|
||||
).scalar()
|
||||
),
|
||||
}
|
||||
for t, tv, u in rows
|
||||
for t, tv, u, qc in rows
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@ from ..models import (
|
||||
User,
|
||||
UserAnswer,
|
||||
)
|
||||
from .test_access import is_test_author, user_has_test_access
|
||||
from .test_access import is_test_author, is_test_edit_open, user_has_test_access
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
@@ -311,6 +311,9 @@ def build_review_from_db(session: Session, attempt_id: str) -> dict:
|
||||
'testId': str(test.id),
|
||||
'testTitle': test.title,
|
||||
'passingThreshold': int(test.passing_threshold or 0),
|
||||
'timeLimit': test.time_limit,
|
||||
'resultMode': test.result_mode or 'end',
|
||||
'hintsEnabled': bool(test.hints_enabled),
|
||||
'correctCount': int(attempt.correct_count or 0),
|
||||
'totalQuestions': total,
|
||||
'percent': percent,
|
||||
@@ -343,7 +346,7 @@ def get_attempt_review_for_user(session_or_eng, current_user_id: str, test_id: s
|
||||
|
||||
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:
|
||||
if not is_owner and not is_author and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
return build_review_from_db(session, attempt_id)
|
||||
|
||||
@@ -387,7 +390,7 @@ def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, author_id):
|
||||
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
active_version = (
|
||||
@@ -507,7 +510,7 @@ def list_test_attempts_for_author(session_or_eng, author_id: str, test_id: str)
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, author_id):
|
||||
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
rows = (
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
||||
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_access import is_test_author, is_test_edit_open
|
||||
from .test_chain import has_any_attempt_for_test
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict:
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, author_id):
|
||||
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
if payload.get('title') is not None:
|
||||
|
||||
Reference in New Issue
Block a user