UI bugfixes with boss

This commit is contained in:
Константин Лебединский
2026-04-30 19:53:49 +05:00
parent df6e770f90
commit b72b485fce
17 changed files with 469 additions and 250 deletions
+8 -3
View File
@@ -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'],
}
+2
View File
@@ -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': (
+13
View File
@@ -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
+2 -2
View File
@@ -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 = (
+60 -6
View File
@@ -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
]
+7 -4
View File
@@ -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 = (
+2 -2
View File
@@ -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: