блоки 2 и 3 доработки интерфейса системы тестирования
This commit is contained in:
@@ -32,6 +32,7 @@ services:
|
||||
HR_AUTH: ${HR_AUTH:-1}
|
||||
HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test}
|
||||
UI_VARIANT: ${UI_VARIANT_MODERN:-modern}
|
||||
DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-}
|
||||
# LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта.
|
||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
@@ -59,6 +60,7 @@ services:
|
||||
HR_AUTH: ${HR_AUTH:-1}
|
||||
HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test}
|
||||
UI_VARIANT: ${UI_VARIANT_LEGACY:-legacy}
|
||||
DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-}
|
||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
# URL задаётся динамически в env.py через get_database_url()
|
||||
sqlalchemy.url =
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Alembic environment — подключает модели и настраивает миграции."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# Добавляем flask_app в sys.path, чтобы `from app...` работало.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from app.db import get_database_url
|
||||
from app.models import Base # noqa: E402 — импорт после sys.path
|
||||
|
||||
config = context.config
|
||||
config.set_main_option('sqlalchemy.url', get_database_url())
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option('sqlalchemy.url')
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Baseline — existing schema recognised by ORM.
|
||||
|
||||
Revision ID: 0001_baseline
|
||||
Revises:
|
||||
Create Date: 2026-04-29
|
||||
|
||||
Эта ревизия не создаёт таблиц: схема существует. Она фиксирует текущее
|
||||
состояние как отправную точку для будущих автогенерированных миграций.
|
||||
|
||||
Чтобы применить при первом запуске:
|
||||
alembic stamp 0001_baseline # если схема уже есть
|
||||
# или:
|
||||
alembic upgrade head # если схема пустая
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "0001_baseline"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass # схема уже создана SQL-скриптами / Docker entrypoint
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -69,6 +69,12 @@ def create_app() -> Flask:
|
||||
app.register_blueprint(tests_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
|
||||
from . import db as _db
|
||||
|
||||
@app.teardown_appcontext
|
||||
def _shutdown_session(exc): # noqa: ARG001
|
||||
_db.remove_session()
|
||||
|
||||
from .config import is_assignment_feature_enabled, is_dev_ui, is_hr_auth_enabled
|
||||
from .auth.decorators import current_user as _current_user
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from flask import (
|
||||
url_for,
|
||||
)
|
||||
|
||||
from ..config import is_assignment_feature_enabled, is_dev_ui
|
||||
from ..config import get_dev_fio_password, is_assignment_feature_enabled, is_dev_ui
|
||||
from ..messages import RU
|
||||
from .decorators import login_required, current_user
|
||||
from .services import AuthError, authenticate_credentials
|
||||
@@ -46,7 +46,11 @@ def _do_login(login: str, password: str):
|
||||
def login_page():
|
||||
if current_user() is not None:
|
||||
return redirect(_safe_next('/'))
|
||||
return render_template('auth/login.html', next=_safe_next('/'))
|
||||
return render_template(
|
||||
'auth/login.html',
|
||||
next=_safe_next('/'),
|
||||
dev_fio_enabled=bool(get_dev_fio_password()),
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
@@ -57,11 +61,21 @@ def login_submit():
|
||||
_do_login(login, password)
|
||||
except AuthError as e:
|
||||
flash(e.message, 'error')
|
||||
return render_template('auth/login.html', next=_safe_next('/'), login=login), e.status
|
||||
return render_template(
|
||||
'auth/login.html',
|
||||
next=_safe_next('/'),
|
||||
login=login,
|
||||
dev_fio_enabled=bool(get_dev_fio_password()),
|
||||
), e.status
|
||||
except Exception:
|
||||
log.exception('login_submit failed')
|
||||
flash(RU['loginFailed'], 'error')
|
||||
return render_template('auth/login.html', next=_safe_next('/'), login=login), 500
|
||||
return render_template(
|
||||
'auth/login.html',
|
||||
next=_safe_next('/'),
|
||||
login=login,
|
||||
dev_fio_enabled=bool(get_dev_fio_password()),
|
||||
), 500
|
||||
return redirect(_safe_next('/'))
|
||||
|
||||
|
||||
@@ -96,6 +110,56 @@ def api_logout():
|
||||
return jsonify(message=RU['loggedOut'])
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/dev/assignment-directory', methods=['GET'])
|
||||
@login_required
|
||||
def api_assignment_directory():
|
||||
"""Список сотрудников для назначения теста с поиском по имени/логину и фильтром отдела."""
|
||||
from ..db import get_session
|
||||
from ..models import Department, User as UserModel
|
||||
|
||||
q_str = (request.args.get('q') or '').strip()
|
||||
dept_filter = (request.args.get('department') or '').strip()
|
||||
clinic_filter = (request.args.get('clinic') or 'all').strip()
|
||||
|
||||
session = get_session()
|
||||
|
||||
dept_rows = session.query(Department).order_by(Department.name).all()
|
||||
dept_by_name = {d.name: d.id for d in dept_rows}
|
||||
departments = [d.name for d in dept_rows]
|
||||
|
||||
query = (
|
||||
session.query(UserModel)
|
||||
.filter(UserModel.is_active.is_(True))
|
||||
)
|
||||
if q_str:
|
||||
like = f'%{q_str}%'
|
||||
query = query.filter(
|
||||
UserModel.full_name.ilike(like) | UserModel.login.ilike(like)
|
||||
)
|
||||
if dept_filter and dept_filter in dept_by_name:
|
||||
query = query.filter(UserModel.department_id == dept_by_name[dept_filter])
|
||||
if clinic_filter == 'with':
|
||||
query = query.filter(UserModel.staff_id.isnot(None))
|
||||
elif clinic_filter == 'without':
|
||||
query = query.filter(UserModel.staff_id.is_(None))
|
||||
|
||||
users = query.order_by(UserModel.full_name).limit(200).all()
|
||||
|
||||
dept_name_by_id = {d.id: d.name for d in dept_rows}
|
||||
people = [
|
||||
{
|
||||
'staffId': str(u.id),
|
||||
'fio': u.full_name,
|
||||
'webLogin': u.login,
|
||||
'role': u.role,
|
||||
'department': dept_name_by_id.get(u.department_id) if u.department_id else None,
|
||||
'clinicUserId': u.staff_id,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
return jsonify(people=people, departments=departments)
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/me', methods=['GET'])
|
||||
@login_required
|
||||
def api_me():
|
||||
|
||||
+163
-98
@@ -1,16 +1,13 @@
|
||||
"""Бизнес-логика логина — порт `backend/src/routes/auth.js` + `utils/auth.js`.
|
||||
"""Бизнес-логика логина (ORM-версия).
|
||||
|
||||
Поддерживает оба режима:
|
||||
- Локальный (по умолчанию): bcrypt в `clinic_tests.users.password_hash`.
|
||||
- HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (Werkzeug scrypt/pbkdf2),
|
||||
затем находим запись `staff_members` по `web_login` и UPSERT-им в
|
||||
`clinic_tests.users` по `staff_id`.
|
||||
|
||||
Werkzeug-хеши проверяются через `werkzeug.security.check_password_hash`,
|
||||
bcrypt-хеши — через пакет `bcrypt`.
|
||||
- Локальный (по умолчанию): bcrypt / Werkzeug в `clinic_tests.users.password_hash`.
|
||||
- HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (raw SQL, внешняя схема),
|
||||
затем UPSERT в `clinic_tests.users` по `staff_id`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@@ -20,16 +17,18 @@ from werkzeug.security import check_password_hash as _werkzeug_check
|
||||
|
||||
from ..config import (
|
||||
HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
get_dev_fio_password,
|
||||
is_hr_auth_enabled,
|
||||
)
|
||||
from ..db import get_engine, get_hr_engine
|
||||
from ..db import get_engine, get_session
|
||||
from ..messages import RU
|
||||
from ..models import User
|
||||
from .hr_role import map_hr_role_to_app
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthUser:
|
||||
id: str # UUID в виде строки
|
||||
id: str
|
||||
login: str
|
||||
full_name: str | None
|
||||
role: str
|
||||
@@ -37,20 +36,17 @@ class AuthUser:
|
||||
staff_id: Optional[int]
|
||||
|
||||
def to_public_dict(self) -> dict:
|
||||
out = {
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'login': self.login,
|
||||
'fullName': self.full_name,
|
||||
'role': self.role,
|
||||
'departmentId': self.department_id,
|
||||
'departmentId': str(self.department_id) if self.department_id else None,
|
||||
'staffId': self.staff_id,
|
||||
}
|
||||
out['staffId'] = self.staff_id
|
||||
return out
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""Ошибка авторизации с HTTP-кодом и сообщением для пользователя."""
|
||||
|
||||
def __init__(self, status: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
@@ -58,7 +54,6 @@ class AuthError(Exception):
|
||||
|
||||
|
||||
def _verify_password(plain: str, hashed: str | None) -> bool:
|
||||
"""Универсальная проверка пароля: bcrypt + Werkzeug (scrypt/pbkdf2)."""
|
||||
if not hashed:
|
||||
return False
|
||||
if hashed == HR_MANAGED_PASSWORD_PLACEHOLDER:
|
||||
@@ -79,53 +74,136 @@ def _verify_password(plain: str, hashed: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _user_to_auth(user: User) -> AuthUser:
|
||||
return AuthUser(
|
||||
id=str(user.id),
|
||||
login=user.login,
|
||||
full_name=user.full_name,
|
||||
role=user.role,
|
||||
department_id=str(user.department_id) if user.department_id else None,
|
||||
staff_id=user.staff_id,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_fio(s: str) -> str:
|
||||
"""Приводим ФИО к одному виду: trim, пробелы, неразрывный пробел, много переводов строк."""
|
||||
t = str(s or '')
|
||||
t = t.replace('\u00a0', ' ')
|
||||
return re.sub(r'\s+', ' ', t).strip()
|
||||
|
||||
|
||||
def authenticate_credentials(login: str, password: str) -> AuthUser:
|
||||
"""Главная точка входа. Возвращает AuthUser или поднимает AuthError."""
|
||||
login = (login or '').strip()
|
||||
password = password or ''
|
||||
if not login or not password:
|
||||
raise AuthError(400, RU['loginAndPasswordRequired'])
|
||||
|
||||
# Dev-режим: вход по ФИО из HR с общим паролем
|
||||
dev_pw = get_dev_fio_password()
|
||||
if dev_pw and password.strip() == dev_pw.strip():
|
||||
try:
|
||||
return _authenticate_dev_fio(login)
|
||||
except AuthError:
|
||||
pass # если ФИО не найдено — продолжаем обычную проверку
|
||||
|
||||
if is_hr_auth_enabled():
|
||||
return _authenticate_via_hr(login, password)
|
||||
return _authenticate_local(login, password)
|
||||
|
||||
|
||||
# ─── локальный режим ────────────────────────────────────────────────
|
||||
def _authenticate_dev_fio(fio_raw: str) -> AuthUser:
|
||||
"""Dev-режим: найти сотрудника по ФИО в HR-системе и создать/обновить локального пользователя."""
|
||||
from ..db import get_hr_engine
|
||||
hr_eng = get_hr_engine()
|
||||
if hr_eng is None:
|
||||
raise AuthError(500, RU['hrDatabaseUrlMissing'])
|
||||
|
||||
fio_needle = _normalize_fio(fio_raw)
|
||||
if not fio_needle:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
|
||||
needle = fio_needle.lower()
|
||||
|
||||
with hr_eng.connect() as hr_conn:
|
||||
rows = hr_conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, fio, web_login
|
||||
FROM staff_members
|
||||
WHERE fio IS NOT NULL
|
||||
AND lower(
|
||||
regexp_replace(
|
||||
trim(replace(replace(coalesce(fio, ''), chr(160), ' '), E'\\t', ' ')),
|
||||
'[[:space:]]+', ' ', 'g'
|
||||
)
|
||||
) = :needle
|
||||
"""
|
||||
),
|
||||
{'needle': needle},
|
||||
).mappings().all()
|
||||
if len(rows) != 1:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
s = rows[0]
|
||||
|
||||
web_login = (s['web_login'] or '').strip() or f'fio_{s["id"]}'
|
||||
|
||||
staff_id = int(s['id'])
|
||||
full_name = s['fio'] or fio_raw
|
||||
|
||||
session = get_session()
|
||||
|
||||
user = session.query(User).filter(User.staff_id == staff_id).first()
|
||||
if not user:
|
||||
# Не переиспользовать строку другого человека только по совпадению логина HR
|
||||
occupied = session.query(User).filter(User.login == web_login).first()
|
||||
login_val = web_login
|
||||
if occupied and occupied.staff_id != staff_id:
|
||||
login_val = f'hr_staff_{staff_id}'
|
||||
user = User(
|
||||
login=login_val,
|
||||
password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
full_name=full_name,
|
||||
role='employee',
|
||||
is_active=True,
|
||||
staff_id=staff_id,
|
||||
)
|
||||
session.add(user)
|
||||
else:
|
||||
user.full_name = full_name
|
||||
user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER
|
||||
user.is_active = True
|
||||
# Обновить логин с HR только если конфликта нет
|
||||
if web_login != user.login:
|
||||
taken = session.query(User).filter(
|
||||
User.login == web_login,
|
||||
User.id != user.id,
|
||||
).first()
|
||||
if not taken:
|
||||
user.login = web_login
|
||||
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return _user_to_auth(user)
|
||||
|
||||
|
||||
def _authenticate_local(login: str, password: str) -> AuthUser:
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
'SELECT id, login, password_hash, full_name, role, department_id, staff_id '
|
||||
'FROM users WHERE login = :login AND is_active = true'
|
||||
),
|
||||
{'login': login},
|
||||
).mappings().first()
|
||||
|
||||
if not row:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
|
||||
if row['password_hash'] == HR_MANAGED_PASSWORD_PLACEHOLDER:
|
||||
raise AuthError(401, RU['useHrLogin'])
|
||||
|
||||
if not _verify_password(password, row['password_hash']):
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
|
||||
return AuthUser(
|
||||
id=str(row['id']),
|
||||
login=row['login'],
|
||||
full_name=row['full_name'],
|
||||
role=row['role'],
|
||||
department_id=row['department_id'],
|
||||
staff_id=row['staff_id'],
|
||||
session = get_session()
|
||||
user = (
|
||||
session.query(User)
|
||||
.filter(User.login == login, User.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not user:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
if user.password_hash == HR_MANAGED_PASSWORD_PLACEHOLDER:
|
||||
raise AuthError(401, RU['useHrLogin'])
|
||||
if not _verify_password(password, user.password_hash):
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
return _user_to_auth(user)
|
||||
|
||||
|
||||
# ─── HR_AUTH=1 ──────────────────────────────────────────────────────
|
||||
|
||||
def _authenticate_via_hr(login: str, password: str) -> AuthUser:
|
||||
from ..db import get_hr_engine
|
||||
hr_eng = get_hr_engine()
|
||||
if hr_eng is None:
|
||||
raise AuthError(500, RU['hrDatabaseUrlMissing'])
|
||||
@@ -157,61 +235,48 @@ def _authenticate_via_hr(login: str, password: str) -> AuthUser:
|
||||
fio = s['fio'] or login
|
||||
app_role = map_hr_role_to_app(u['role'])
|
||||
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (login, password_hash, full_name, role,
|
||||
department_id, is_active, staff_id)
|
||||
VALUES (:login, :ph, :fn, :role, NULL, true, :staff_id)
|
||||
ON CONFLICT (staff_id) DO UPDATE SET
|
||||
login = EXCLUDED.login,
|
||||
full_name = EXCLUDED.full_name,
|
||||
role = EXCLUDED.role,
|
||||
password_hash = EXCLUDED.password_hash
|
||||
RETURNING id, login, full_name, role, department_id, staff_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
'login': login,
|
||||
'ph': HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
'fn': fio,
|
||||
'role': app_role,
|
||||
'staff_id': staff_id,
|
||||
},
|
||||
).mappings().first()
|
||||
|
||||
return AuthUser(
|
||||
id=str(row['id']),
|
||||
login=row['login'],
|
||||
full_name=row['full_name'],
|
||||
role=row['role'],
|
||||
department_id=row['department_id'],
|
||||
staff_id=row['staff_id'],
|
||||
)
|
||||
# UPSERT через ORM: ищем по staff_id, затем по login, затем создаём
|
||||
session = get_session()
|
||||
user = session.query(User).filter(User.staff_id == staff_id).first()
|
||||
if not user:
|
||||
# при первом входе staff_id ещё не проставлен — ищем по login
|
||||
user = session.query(User).filter(User.login == login).first()
|
||||
if user:
|
||||
user.login = login
|
||||
user.full_name = fio
|
||||
user.role = app_role
|
||||
user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER
|
||||
user.staff_id = staff_id
|
||||
user.is_active = True
|
||||
else:
|
||||
user = User(
|
||||
login=login,
|
||||
password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
full_name=fio,
|
||||
role=app_role,
|
||||
is_active=True,
|
||||
staff_id=staff_id,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return _user_to_auth(user)
|
||||
|
||||
|
||||
def load_user_by_id(user_id: str) -> Optional[AuthUser]:
|
||||
"""Догружает пользователя из `clinic_tests.users` (используется при каждом запросе)."""
|
||||
if not user_id:
|
||||
return None
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
'SELECT id, login, full_name, role, department_id, staff_id '
|
||||
'FROM users WHERE id = :id AND is_active = true'
|
||||
),
|
||||
{'id': user_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
session = get_session()
|
||||
try:
|
||||
import uuid as _uuid
|
||||
uid = _uuid.UUID(user_id)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
return AuthUser(
|
||||
id=str(row['id']),
|
||||
login=row['login'],
|
||||
full_name=row['full_name'],
|
||||
role=row['role'],
|
||||
department_id=row['department_id'],
|
||||
staff_id=row['staff_id'],
|
||||
user = (
|
||||
session.query(User)
|
||||
.filter(User.id == uid, User.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not user:
|
||||
return None
|
||||
return _user_to_auth(user)
|
||||
|
||||
@@ -1,18 +1,117 @@
|
||||
"""Главный blueprint — посадочная страница и health-чек.
|
||||
|
||||
В E1.0 здесь нет бизнес-логики; страницы и эндпоинты добавляются в следующих
|
||||
спринтах (E1.1 — auth, E1.2 — тесты, и т.д.).
|
||||
"""
|
||||
"""Главный blueprint — посадочная страница, статистика и health-чек."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
|
||||
from .. import db as app_db
|
||||
from ..auth.decorators import login_required
|
||||
from ..auth.decorators import current_user as get_current_user
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
def _get_stats() -> dict:
|
||||
"""Собирает статистику прохождений для дашборда."""
|
||||
from sqlalchemy import func, case
|
||||
from ..db import get_session
|
||||
from ..models import Department, User, Test, TestAttempt
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
total_tests = session.query(func.count(Test.id)).scalar() or 0
|
||||
active_tests = session.query(func.count(Test.id)).filter(Test.is_active.is_(True)).scalar() or 0
|
||||
|
||||
total_completed = (
|
||||
session.query(func.count(TestAttempt.id))
|
||||
.filter(TestAttempt.status == 'completed')
|
||||
.scalar() or 0
|
||||
)
|
||||
total_passed = (
|
||||
session.query(func.count(TestAttempt.id))
|
||||
.filter(TestAttempt.status == 'completed', TestAttempt.passed.is_(True))
|
||||
.scalar() or 0
|
||||
)
|
||||
total_users = session.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0
|
||||
|
||||
# Статистика по отделам
|
||||
dept_rows = (
|
||||
session.query(
|
||||
Department.name,
|
||||
func.count(TestAttempt.id).label('total'),
|
||||
func.sum(
|
||||
case((TestAttempt.passed.is_(True), 1), else_=0)
|
||||
).label('passed_count'),
|
||||
)
|
||||
.join(User, User.department_id == Department.id)
|
||||
.join(TestAttempt, TestAttempt.user_id == User.id)
|
||||
.filter(TestAttempt.status == 'completed')
|
||||
.group_by(Department.id, Department.name)
|
||||
.order_by(Department.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
from ..models import TestVersion
|
||||
recent_rows = (
|
||||
session.query(
|
||||
TestAttempt.started_at,
|
||||
TestAttempt.completed_at,
|
||||
TestAttempt.passed,
|
||||
TestAttempt.correct_count,
|
||||
TestAttempt.total_questions,
|
||||
User.full_name,
|
||||
User.login,
|
||||
Test.title.label('test_title'),
|
||||
)
|
||||
.join(User, User.id == TestAttempt.user_id)
|
||||
.join(TestVersion, TestVersion.id == TestAttempt.test_version_id)
|
||||
.join(Test, Test.id == TestVersion.test_id)
|
||||
.filter(TestAttempt.status == 'completed')
|
||||
.order_by(TestAttempt.completed_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
dept_stats = [
|
||||
{
|
||||
'name': r.name,
|
||||
'total': r.total,
|
||||
'passed': int(r.passed_count or 0),
|
||||
'rate': round(100 * int(r.passed_count or 0) / r.total) if r.total else 0,
|
||||
}
|
||||
for r in dept_rows
|
||||
]
|
||||
|
||||
recent_list = [
|
||||
{
|
||||
'user': r.full_name or r.login,
|
||||
'test': r.test_title,
|
||||
'passed': r.passed,
|
||||
'score': f'{r.correct_count}/{r.total_questions}' if r.correct_count is not None else '—',
|
||||
'at': r.completed_at.strftime('%d.%m %H:%M') if r.completed_at else '—',
|
||||
}
|
||||
for r in recent_rows
|
||||
]
|
||||
|
||||
pass_rate = round(100 * total_passed / total_completed) if total_completed else 0
|
||||
|
||||
return {
|
||||
'total_tests': total_tests,
|
||||
'active_tests': active_tests,
|
||||
'total_completed': total_completed,
|
||||
'total_passed': total_passed,
|
||||
'total_users': total_users,
|
||||
'pass_rate': pass_rate,
|
||||
'dept_stats': dept_stats,
|
||||
'recent': recent_list,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
'total_tests': 0, 'active_tests': 0, 'total_completed': 0,
|
||||
'total_passed': 0, 'total_users': 0, 'pass_rate': 0,
|
||||
'dept_stats': [], 'recent': [],
|
||||
}
|
||||
|
||||
|
||||
@main_bp.route('/health')
|
||||
def health():
|
||||
"""Smoke-проверка приложения и подключений к БД (без авторизации)."""
|
||||
@@ -29,3 +128,29 @@ def health():
|
||||
@login_required
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@main_bp.route('/stats')
|
||||
@login_required
|
||||
def stats_page():
|
||||
return render_template('stats.html', stats=_get_stats())
|
||||
|
||||
|
||||
@main_bp.route('/assignments')
|
||||
@login_required
|
||||
def assignments_page():
|
||||
from ..db import get_session
|
||||
from ..models import Test, TestVersion
|
||||
from sqlalchemy.orm import selectinload
|
||||
session = get_session()
|
||||
tests = (
|
||||
session.query(Test)
|
||||
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
||||
.filter(Test.is_active.is_(True))
|
||||
.order_by(Test.title)
|
||||
.all()
|
||||
)
|
||||
return render_template('assignments.html', tests=[
|
||||
{'id': str(t.id), 'title': t.title or '(без названия)'}
|
||||
for t in tests
|
||||
])
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"""Страница настроек: статус LLM-ключа и проверка подключения (E1.8).
|
||||
|
||||
Ключ — общий, читается из ENV (`DEEPSEEK_API_KEY` / `OPENAI_API_KEY`). Здесь —
|
||||
только просмотр статуса и smoke-проверка. Изменение ключа — через `.env` и
|
||||
рестарт процесса.
|
||||
"""
|
||||
"""Настройки: LLM-статус, пинг и редактор промптов."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
from ..auth.decorators import login_required
|
||||
from ..services.llm_client import get_llm_config, ping_llm
|
||||
from ..services import prompt_store
|
||||
|
||||
settings_bp = Blueprint('settings', __name__)
|
||||
|
||||
@@ -27,7 +23,34 @@ def settings_page():
|
||||
)
|
||||
|
||||
|
||||
@settings_bp.route('/settings/prompts', methods=['GET'])
|
||||
@login_required
|
||||
def prompts_page():
|
||||
return render_template('settings_prompts.html', prompts=prompt_store.get_all())
|
||||
|
||||
|
||||
@settings_bp.route('/api/llm/ping', methods=['POST', 'GET'])
|
||||
@login_required
|
||||
def api_llm_ping():
|
||||
return jsonify(ping_llm())
|
||||
|
||||
|
||||
@settings_bp.route('/api/ai/prompts', methods=['GET'])
|
||||
@login_required
|
||||
def api_get_prompts():
|
||||
return jsonify(prompts=prompt_store.get_all())
|
||||
|
||||
|
||||
@settings_bp.route('/api/ai/prompts/<prompt_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def api_save_prompt(prompt_id: str):
|
||||
body = request.get_json(silent=True) or {}
|
||||
system = str(body.get('system') or '').strip()
|
||||
user = str(body.get('user') or '').strip()
|
||||
try:
|
||||
prompt_store.save_prompt(prompt_id, system, user)
|
||||
except KeyError as e:
|
||||
return jsonify(ok=False, error=str(e)), 404
|
||||
except Exception as e:
|
||||
return jsonify(ok=False, error=str(e)), 500
|
||||
return jsonify(ok=True)
|
||||
|
||||
@@ -37,3 +37,8 @@ def is_dev_ui() -> bool:
|
||||
if (os.environ.get('FLASK_ENV') or '').lower() == 'development':
|
||||
return True
|
||||
return (os.environ.get('FLASK_DEBUG') or '').strip() == '1'
|
||||
|
||||
|
||||
def get_dev_fio_password() -> str | None:
|
||||
"""Общий пароль для dev-входа по ФИО. Задаётся в DEV_FIO_PASSWORD."""
|
||||
return os.environ.get('DEV_FIO_PASSWORD') or None
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"generate_question_full": {
|
||||
"label": "Генерация вопроса (новый)",
|
||||
"description": "AI создаёт вопрос с вариантами ответа по теме теста.",
|
||||
"system": "Ты составитель тестов. Отвечай ТОЛЬКО JSON: {\"text\", \"hasMultipleAnswers\", \"options\": [{ \"text\", \"isCorrect\" }]}. Все на русском.",
|
||||
"user": "Тема теста: {topic}\n\nСформулируй ОДИН вопрос по этой теме с ровно {optionsCount} вариантами ответа. hasMultipleAnswers = {multiClause}",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: сделай акцент на противопожарных нормах",
|
||||
"vars": {
|
||||
"topic": "Название + описание теста",
|
||||
"optionsCount": "Количество вариантов ответа",
|
||||
"multiClause": "Пояснение о типе ответа"
|
||||
}
|
||||
},
|
||||
"rephrase_question": {
|
||||
"label": "Улучшение вопроса",
|
||||
"description": "AI переформулирует черновик вопроса, сохраняя смысл.",
|
||||
"system": "Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {\"text\": string} — чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости от сложности исходного черновика, без вариантов ответа.",
|
||||
"user": "Тема теста: {topic}\n\nИсходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n{questionText}",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: сделай формулировку короче и чётче",
|
||||
"vars": {
|
||||
"topic": "Название + описание теста",
|
||||
"questionText": "Исходный текст вопроса"
|
||||
}
|
||||
},
|
||||
"generate_distractors": {
|
||||
"label": "Генерация дистракторов",
|
||||
"description": "AI заполняет пустые варианты ответа правдоподобными, но неверными.",
|
||||
"system": "Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: {\"options\": [{\"text\": string, \"isCorrect\": false}, ...]} — ровно {emptyCount} объекта в массиве. Все тексты на русском, без нумерации, без кавычек.",
|
||||
"user": "Тема теста: {topic}\n\nВопрос: {questionText}\n\nУже заполненные варианты:\n{filledOptions}\n\nПридумай ровно {emptyCount} правдоподобных, но НЕВЕРНЫХ дистракторов (isCorrect: false), которые не повторяют уже существующие варианты и выглядят похоже на реальные ответы.",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: дистракторы должны быть из той же категории",
|
||||
"vars": {
|
||||
"topic": "Название + описание теста",
|
||||
"questionText": "Текст вопроса",
|
||||
"filledOptions": "Список уже заполненных вариантов",
|
||||
"emptyCount": "Количество пустых слотов"
|
||||
}
|
||||
},
|
||||
"generate_test_by_title": {
|
||||
"label": "Генерация теста по теме",
|
||||
"description": "AI генерирует весь тест (структуру + вопросы) по названию.",
|
||||
"system": "Ты опытный методист, составляешь учебные тесты. Отвечай ТОЛЬКО одним JSON-объектом на русском. Схема: {\"title\", \"description\", \"questions\": [{\"text\", \"hasMultipleAnswers\": boolean, \"options\": [{\"text\", \"isCorrect\"}]}]}. Минимум 2 варианта, отметь хотя бы один isCorrect: true.",
|
||||
"user": "Составь учебный тест по этой теме.\n\nНазвание теста: {title}\nОписание/контекст: {desc}\n\nПодсказка по сетке: примерно {nQ} вопросов, в каждом по {nOpt} вариантов ответа. Покрой ключевые подтемы. Дистракторы делай правдоподобными, не очевидно неверными. Текст — короткий, понятный.",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: сделай акцент на правилах безопасности, избегай теоретических вопросов",
|
||||
"vars": {
|
||||
"title": "Название теста",
|
||||
"desc": "Описание/контекст темы",
|
||||
"nQ": "Желаемое количество вопросов",
|
||||
"nOpt": "Желаемое количество вариантов"
|
||||
}
|
||||
},
|
||||
"generate_test_from_doc": {
|
||||
"label": "Генерация теста из документа",
|
||||
"description": "AI создаёт тест на основе загруженного текста документа.",
|
||||
"system": "Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом без пояснений. Схема: {\"title\": string, \"description\"?: string, \"questions\": array}. Каждый вопрос: {\"text\", \"hasMultipleAnswers\": boolean, \"options\": [{\"text\", \"isCorrect\": boolean}, ...]}. Минимум 2 варианта. Для одиночного выбора ровно один isCorrect: true. Текст и формулировки — на русском, по содержанию входного материала.",
|
||||
"user": "Составь тест с вопросами с одним или несколькими правильными ответами на основе текста:\n\n{documentText}",
|
||||
"user_hint_label": "На что сделать акцент",
|
||||
"user_hint_placeholder": "Например: сделай акцент на разделе 3, избегай вопросов про даты",
|
||||
"vars": {
|
||||
"documentText": "Извлечённый текст документа"
|
||||
}
|
||||
},
|
||||
"check_test_quality": {
|
||||
"label": "Проверка качества теста",
|
||||
"description": "AI анализирует тест и выдаёт рекомендации по улучшению.",
|
||||
"system": "Ты ревьюер учебных тестов. Отвечай ТОЛЬКО JSON: {\"verdict\": \"ok\"|\"warn\"|\"bad\", \"summary\": string, \"sections\": [{\"title\": string, \"items\": [string, ...]}]}. Разделы рекомендаций: «Чёткость формулировок», «Качество дистракторов», «Охват темы», «Сбалансированность сложности». Пропусти раздел, если претензий нет. Вердикт: ok — годен; warn — есть замечания; bad — серьёзные проблемы. Все тексты — на русском, короткие и предметные.",
|
||||
"user": "Проверь качество теста и дай рекомендации:\n\n{testDump}",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: обрати особое внимание на дистракторы",
|
||||
"vars": {
|
||||
"testDump": "JSON-дамп теста (заголовок + вопросы)"
|
||||
}
|
||||
},
|
||||
"improve_test_full": {
|
||||
"label": "Улучшение всего теста",
|
||||
"description": "AI предлагает улучшенные формулировки для всех вопросов и ответов.",
|
||||
"system": "Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. Отвечай ТОЛЬКО JSON: {\"questions\": [{\"text\", \"hasMultipleAnswers\", \"options\": [{\"text\", \"isCorrect\"}]}, ...]}. Тексты — на русском, короткие.",
|
||||
"user": "Улучши тест без изменения сетки:\n\n{testDump}",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: сделай дистракторы правдоподобнее",
|
||||
"vars": {
|
||||
"testDump": "JSON-дамп теста (заголовок + вопросы)"
|
||||
}
|
||||
},
|
||||
"question_hint": {
|
||||
"label": "Подсказка к вопросу",
|
||||
"description": "AI объясняет правильный ответ (показывается при прохождении теста).",
|
||||
"system": "Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения), без markdown и без вступлений. Объясни почему правильный вариант — правильный.",
|
||||
"user": "Вопрос: {questionText}\nВарианты: {allOptions}\nПравильный ответ: {correctOptions}\n\nДай краткое объяснение для подсказки во всплывающем окне.",
|
||||
"vars": {
|
||||
"questionText": "Текст вопроса",
|
||||
"allOptions": "Все варианты ответа",
|
||||
"correctOptions": "Правильные варианты ответа"
|
||||
}
|
||||
},
|
||||
"explain_answer": {
|
||||
"label": "Объяснение ответа",
|
||||
"description": "AI объясняет результат ответа пользователя (после прохождения).",
|
||||
"system": "Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения). Объясни почему правильный ответ именно такой, без лишней воды и без markdown.",
|
||||
"user": "Вопрос: {questionText}\nПравильный ответ: {correctOptions}\nОтвет ученика ({verdict}): {selectedOptions}\n\nДай краткое объяснение для подсказки во всплывающем окне.",
|
||||
"vars": {
|
||||
"questionText": "Текст вопроса",
|
||||
"correctOptions": "Правильные варианты",
|
||||
"verdict": "Результат (верно/неверно)",
|
||||
"selectedOptions": "Выбранные пользователем варианты"
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
-74
@@ -1,7 +1,7 @@
|
||||
"""Подключение к PostgreSQL — тот же паттерн, что в HR_TG_Bot/tgFlaskForm/db/session.py.
|
||||
"""Подключение к PostgreSQL и ORM-сессии.
|
||||
|
||||
В Этапе 1 работаем с БД `clinic_tests` (схема не меняется). Опционально доступна
|
||||
вторая БД `hr_bot_test` для HR-аутентификации (см. .env.example, флаг HR_AUTH).
|
||||
Основная БД — `clinic_tests`.
|
||||
Опциональная вторая БД — `hr_bot_test` (когда HR_AUTH=1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,76 +11,85 @@ from typing import Optional
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
_lock = threading.Lock()
|
||||
_engine: Optional[Engine] = None
|
||||
_session_local: Optional[sessionmaker] = None
|
||||
_hr_engine: Optional[Engine] = None
|
||||
_hr_session_local: Optional[sessionmaker] = None
|
||||
_engine_lock = threading.Lock()
|
||||
_session_lock = threading.Lock()
|
||||
_hr_engine_lock = threading.Lock()
|
||||
|
||||
_engine: Optional[Engine] = None
|
||||
_session_factory: Optional[scoped_session] = None
|
||||
_hr_engine: Optional[Engine] = None
|
||||
|
||||
|
||||
# ─── URL helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_database_url() -> str:
|
||||
"""URL основной БД (`clinic_tests`).
|
||||
|
||||
Приоритет: DATABASE_URL → отдельные DB_*-переменные.
|
||||
"""
|
||||
if db_url := os.environ.get('DATABASE_URL'):
|
||||
return db_url.strip()
|
||||
|
||||
db_host = os.environ.get('DB_HOST', 'localhost')
|
||||
db_port = os.environ.get('DB_PORT', '5432')
|
||||
db_name = os.environ.get('DB_NAME', 'clinic_tests')
|
||||
db_user = os.environ.get('DB_USER', 'hr_bot_user')
|
||||
db_password = os.environ.get('DB_PASSWORD', 'hrbot123')
|
||||
return f'postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}'
|
||||
|
||||
|
||||
def get_hr_database_url() -> Optional[str]:
|
||||
"""URL БД HR (`hr_bot_test`) — только если включён HR_AUTH."""
|
||||
if not _hr_auth_enabled():
|
||||
return None
|
||||
if url := os.environ.get('HR_DATABASE_URL'):
|
||||
return url.strip()
|
||||
return None
|
||||
host = os.environ.get('DB_HOST', 'localhost')
|
||||
port = os.environ.get('DB_PORT', '5432')
|
||||
name = os.environ.get('DB_NAME', 'clinic_tests')
|
||||
user = os.environ.get('DB_USER', 'hr_bot_user')
|
||||
password = os.environ.get('DB_PASSWORD', 'hrbot123')
|
||||
return f'postgresql+psycopg2://{user}:{password}@{host}:{port}/{name}'
|
||||
|
||||
|
||||
def _hr_auth_enabled() -> bool:
|
||||
val = (os.environ.get('HR_AUTH') or '').strip().lower()
|
||||
return val in ('1', 'true', 'yes', 'on')
|
||||
return (os.environ.get('HR_AUTH') or '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def get_hr_database_url() -> Optional[str]:
|
||||
if not _hr_auth_enabled():
|
||||
return None
|
||||
url = (os.environ.get('HR_DATABASE_URL') or '').strip()
|
||||
return url or None
|
||||
|
||||
|
||||
# ─── Main engine ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_engine() -> Engine:
|
||||
"""Возвращает общий engine основной БД (singleton на процесс)."""
|
||||
global _engine
|
||||
if _engine is not None:
|
||||
return _engine
|
||||
with _lock:
|
||||
if _engine is not None:
|
||||
return _engine
|
||||
_engine = create_engine(
|
||||
get_database_url(),
|
||||
poolclass=QueuePool,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
with _engine_lock:
|
||||
if _engine is None:
|
||||
_engine = create_engine(
|
||||
get_database_url(),
|
||||
poolclass=QueuePool,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session():
|
||||
"""Создаёт новую ORM-сессию поверх общего engine."""
|
||||
global _session_local
|
||||
if _session_local is None:
|
||||
with _lock:
|
||||
if _session_local is None:
|
||||
_session_local = sessionmaker(bind=get_engine())
|
||||
return _session_local()
|
||||
# ─── Scoped session ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_session() -> Session:
|
||||
"""Возвращает ORM-сессию для текущего потока (scoped_session)."""
|
||||
global _session_factory
|
||||
if _session_factory is None:
|
||||
with _session_lock:
|
||||
if _session_factory is None:
|
||||
# Инициализируем engine до захвата session_lock, чтобы не было вложенных блокировок
|
||||
engine = get_engine()
|
||||
_session_factory = scoped_session(
|
||||
sessionmaker(bind=engine, autoflush=True, autocommit=False)
|
||||
)
|
||||
return _session_factory # type: ignore[return-value]
|
||||
|
||||
|
||||
def remove_session() -> None:
|
||||
"""Освобождает сессию для текущего потока. Вызывается в teardown_appcontext."""
|
||||
if _session_factory is not None:
|
||||
_session_factory.remove()
|
||||
|
||||
|
||||
# ─── HR engine (raw SQL only) ────────────────────────────────────────────────
|
||||
|
||||
def get_hr_engine() -> Optional[Engine]:
|
||||
"""Engine для HR-БД. Возвращает None, если HR_AUTH не включён."""
|
||||
if not _hr_auth_enabled():
|
||||
return None
|
||||
global _hr_engine
|
||||
@@ -89,34 +98,21 @@ def get_hr_engine() -> Optional[Engine]:
|
||||
url = get_hr_database_url()
|
||||
if not url:
|
||||
return None
|
||||
with _lock:
|
||||
if _hr_engine is not None:
|
||||
return _hr_engine
|
||||
_hr_engine = create_engine(
|
||||
url,
|
||||
poolclass=QueuePool,
|
||||
pool_size=3,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
with _hr_engine_lock:
|
||||
if _hr_engine is None:
|
||||
_hr_engine = create_engine(
|
||||
url,
|
||||
poolclass=QueuePool,
|
||||
pool_size=3,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
return _hr_engine
|
||||
|
||||
|
||||
def get_hr_session():
|
||||
"""Сессия для HR-БД (или None при выключенном HR_AUTH)."""
|
||||
eng = get_hr_engine()
|
||||
if eng is None:
|
||||
return None
|
||||
global _hr_session_local
|
||||
if _hr_session_local is None:
|
||||
with _lock:
|
||||
if _hr_session_local is None:
|
||||
_hr_session_local = sessionmaker(bind=eng)
|
||||
return _hr_session_local()
|
||||
|
||||
# ─── Smoke check ─────────────────────────────────────────────────────────────
|
||||
|
||||
def ping() -> dict:
|
||||
"""Smoke-проверка подключения к БД (используется в /health)."""
|
||||
out: dict = {'main': 'unknown'}
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""SQLAlchemy ORM-модели для БД clinic_tests.
|
||||
|
||||
Таблицы: departments, users, tests, test_versions, questions, answer_options,
|
||||
test_assignments, test_assignment_targets, test_attempts, user_answers.
|
||||
|
||||
Enum-типы (`user_role`, `target_type`, `attempt_status`) соответствуют PostgreSQL-перечислениям
|
||||
из 001_initial.sql — создаются через `create_constraint=False` (тип уже есть в БД).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
# ─── Base ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
# ─── Enum types ───────────────────────────────────────────────────────────────
|
||||
|
||||
user_role_enum = Enum(
|
||||
"hr", "manager", "employee",
|
||||
name="user_role",
|
||||
create_constraint=False, # тип уже существует в БД
|
||||
)
|
||||
|
||||
target_type_enum = Enum(
|
||||
"department", "user",
|
||||
name="target_type",
|
||||
create_constraint=False,
|
||||
)
|
||||
|
||||
attempt_status_enum = Enum(
|
||||
"in_progress", "completed", "expired",
|
||||
name="attempt_status",
|
||||
create_constraint=False,
|
||||
)
|
||||
|
||||
|
||||
# ─── Models ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class Department(Base):
|
||||
__tablename__ = "departments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
users: Mapped[List["User"]] = relationship(back_populates="department")
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
Index("idx_users_login", "login"),
|
||||
Index("idx_users_department", "department_id"),
|
||||
Index(
|
||||
"idx_users_staff_id", "staff_id",
|
||||
postgresql_where="staff_id IS NOT NULL",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
login: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(user_role_enum, nullable=False, server_default="employee")
|
||||
department_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("departments.id"), nullable=True
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default="true")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
staff_id: Mapped[Optional[int]] = mapped_column(Integer, unique=True, nullable=True)
|
||||
|
||||
department: Mapped[Optional["Department"]] = relationship(back_populates="users")
|
||||
tests: Mapped[List["Test"]] = relationship(back_populates="author", foreign_keys="Test.created_by")
|
||||
attempts: Mapped[List["TestAttempt"]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class Test(Base):
|
||||
__tablename__ = "tests"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
passing_threshold: Mapped[Optional[int]] = mapped_column(Integer, server_default="70")
|
||||
time_limit: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
allow_back: Mapped[bool] = mapped_column(Boolean, server_default="true")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default="true")
|
||||
is_versioned: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||
hints_enabled: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||
result_mode: Mapped[str] = mapped_column(String(16), server_default="end")
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
author: Mapped[Optional["User"]] = relationship(
|
||||
back_populates="tests", foreign_keys=[created_by]
|
||||
)
|
||||
versions: Mapped[List["TestVersion"]] = relationship(
|
||||
back_populates="test", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class TestVersion(Base):
|
||||
__tablename__ = "test_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("test_id", "version", name="test_versions_test_id_version_key"),
|
||||
Index("idx_test_versions_parent_id", "parent_id"),
|
||||
Index(
|
||||
"uq_test_versions_one_active_per_test", "test_id",
|
||||
unique=True,
|
||||
postgresql_where="is_active = true",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
test_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tests.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("test_versions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
|
||||
test: Mapped["Test"] = relationship(back_populates="versions")
|
||||
questions: Mapped[List["Question"]] = relationship(
|
||||
back_populates="version", cascade="all, delete-orphan", order_by="Question.question_order"
|
||||
)
|
||||
attempts: Mapped[List["TestAttempt"]] = relationship(back_populates="test_version")
|
||||
assignments: Mapped[List["TestAssignment"]] = relationship(back_populates="test_version")
|
||||
parent: Mapped[Optional["TestVersion"]] = relationship(
|
||||
remote_side="TestVersion.id", foreign_keys=[parent_id]
|
||||
)
|
||||
|
||||
|
||||
class Question(Base):
|
||||
__tablename__ = "questions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
test_version_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("test_versions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
question_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
has_multiple_answers: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||
ai_hint: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
version: Mapped["TestVersion"] = relationship(back_populates="questions")
|
||||
options: Mapped[List["AnswerOption"]] = relationship(
|
||||
back_populates="question", cascade="all, delete-orphan", order_by="AnswerOption.option_order"
|
||||
)
|
||||
user_answers: Mapped[List["UserAnswer"]] = relationship(back_populates="question")
|
||||
|
||||
|
||||
class AnswerOption(Base):
|
||||
__tablename__ = "answer_options"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
question_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("questions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_correct: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||
option_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
question: Mapped["Question"] = relationship(back_populates="options")
|
||||
|
||||
|
||||
class TestAssignment(Base):
|
||||
__tablename__ = "test_assignments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
test_version_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("test_versions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
assigned_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
deadline: Mapped[Optional[date]] = mapped_column(Date)
|
||||
max_attempts: Mapped[Optional[int]] = mapped_column(Integer, server_default="1")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
|
||||
test_version: Mapped["TestVersion"] = relationship(back_populates="assignments")
|
||||
targets: Mapped[List["TestAssignmentTarget"]] = relationship(
|
||||
back_populates="assignment", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class TestAssignmentTarget(Base):
|
||||
__tablename__ = "test_assignment_targets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
assignment_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("test_assignments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
target_type: Mapped[str] = mapped_column(target_type_enum, nullable=False)
|
||||
target_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
|
||||
assignment: Mapped["TestAssignment"] = relationship(back_populates="targets")
|
||||
|
||||
|
||||
class TestAttempt(Base):
|
||||
__tablename__ = "test_attempts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"test_version_id", "user_id", "attempt_number",
|
||||
name="test_attempts_test_version_id_user_id_attempt_number_key",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
test_version_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("test_versions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
attempt_number: Mapped[int] = mapped_column(Integer, server_default="1")
|
||||
status: Mapped[str] = mapped_column(attempt_status_enum, server_default="in_progress")
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
correct_count: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
total_questions: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
passed: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
|
||||
test_version: Mapped["TestVersion"] = relationship(back_populates="attempts")
|
||||
user: Mapped["User"] = relationship(back_populates="attempts")
|
||||
user_answers: Mapped[List["UserAnswer"]] = relationship(
|
||||
back_populates="attempt", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class UserAnswer(Base):
|
||||
__tablename__ = "user_answers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
attempt_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("test_attempts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
question_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("questions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
selected_options: Mapped[Optional[List[uuid.UUID]]] = mapped_column(
|
||||
ARRAY(UUID(as_uuid=True)), server_default="{}"
|
||||
)
|
||||
|
||||
attempt: Mapped["TestAttempt"] = relationship(back_populates="user_answers")
|
||||
question: Mapped["Question"] = relationship(back_populates="user_answers")
|
||||
@@ -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}
|
||||
|
||||
@@ -264,6 +264,20 @@ body.ui-legacy .btn-ghost:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body.ui-legacy .btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
body.ui-legacy .btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
body.ui-legacy .btn-primary:active {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
body.ui-legacy .text-muted {
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 0.875rem;
|
||||
@@ -365,6 +379,52 @@ body.ui-legacy .callout--warning {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
body.ui-legacy .callout--error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Страница входа (legacy) */
|
||||
body.ui-legacy .login-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
body.ui-legacy .login-shell {
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
}
|
||||
body.ui-legacy .login-logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
body.ui-legacy .login-logo .font-headline {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
body.ui-legacy .login-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 38%, transparent);
|
||||
border-radius: 1rem;
|
||||
padding: 1.35rem 1.25rem 1.5rem;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
body.ui-legacy .form-field + .form-field {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
body.ui-legacy .login-card .btn-primary {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
min-height: 2.65rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body.ui-legacy .muted,
|
||||
body.ui-legacy .text-muted,
|
||||
body.ui-legacy .text-secondary {
|
||||
@@ -402,6 +462,16 @@ body.ui-legacy .form-input:focus {
|
||||
box-shadow: 0 0 0 3px rgba(0, 113, 104, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
body.ui-legacy select.form-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding-right: 2.5rem;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body.ui-legacy .surface-card {
|
||||
background: var(--surface);
|
||||
@@ -421,23 +491,53 @@ body.ui-legacy .cabinet-brick--hero {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-brick__nav {
|
||||
.hero-brick__meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-500, #6b7280);
|
||||
}
|
||||
.hero-brick__meta {
|
||||
.hero-brick__sep { opacity: 0.45; }
|
||||
|
||||
.hero-brick__divider {
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant, #e5e7eb) 60%, transparent);
|
||||
}
|
||||
|
||||
.hero-brick__tags {
|
||||
margin-top: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
.hero-brick__tag {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
color: var(--ink-500, #6b7280);
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-600, #4b5563);
|
||||
padding: 0.18rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--outline-variant, #e5e7eb) 35%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant, #e5e7eb) 65%, transparent);
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hero-brick__sep { opacity: 0.55; }
|
||||
.hero-brick__tag--toggle { cursor: pointer; }
|
||||
.hero-brick__tag--toggle:hover {
|
||||
background: color-mix(in srgb, var(--primary, #0d9488) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--primary, #0d9488) 40%, transparent);
|
||||
}
|
||||
.hero-brick__tag input[type="checkbox"] {
|
||||
accent-color: var(--primary, #0d9488);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* keep old chip classes for any stale references */
|
||||
.hero-brick__chips { display: none; }
|
||||
|
||||
.hero-brick__title {
|
||||
display: block;
|
||||
@@ -510,6 +610,41 @@ body.ui-legacy .cabinet-brick--hero {
|
||||
}
|
||||
.hero-brick__chip input[type="checkbox"] { accent-color: var(--primary, #0d9488); }
|
||||
|
||||
.q-item { transition: box-shadow .15s ease, transform .12s ease; }
|
||||
.q-item.q-dragging { opacity: 0.55; box-shadow: 0 6px 20px rgba(0,0,0,0.12); }
|
||||
.q-item.q-drop-before { box-shadow: 0 -2px 0 0 var(--primary, #0d9488) inset; }
|
||||
.q-item.q-drop-after { box-shadow: 0 2px 0 0 var(--primary, #0d9488) inset; }
|
||||
.q-drag { cursor: grab; color: var(--ink-500, #6b7280); }
|
||||
.q-drag:active { cursor: grabbing; }
|
||||
|
||||
.q-item.q-removed > *:not(.q-removed-banner) { opacity: 0.45; }
|
||||
.q-item.q-removed .q-text,
|
||||
.q-item.q-removed .q-multi,
|
||||
.q-item.q-removed .q-options,
|
||||
.q-item.q-removed .q-add-option,
|
||||
.q-item.q-removed .q-ai,
|
||||
.q-item.q-removed .q-up,
|
||||
.q-item.q-removed .q-down,
|
||||
.q-item.q-removed .q-delete,
|
||||
.q-item.q-removed .q-drag {
|
||||
pointer-events: none;
|
||||
}
|
||||
.q-item.q-removed .q-text { text-decoration: line-through; }
|
||||
.q-removed-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
padding: .35rem .6rem;
|
||||
background: color-mix(in srgb, #fff7ed 65%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #fbbf24 50%, transparent);
|
||||
border-radius: .5rem;
|
||||
color: #92400e;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.q-removed-banner .q-restore { pointer-events: auto; }
|
||||
|
||||
body.ui-legacy .cabinet-disclosure {
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
|
||||
border-radius: 1rem;
|
||||
@@ -567,10 +702,167 @@ body.ui-legacy .cabinet-disclosure__summary-sub {
|
||||
}
|
||||
|
||||
body.ui-legacy .cabinet-disclosure__body {
|
||||
padding: 0.7rem 1rem 1.05rem;
|
||||
padding: 1rem 1rem 1.25rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Question textarea + char counter ──────────────────────────── */
|
||||
.q-text {
|
||||
padding-bottom: 1.6rem; /* space for counter */
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.q-text-counter {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1;
|
||||
bottom: 6px !important;
|
||||
right: 10px !important;
|
||||
}
|
||||
|
||||
/* ─── Question editor blocks (AI panel sections) ─────────────────── */
|
||||
body.ui-legacy .question-editor-block {
|
||||
padding-top: 1rem;
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 28%, transparent);
|
||||
}
|
||||
body.ui-legacy .question-editor-block--first {
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
body.ui-legacy .test-detail-subsection__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.6rem;
|
||||
color: var(--ink-900, #111827);
|
||||
}
|
||||
body.ui-legacy .test-detail-ai-panel {
|
||||
padding: 1rem 1.1rem 1.1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* ─── Option row alignment ───────────────────────────────────────── */
|
||||
.question-option-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.question-option-row__mark-wrap {
|
||||
padding-top: 0.45rem; /* align checkbox with first line of textarea */
|
||||
}
|
||||
.opt-text {
|
||||
line-height: 1.55;
|
||||
}
|
||||
.opt-delete {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* ─── Question AI overlay ────────────────────────────────────────── */
|
||||
.q-ai-overlay {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.q-item[style*="pointer-events: none"] .q-text,
|
||||
.q-item[style*="pointer-events: none"] .opt-text {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ─── Drag-and-drop import dropzone ─────────────────────────────── */
|
||||
.import-dropzone {
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
}
|
||||
.import-dropzone--over {
|
||||
border-color: var(--brand-500, #6366f1) !important;
|
||||
background-color: color-mix(in srgb, var(--brand-100, #e0e7ff) 40%, transparent) !important;
|
||||
}
|
||||
.import-dropzone--loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.import-dropzone--loading .material-symbols-outlined {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.import-dropzone--done {
|
||||
border-style: solid;
|
||||
border-color: var(--primary, #007168) !important;
|
||||
background-color: color-mix(in srgb, var(--primary, #007168) 6%, transparent) !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ─── Import modal (compact, like save-modal) ───────────────────── */
|
||||
.save-modal {
|
||||
padding: 0;
|
||||
margin: 3rem auto auto;
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.18);
|
||||
max-width: 26rem;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
.save-modal::backdrop {
|
||||
background: rgba(0,0,0,.4);
|
||||
}
|
||||
.save-modal__inner {
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.25rem;
|
||||
border-bottom: 1px dashed color-mix(in srgb, var(--outline-variant) 30%, transparent);
|
||||
}
|
||||
.settings-row:last-child { border-bottom: none; }
|
||||
.settings-row--block {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.settings-row__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.92rem;
|
||||
color: var(--ink-700, #2c3a37);
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.settings-row__hint {
|
||||
font-size: 0.78rem;
|
||||
color: #6b7d79;
|
||||
font-weight: 400;
|
||||
}
|
||||
.settings-row__input {
|
||||
width: 6.5rem;
|
||||
text-align: right;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--ink-300, #c8d2cf);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: white;
|
||||
}
|
||||
.settings-row__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-500, #2bb39a);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand-500, #2bb39a) 18%, transparent);
|
||||
}
|
||||
.settings-radio {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.92rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.settings-radio input { margin-top: 0.2rem; }
|
||||
|
||||
body.ui-legacy .test-detail-subsection {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.15rem;
|
||||
@@ -584,8 +876,8 @@ body.ui-legacy .test-detail-subsection--tight {
|
||||
}
|
||||
|
||||
body.ui-legacy .test-detail-subsection__title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -597,8 +889,8 @@ body.ui-legacy .test-detail-hint {
|
||||
}
|
||||
|
||||
body.ui-legacy .test-detail-ai-panel {
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 1.15rem;
|
||||
padding: 1rem 1.1rem 1.1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
background: var(--surface-container-low);
|
||||
border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
|
||||
border-radius: 0.85rem;
|
||||
@@ -666,3 +958,69 @@ body.ui-legacy .attempts-card-list {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ─── Version items (compact row in top section) ─────────────────── */
|
||||
body.ui-legacy .version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.6rem;
|
||||
background: var(--surface-container-low, #f5f5f5);
|
||||
border: 1px solid var(--outline-variant, #e0e0e0);
|
||||
}
|
||||
body.ui-legacy .version-item[data-active="1"] {
|
||||
background: color-mix(in srgb, var(--primary, #007168) 8%, white);
|
||||
border-color: color-mix(in srgb, var(--primary, #007168) 25%, transparent);
|
||||
}
|
||||
body.ui-legacy .version-item__label {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
body.ui-legacy .version-item__badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--primary, #007168);
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
body.ui-legacy .version-item__date {
|
||||
font-size: 0.78rem;
|
||||
flex: 1;
|
||||
}
|
||||
body.ui-legacy .version-item__spacer {
|
||||
width: 1px;
|
||||
}
|
||||
body.ui-legacy #versions-section {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
body.ui-legacy .attempts-card-list__item {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
body.ui-legacy .attempts-card-list__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
body.ui-legacy .attempts-card-list__main {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
body.ui-legacy .attempts-card-list__main p {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
body.ui-legacy .attempts-card-list__main p + p {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
body.ui-legacy .attempts-card-list__action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
const $ = (sel, parent = document) => parent.querySelector(sel);
|
||||
const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel));
|
||||
|
||||
const MAX_OPTIONS = 8;
|
||||
|
||||
const titleEl = $('#test-title');
|
||||
const descEl = $('#test-description');
|
||||
const thresholdEl = $('#test-threshold');
|
||||
@@ -24,7 +26,8 @@
|
||||
const qCountEl = $('#q-count');
|
||||
const saveStatusEl = $('#save-status');
|
||||
const aiStatusEl = $('#ai-status');
|
||||
const chainActiveEl = $('#chain-active');
|
||||
const chainActiveEl = { checked: true, _val: true }; // display-only — реальный toggle в блоке «Показ в каталоге»
|
||||
const chainActiveDisplay = $('#chain-active-display');
|
||||
const aiTopicEl = $('#ai-topic');
|
||||
const aiQCountEl = $('#ai-q-count');
|
||||
const aiOCountEl = $('#ai-o-count');
|
||||
@@ -100,30 +103,78 @@
|
||||
$('.q-multi', node).checked = !!q.hasMultipleAnswers;
|
||||
|
||||
const optsEl = $('.q-options', node);
|
||||
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o, node)));
|
||||
|
||||
bindQuestionEvents(node);
|
||||
syncOptionInputTypes(node);
|
||||
updateOptionsCounter(node);
|
||||
updateAiButtonLabel(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function renderOption(o) {
|
||||
function renderOption(o, qNode) {
|
||||
const node = tplO.content.firstElementChild.cloneNode(true);
|
||||
$('.opt-text', node).value = o.text || '';
|
||||
const textEl = $('.opt-text', node);
|
||||
textEl.value = o.text || '';
|
||||
$('.opt-correct', node).checked = !!o.isCorrect;
|
||||
if (textEl && textEl.tagName === 'TEXTAREA') {
|
||||
const resize = () => autoResize(textEl);
|
||||
textEl.addEventListener('input', resize);
|
||||
requestAnimationFrame(resize);
|
||||
}
|
||||
$('.opt-delete', node).addEventListener('click', () => {
|
||||
node.remove();
|
||||
if (qNode) updateOptionsCounter(qNode);
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
function bindQuestionEvents(node) {
|
||||
$('.q-delete', node).addEventListener('click', () => {
|
||||
if (!confirm('Удалить вопрос?')) return;
|
||||
node.remove();
|
||||
let dragSrc = null;
|
||||
function bindDragEvents(node) {
|
||||
const handle = $('.q-drag', node);
|
||||
if (handle) {
|
||||
handle.addEventListener('mousedown', () => { node.draggable = true; });
|
||||
handle.addEventListener('mouseup', () => { node.draggable = true; });
|
||||
}
|
||||
node.addEventListener('dragstart', (e) => {
|
||||
dragSrc = node;
|
||||
node.classList.add('q-dragging');
|
||||
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); } catch {}
|
||||
});
|
||||
node.addEventListener('dragend', () => {
|
||||
node.classList.remove('q-dragging');
|
||||
$$('#questions .q-item').forEach((li) => li.classList.remove('q-drop-before', 'q-drop-after'));
|
||||
dragSrc = null;
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
node.addEventListener('dragover', (e) => {
|
||||
if (!dragSrc || dragSrc === node) return;
|
||||
e.preventDefault();
|
||||
const rect = node.getBoundingClientRect();
|
||||
const before = (e.clientY - rect.top) < rect.height / 2;
|
||||
node.classList.toggle('q-drop-before', before);
|
||||
node.classList.toggle('q-drop-after', !before);
|
||||
});
|
||||
node.addEventListener('dragleave', () => {
|
||||
node.classList.remove('q-drop-before', 'q-drop-after');
|
||||
});
|
||||
node.addEventListener('drop', (e) => {
|
||||
if (!dragSrc || dragSrc === node) return;
|
||||
e.preventDefault();
|
||||
const rect = node.getBoundingClientRect();
|
||||
const before = (e.clientY - rect.top) < rect.height / 2;
|
||||
node.classList.remove('q-drop-before', 'q-drop-after');
|
||||
node.parentNode.insertBefore(dragSrc, before ? node : node.nextSibling);
|
||||
});
|
||||
}
|
||||
|
||||
function bindQuestionEvents(node) {
|
||||
bindDragEvents(node);
|
||||
$('.q-delete', node).addEventListener('click', () => {
|
||||
markQuestionRemoved(node);
|
||||
});
|
||||
$('.q-up', node).addEventListener('click', () => {
|
||||
if (node.previousElementSibling) {
|
||||
node.parentNode.insertBefore(node, node.previousElementSibling);
|
||||
@@ -138,26 +189,122 @@
|
||||
scheduleDirtyCheck();
|
||||
}
|
||||
});
|
||||
$('.q-add-option', node).addEventListener('click', () => {
|
||||
$('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false }));
|
||||
const addOptBtn = $('.q-add-option', node);
|
||||
addOptBtn.addEventListener('click', () => {
|
||||
const optsEl = $('.q-options', node);
|
||||
const count = $$('.opt-item', node).length;
|
||||
if (count >= MAX_OPTIONS) return;
|
||||
optsEl.appendChild(renderOption({ text: '', isCorrect: false }, node));
|
||||
syncOptionInputTypes(node);
|
||||
updateOptionsCounter(node);
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
|
||||
// Кнопка очистки вопроса
|
||||
const clearBtn = $('.q-clear', node);
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
const qTextEl = $('.q-text', node);
|
||||
qTextEl.value = '';
|
||||
autoResize(qTextEl);
|
||||
$$('.opt-text', node).forEach((t) => { t.value = ''; autoResize(t); });
|
||||
$$('.opt-correct', node).forEach((c) => { c.checked = false; });
|
||||
updateAiButtonLabel(node);
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
}
|
||||
|
||||
// Умная кнопка AI — label зависит от наличия текста
|
||||
const qTextEl2 = $('.q-text', node);
|
||||
if (qTextEl2) {
|
||||
qTextEl2.addEventListener('input', () => updateAiButtonLabel(node));
|
||||
}
|
||||
|
||||
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
|
||||
$('.q-multi', node).addEventListener('change', () => {
|
||||
syncOptionInputTypes(node);
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
|
||||
// Счётчик символов у textarea вопроса
|
||||
const qTextEl = $('.q-text', node);
|
||||
const qCounter = $('.q-text-counter', node);
|
||||
if (qTextEl && qCounter) {
|
||||
const updateCounter = () => {
|
||||
const len = qTextEl.value.length;
|
||||
const max = parseInt(qTextEl.getAttribute('maxlength') || '500', 10);
|
||||
qCounter.textContent = len > 200 ? `${len}/${max}` : '';
|
||||
qCounter.style.color = len > 450 ? '#ef4444' : len > 350 ? '#f59e0b' : '';
|
||||
autoResize(qTextEl);
|
||||
};
|
||||
qTextEl.addEventListener('input', () => { updateCounter(); scheduleDirtyCheck(); });
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAiButtonLabel(node) {
|
||||
const qText = $('.q-text', node);
|
||||
const label = $('.q-ai-label', node);
|
||||
if (!qText || !label) return;
|
||||
const hasText = qText.value.trim().length > 0;
|
||||
label.textContent = hasText ? 'Улучшить' : 'Сгенерировать';
|
||||
}
|
||||
|
||||
function updateOptionsCounter(node) {
|
||||
const count = $$('.opt-item', node).length;
|
||||
const countEl = $('.q-options-count', node);
|
||||
const addBtn = $('.q-add-option', node);
|
||||
const labelEl = $('.q-add-option-label', node);
|
||||
if (countEl) countEl.textContent = count > 0 ? `${count}/${MAX_OPTIONS}` : '';
|
||||
if (addBtn) {
|
||||
const atMax = count >= MAX_OPTIONS;
|
||||
addBtn.disabled = atMax;
|
||||
addBtn.style.opacity = atMax ? '0.4' : '';
|
||||
if (labelEl) labelEl.textContent = atMax ? 'Лимит вариантов' : 'Добавить вариант';
|
||||
}
|
||||
}
|
||||
|
||||
function renumber() {
|
||||
$$('#questions .q-item').forEach((li, i) => {
|
||||
$('.q-num', li).textContent = `Вопрос #${i + 1}`;
|
||||
let i = 0;
|
||||
$$('#questions .q-item').forEach((li) => {
|
||||
const removed = li.classList.contains('q-removed');
|
||||
if (removed) {
|
||||
$$('.q-num', li).forEach((el) => { el.textContent = 'Удалён'; });
|
||||
return;
|
||||
}
|
||||
i += 1;
|
||||
$$('.q-num', li).forEach((el) => { el.textContent = `Вопрос #${i}`; });
|
||||
});
|
||||
const n = $$('#questions .q-item').length;
|
||||
if (qCountEl) qCountEl.textContent = n;
|
||||
if (qCountEl) qCountEl.textContent = i;
|
||||
const mirror = document.getElementById('q-count-mirror');
|
||||
if (mirror) mirror.textContent = n;
|
||||
if (mirror) mirror.textContent = i;
|
||||
}
|
||||
|
||||
function markQuestionRemoved(node) {
|
||||
if (node.classList.contains('q-removed')) return;
|
||||
node.classList.add('q-removed');
|
||||
node.draggable = false;
|
||||
let banner = $('.q-removed-banner', node);
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.className = 'q-removed-banner';
|
||||
banner.innerHTML =
|
||||
'<span>Вопрос будет удалён при сохранении</span>' +
|
||||
'<button type="button" class="q-restore btn btn-ghost btn--sm">Отменить</button>';
|
||||
node.prepend(banner);
|
||||
$('.q-restore', banner).addEventListener('click', () => restoreQuestion(node));
|
||||
}
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
}
|
||||
|
||||
function restoreQuestion(node) {
|
||||
node.classList.remove('q-removed');
|
||||
node.draggable = true;
|
||||
const banner = $('.q-removed-banner', node);
|
||||
if (banner) banner.remove();
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
}
|
||||
|
||||
function autoResize(el) {
|
||||
@@ -166,20 +313,71 @@
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
function syncThresholdMirror() {
|
||||
const m = document.getElementById('threshold-mirror');
|
||||
if (!m) return;
|
||||
const v = (thresholdEl && thresholdEl.value !== '') ? thresholdEl.value : '—';
|
||||
m.textContent = v;
|
||||
}
|
||||
|
||||
function loadInitial() {
|
||||
titleEl.value = initial.test.title || '';
|
||||
descEl.value = initial.test.description || '';
|
||||
autoResize(titleEl);
|
||||
autoResize(descEl);
|
||||
if (thresholdEl) {
|
||||
thresholdEl.addEventListener('input', syncThresholdMirror);
|
||||
thresholdEl.addEventListener('change', syncThresholdMirror);
|
||||
}
|
||||
if (titleEl && titleEl.tagName === 'TEXTAREA') {
|
||||
titleEl.addEventListener('input', () => autoResize(titleEl));
|
||||
titleEl.addEventListener('input', () => {
|
||||
autoResize(titleEl);
|
||||
// Синхронизируем поле темы, только если оно не было изменено вручную
|
||||
if (aiTopicEl && aiTopicEl.dataset.userEdited !== '1') {
|
||||
aiTopicEl.value = titleEl.value;
|
||||
}
|
||||
});
|
||||
titleEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') e.preventDefault();
|
||||
});
|
||||
}
|
||||
if (aiTopicEl) {
|
||||
aiTopicEl.addEventListener('input', () => {
|
||||
aiTopicEl.dataset.userEdited = '1';
|
||||
autoResize(aiTopicEl);
|
||||
});
|
||||
}
|
||||
if (descEl) descEl.addEventListener('input', () => autoResize(descEl));
|
||||
if (docUserHint) docUserHint.addEventListener('input', () => autoResize(docUserHint));
|
||||
thresholdEl.value =
|
||||
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
|
||||
syncThresholdMirror();
|
||||
|
||||
const timeLimitEl = document.getElementById('test-time-limit');
|
||||
const hintsEl = document.getElementById('test-hints-enabled');
|
||||
const hintsRow = document.getElementById('test-hints-row');
|
||||
const resultModeRadios = document.querySelectorAll('input[name="result-mode"]');
|
||||
|
||||
if (timeLimitEl) {
|
||||
timeLimitEl.value = initial.test.timeLimit == null ? '' : Number(initial.test.timeLimit);
|
||||
timeLimitEl.addEventListener('input', scheduleDirtyCheck);
|
||||
}
|
||||
const initMode = (initial.test.resultMode === 'immediate') ? 'immediate' : 'end';
|
||||
resultModeRadios.forEach((r) => {
|
||||
r.checked = (r.value === initMode);
|
||||
r.addEventListener('change', () => {
|
||||
const mode = document.querySelector('input[name="result-mode"]:checked');
|
||||
const isImmediate = mode && mode.value === 'immediate';
|
||||
if (hintsRow) hintsRow.style.display = isImmediate ? '' : 'none';
|
||||
if (hintsEl && !isImmediate) hintsEl.checked = false;
|
||||
scheduleDirtyCheck();
|
||||
});
|
||||
});
|
||||
if (hintsEl) {
|
||||
hintsEl.checked = !!initial.test.hintsEnabled;
|
||||
hintsEl.addEventListener('change', scheduleDirtyCheck);
|
||||
}
|
||||
if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : 'none';
|
||||
|
||||
questionsEl.innerHTML = '';
|
||||
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
@@ -187,6 +385,7 @@
|
||||
if (aiTopicEl && !aiTopicEl.value.trim()) {
|
||||
aiTopicEl.value = initial.test.title || '';
|
||||
}
|
||||
if (aiTopicEl) requestAnimationFrame(() => autoResize(aiTopicEl));
|
||||
}
|
||||
|
||||
function fmtDt(iso) {
|
||||
@@ -207,7 +406,7 @@
|
||||
// ─── collect ───────────────────────────────────────────────────────
|
||||
|
||||
function collectPayload() {
|
||||
const questions = $$('#questions .q-item').map((li, i) => ({
|
||||
const questions = $$('#questions .q-item:not(.q-removed)').map((li, i) => ({
|
||||
text: $('.q-text', li).value.trim(),
|
||||
question_order: i + 1,
|
||||
hasMultipleAnswers: $('.q-multi', li).checked,
|
||||
@@ -224,6 +423,17 @@
|
||||
};
|
||||
const t = thresholdEl.value;
|
||||
if (t !== '' && Number.isFinite(Number(t))) payload.passingThreshold = Number(t);
|
||||
|
||||
const timeLimitEl = document.getElementById('test-time-limit');
|
||||
if (timeLimitEl) {
|
||||
const tl = timeLimitEl.value;
|
||||
payload.timeLimit = (tl === '' ? null : Math.max(0, Number(tl) || 0));
|
||||
}
|
||||
const modeEl = document.querySelector('input[name="result-mode"]:checked');
|
||||
payload.resultMode = (modeEl && modeEl.value === 'immediate') ? 'immediate' : 'end';
|
||||
const hintsEl = document.getElementById('test-hints-enabled');
|
||||
payload.hintsEnabled = !!(hintsEl && hintsEl.checked && payload.resultMode === 'immediate');
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -271,11 +481,48 @@
|
||||
});
|
||||
if (r2.ok) chainActive = chainActiveEl.checked;
|
||||
}
|
||||
saveStatusEl.textContent = data.forked
|
||||
? 'Сохранено (создана новая версия — есть попытки прохождения).'
|
||||
: 'Сохранено.';
|
||||
resetBaselineDraft();
|
||||
setTimeout(() => (saveStatusEl.textContent = ''), 4000);
|
||||
const msg = data.forked
|
||||
? 'Сохранено. Создана новая версия — у теста есть попытки прохождения.'
|
||||
: 'Изменения сохранены.';
|
||||
const saveModal = document.getElementById('save-modal');
|
||||
const saveMsg = document.getElementById('save-modal-msg');
|
||||
|
||||
const hintsEl = document.getElementById('test-hints-enabled');
|
||||
const modeEl = document.querySelector('input[name="result-mode"]:checked');
|
||||
const wantsHints = !!(hintsEl && hintsEl.checked) && modeEl && modeEl.value === 'immediate';
|
||||
if (wantsHints) {
|
||||
try {
|
||||
const sr = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`);
|
||||
const st = await sr.json().catch(() => ({}));
|
||||
if (sr.ok && Number(st.missing) > 0) {
|
||||
saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`;
|
||||
const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
|
||||
const gd = await gr.json().catch(() => ({}));
|
||||
if (!gr.ok) {
|
||||
saveStatusEl.textContent = '';
|
||||
alert(gd.error || 'Не удалось сгенерировать подсказки.');
|
||||
if (saveMsg) saveMsg.textContent = msg + ' (часть подсказок не создана)';
|
||||
if (saveModal) saveModal.showModal();
|
||||
return;
|
||||
}
|
||||
const tail = gd.failed
|
||||
? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось.`
|
||||
: ` Подсказки созданы (${gd.generated}).`;
|
||||
if (saveMsg) saveMsg.textContent = msg + tail;
|
||||
} else {
|
||||
if (saveMsg) saveMsg.textContent = msg;
|
||||
}
|
||||
} catch (err) {
|
||||
if (saveMsg) saveMsg.textContent = msg + ' (ИИ-подсказки не созданы)';
|
||||
}
|
||||
} else {
|
||||
if (saveMsg) saveMsg.textContent = msg;
|
||||
}
|
||||
saveStatusEl.textContent = '';
|
||||
if (saveModal) {
|
||||
saveModal.showModal();
|
||||
}
|
||||
} catch (e) {
|
||||
saveStatusEl.textContent = '';
|
||||
alert(e.message || 'Не удалось сохранить.');
|
||||
@@ -288,6 +535,16 @@
|
||||
alert('Укажите тему.');
|
||||
return;
|
||||
}
|
||||
// Предупреждение, если в тесте уже есть вопросы или заполненное название/описание
|
||||
const hasContent = questionsEl.children.length > 0
|
||||
|| titleEl.value.trim()
|
||||
|| descEl.value.trim();
|
||||
if (hasContent) {
|
||||
const ok = confirm(
|
||||
'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?'
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
|
||||
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
|
||||
const shape = Array.from({ length: nQ }, () => ({
|
||||
@@ -325,48 +582,225 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ─── импорт документа (E1.3) ───────────────────────────────────
|
||||
// ─── импорт документа с drag-and-drop (E1.3) ──────────────────
|
||||
|
||||
$('#ai-import-file').addEventListener('change', async (ev) => {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
const importDropzone = $('#ai-import-dropzone');
|
||||
const importDropzoneLabel = $('#ai-import-dropzone-label');
|
||||
const docUserHint = $('#doc-user-hint');
|
||||
const docGenerateBtn = $('#doc-generate-btn');
|
||||
const importModal = $('#import-modal');
|
||||
const importModalTitle = $('#import-modal-title');
|
||||
const importModalBody = $('#import-modal-body');
|
||||
const importModalActions = $('#import-modal-actions');
|
||||
|
||||
let _extractedText = '';
|
||||
let _extractedFileName = '';
|
||||
|
||||
function openImportModal(title, bodyHtml, actions) {
|
||||
importModalTitle.textContent = title;
|
||||
importModalBody.innerHTML = bodyHtml;
|
||||
importModalActions.innerHTML = '';
|
||||
actions.forEach(({ label, onClick, primary }) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = label;
|
||||
btn.className = primary
|
||||
? 'px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium'
|
||||
: 'px-4 py-2 rounded-lg bg-white border border-ink-300 hover:bg-ink-50 text-ink-700 text-sm';
|
||||
btn.addEventListener('click', onClick);
|
||||
importModalActions.appendChild(btn);
|
||||
});
|
||||
importModal.showModal();
|
||||
}
|
||||
|
||||
// Фаза 1: выбрать файл → извлечь текст, обновить метку дропзоны
|
||||
async function handleImportFile(file) {
|
||||
if (!file) return;
|
||||
aiStatusEl.textContent = `Загружаем «${file.name}»…`;
|
||||
importDropzone.classList.add('import-dropzone--loading');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd });
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось импортировать.');
|
||||
const g = data.generation || {};
|
||||
if (!g.available) {
|
||||
aiStatusEl.textContent = '';
|
||||
const msg = g.message || 'AI недоступен.';
|
||||
const preview = (g.textPreview || data.extractedText || '').slice(0, 600);
|
||||
alert(msg + (preview ? '\n\nПервые 600 символов:\n' + preview : ''));
|
||||
return;
|
||||
}
|
||||
const ok = confirm(
|
||||
`${g.message}\n\nПрименить как новый черновик?\n` +
|
||||
`Текущие вопросы будут заменены.`,
|
||||
);
|
||||
if (!ok) {
|
||||
aiStatusEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
const draft = g.draft;
|
||||
if (draft.title) titleEl.value = draft.title;
|
||||
if (draft.description) descEl.value = draft.description;
|
||||
questionsEl.innerHTML = '';
|
||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.');
|
||||
_extractedText = data.extractedText || '';
|
||||
_extractedFileName = file.name;
|
||||
aiStatusEl.textContent = `Файл загружен: «${file.name}» · ${data.textLength ?? 0} символов`;
|
||||
if (importDropzoneLabel) importDropzoneLabel.textContent = `✓ ${file.name}`;
|
||||
importDropzone.classList.add('import-dropzone--done');
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
alert(e.message || 'Не удалось импортировать.');
|
||||
openImportModal(
|
||||
'Ошибка загрузки',
|
||||
`<p class="text-red-700">${escHtml(e.message || 'Не удалось загрузить файл.')}</p>`,
|
||||
[{ label: 'Закрыть', onClick: () => importModal.close() }],
|
||||
);
|
||||
} finally {
|
||||
importDropzone.classList.remove('import-dropzone--loading');
|
||||
}
|
||||
}
|
||||
|
||||
// Фаза 2: сгенерировать тест из извлечённого текста + подсказки
|
||||
async function handleGenerateFromDoc() {
|
||||
if (!_extractedText) return;
|
||||
const userHint = docUserHint ? docUserHint.value.trim() : '';
|
||||
docGenerateBtn.disabled = true;
|
||||
docGenerateBtn.textContent = 'Генерируем…';
|
||||
aiStatusEl.textContent = 'Генерируем тест из документа…';
|
||||
try {
|
||||
const r = await fetch('/api/tests/generate-from-extracted', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ extractedText: _extractedText, userHint }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Ошибка генерации.');
|
||||
const g = data.generation || {};
|
||||
aiStatusEl.textContent = '';
|
||||
|
||||
if (!g.available) {
|
||||
openImportModal(
|
||||
'AI недоступен',
|
||||
`<p class="mb-2 text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2 text-xs">
|
||||
${escHtml(g.message || 'AI недоступен — ключ не настроен.')}
|
||||
</p>`,
|
||||
[{ label: 'Закрыть', onClick: () => importModal.close() }],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = g.draft || {};
|
||||
const qs = draft.questions || [];
|
||||
const qPreview = qs.slice(0, 4).map((q, i) =>
|
||||
`<li class="text-xs text-ink-600">${i + 1}. ${escHtml((q.text || '').slice(0, 80))}${(q.text || '').length > 80 ? '…' : ''}</li>`
|
||||
).join('');
|
||||
const moreCount = qs.length > 4 ? qs.length - 4 : 0;
|
||||
const bodyHtml = `
|
||||
${draft.title ? `<p class="font-medium text-ink-800 mb-1">${escHtml(draft.title)}</p>` : ''}
|
||||
${draft.description ? `<p class="text-xs text-ink-500 mb-2">${escHtml(draft.description)}</p>` : ''}
|
||||
<p class="text-xs text-ink-500 mb-1">Вопросов: <b>${qs.length}</b></p>
|
||||
${qs.length ? `<ul class="space-y-0.5 mb-1">${qPreview}</ul>
|
||||
${moreCount ? `<p class="text-xs text-ink-400">…и ещё ${moreCount}</p>` : ''}` : ''}
|
||||
<p class="mt-3 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
|
||||
Текущие вопросы теста будут <b>заменены</b>.
|
||||
</p>`;
|
||||
|
||||
openImportModal(
|
||||
`Черновик из «${escHtml(_extractedFileName)}»`,
|
||||
bodyHtml,
|
||||
[
|
||||
{
|
||||
label: 'Применить',
|
||||
primary: true,
|
||||
onClick: () => {
|
||||
importModal.close();
|
||||
if (draft.title) { titleEl.value = draft.title; autoResize(titleEl); }
|
||||
if (draft.description) { descEl.value = draft.description; autoResize(descEl); }
|
||||
questionsEl.innerHTML = '';
|
||||
qs.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = `Применено: ${qs.length} вопросов.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
// Сброс зоны загрузки
|
||||
_extractedText = '';
|
||||
_extractedFileName = '';
|
||||
if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файл сюда или нажмите';
|
||||
importDropzone.classList.remove('import-dropzone--done');
|
||||
if (docUserHint) docUserHint.value = '';
|
||||
aiStatusEl.textContent = '';
|
||||
},
|
||||
},
|
||||
{ label: 'Отмена', onClick: () => importModal.close() },
|
||||
],
|
||||
);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
openImportModal(
|
||||
'Ошибка генерации',
|
||||
`<p class="text-red-700">${escHtml(e.message || 'Не удалось сгенерировать тест.')}</p>`,
|
||||
[{ label: 'Закрыть', onClick: () => importModal.close() }],
|
||||
);
|
||||
} finally {
|
||||
if (docGenerateBtn) {
|
||||
docGenerateBtn.disabled = false;
|
||||
docGenerateBtn.innerHTML = '<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span> Сгенерировать из документа';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (docGenerateBtn) docGenerateBtn.addEventListener('click', () => {
|
||||
if (!_extractedText) {
|
||||
// Файл ещё не выбран — открываем picker, генерация запустится после загрузки
|
||||
const fileInput = $('#ai-import-file');
|
||||
if (fileInput) {
|
||||
const onchange = async (ev) => {
|
||||
fileInput.removeEventListener('change', onchange);
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
await handleImportFile(f);
|
||||
if (_extractedText) handleGenerateFromDoc();
|
||||
};
|
||||
fileInput.addEventListener('change', onchange);
|
||||
fileInput.click();
|
||||
}
|
||||
} else {
|
||||
handleGenerateFromDoc();
|
||||
}
|
||||
});
|
||||
|
||||
$('#ai-import-file').addEventListener('change', (ev) => {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
handleImportFile(file);
|
||||
});
|
||||
|
||||
// Drag-and-drop на зону загрузки
|
||||
if (importDropzone) {
|
||||
importDropzone.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
importDropzone.classList.add('import-dropzone--over');
|
||||
});
|
||||
importDropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
importDropzone.classList.add('import-dropzone--over');
|
||||
});
|
||||
importDropzone.addEventListener('dragleave', (e) => {
|
||||
if (!importDropzone.contains(e.relatedTarget)) {
|
||||
importDropzone.classList.remove('import-dropzone--over');
|
||||
}
|
||||
});
|
||||
importDropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
importDropzone.classList.remove('import-dropzone--over');
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
const allowed = ['.pdf', '.docx', '.txt', '.md'];
|
||||
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
|
||||
if (!allowed.includes(ext)) {
|
||||
aiStatusEl.textContent = `Формат «${ext}» не поддерживается.`;
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 3000);
|
||||
return;
|
||||
}
|
||||
handleImportFile(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-and-drop на всю страницу (когда перетаскивают извне браузера)
|
||||
document.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||
document.addEventListener('drop', (e) => {
|
||||
if (importDropzone && importDropzone.contains(e.target)) return; // уже обработано
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
const allowed = ['.pdf', '.docx', '.txt', '.md'];
|
||||
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
|
||||
if (!allowed.includes(ext)) return;
|
||||
// Подсвечиваем зону и обрабатываем
|
||||
importDropzone?.classList.add('import-dropzone--over');
|
||||
setTimeout(() => importDropzone?.classList.remove('import-dropzone--over'), 600);
|
||||
handleImportFile(file);
|
||||
});
|
||||
|
||||
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
|
||||
@@ -388,6 +822,34 @@
|
||||
const modalActions = $('#ai-modal-actions');
|
||||
$('#ai-modal-close').addEventListener('click', () => modal.close());
|
||||
|
||||
const saveModalEl = document.getElementById('save-modal');
|
||||
const saveStayBtn = document.getElementById('save-modal-stay');
|
||||
const saveGoBtn = document.getElementById('save-modal-go');
|
||||
if (saveStayBtn) saveStayBtn.addEventListener('click', () => saveModalEl.close());
|
||||
if (saveGoBtn) saveGoBtn.addEventListener('click', () => { window.location.href = '/tests'; });
|
||||
|
||||
function doCancel() {
|
||||
if (isDirty()) {
|
||||
if (!confirm('Есть несохранённые изменения. Уйти без сохранения?')) return;
|
||||
}
|
||||
window.location.href = '/tests';
|
||||
}
|
||||
|
||||
const cancelBtn = document.getElementById('btn-cancel');
|
||||
if (cancelBtn) cancelBtn.addEventListener('click', doCancel);
|
||||
|
||||
const cancelBtnInline = document.getElementById('btn-cancel-inline');
|
||||
if (cancelBtnInline) cancelBtnInline.addEventListener('click', doCancel);
|
||||
|
||||
// Кнопка «Сохранить» под вопросами — дублирует основную
|
||||
const saveDraftInlineBtn = document.getElementById('save-draft-inline');
|
||||
const saveStatusInlineEl = document.getElementById('save-status-inline');
|
||||
if (saveDraftInlineBtn) {
|
||||
saveDraftInlineBtn.addEventListener('click', () => {
|
||||
document.getElementById('save-draft')?.click();
|
||||
});
|
||||
}
|
||||
|
||||
function openModal(title, bodyHtml, actions) {
|
||||
modalTitle.textContent = title;
|
||||
modalBody.innerHTML = bodyHtml;
|
||||
@@ -610,11 +1072,39 @@
|
||||
});
|
||||
|
||||
async function aiGenerateQuestion(node) {
|
||||
const qText = $('.q-text', node).value.trim();
|
||||
const optsCount = Math.max(2, $$('.opt-item', node).length || 4);
|
||||
const qTextEl = $('.q-text', node);
|
||||
const qText = qTextEl.value.trim();
|
||||
const existingOpts = $$('.opt-item', node);
|
||||
const optsCount = Math.max(2, existingOpts.length || 4);
|
||||
const multi = $('.q-multi', node).checked;
|
||||
aiStatusEl.textContent = 'AI: один вопрос…';
|
||||
const overlay = $('.q-ai-overlay', node);
|
||||
|
||||
// Показываем оверлей
|
||||
overlay?.classList.remove('hidden');
|
||||
node.style.pointerEvents = 'none';
|
||||
|
||||
try {
|
||||
// Собираем варианты с их состоянием
|
||||
const existingOptions = existingOpts.map((op) => ({
|
||||
text: $('.opt-text', op).value.trim(),
|
||||
isCorrect: $('.opt-correct', op).checked,
|
||||
}));
|
||||
const emptySlots = existingOptions.filter((o) => !o.text).length;
|
||||
const filledSlots = existingOptions.filter((o) => o.text).length;
|
||||
|
||||
// Выбираем режим:
|
||||
// - нет текста вопроса → full
|
||||
// - есть вопрос + есть пустые варианты (и хоть один заполнен) → distractors
|
||||
// - есть вопрос, все варианты заполнены или вариантов нет → rephrase
|
||||
let requestMode;
|
||||
if (!qText) {
|
||||
requestMode = 'full';
|
||||
} else if (emptySlots > 0 && filledSlots > 0) {
|
||||
requestMode = 'distractors';
|
||||
} else {
|
||||
requestMode = 'rephrase';
|
||||
}
|
||||
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -624,86 +1114,154 @@
|
||||
questionText: qText,
|
||||
optionsCount: optsCount,
|
||||
hasMultipleAnswers: multi,
|
||||
mode: requestMode,
|
||||
existingOptions: qText ? existingOptions : undefined,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||
$('.q-text', node).value = data.text || '';
|
||||
if (data.mode === 'full' && Array.isArray(data.options)) {
|
||||
const optsEl = $('.q-options', node);
|
||||
optsEl.innerHTML = '';
|
||||
data.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
||||
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
|
||||
|
||||
// Обновляем текст вопроса (кроме режима дистракторов — текст не меняем)
|
||||
if (data.mode !== 'distractors') {
|
||||
qTextEl.value = data.text || qText;
|
||||
autoResize(qTextEl);
|
||||
}
|
||||
|
||||
const optsEl = $('.q-options', node);
|
||||
if (data.mode === 'full' && Array.isArray(data.options) && data.options.length) {
|
||||
// Полная замена вариантов
|
||||
optsEl.innerHTML = '';
|
||||
data.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
|
||||
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
|
||||
} else if (data.mode === 'distractors' && Array.isArray(data.options) && data.options.length) {
|
||||
// Заполняем только пустые слоты
|
||||
let dIdx = 0;
|
||||
existingOpts.forEach((op) => {
|
||||
const t = $('.opt-text', op);
|
||||
if (!t.value.trim() && dIdx < data.options.length) {
|
||||
t.value = data.options[dIdx].text || '';
|
||||
autoResize(t);
|
||||
dIdx++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
syncOptionInputTypes(node);
|
||||
updateOptionsCounter(node);
|
||||
updateAiButtonLabel(node);
|
||||
scheduleDirtyCheck();
|
||||
aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.';
|
||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||
} catch (e) {
|
||||
aiStatusEl.textContent = '';
|
||||
alert(e.message || 'AI: ошибка.');
|
||||
} finally {
|
||||
overlay?.classList.add('hidden');
|
||||
node.style.pointerEvents = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
|
||||
|
||||
function updateChainActiveDisplay(active) {
|
||||
chainActive = !!active;
|
||||
chainActiveEl.checked = chainActive;
|
||||
if (chainActiveDisplay) {
|
||||
chainActiveDisplay.textContent = chainActive ? '✓ Активна в каталоге' : 'Скрыта из каталога';
|
||||
chainActiveDisplay.style.color = chainActive ? '' : 'var(--ink-500, #6b7280)';
|
||||
}
|
||||
}
|
||||
|
||||
fetch(`/api/tests/${TEST_ID}/summary`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data && data.test && typeof data.test.chainActive === 'boolean') {
|
||||
chainActive = data.test.chainActive;
|
||||
chainActiveEl.checked = chainActive;
|
||||
updateChainActiveDisplay(data.test.chainActive);
|
||||
} else {
|
||||
chainActiveEl.checked = true;
|
||||
chainActive = true;
|
||||
updateChainActiveDisplay(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
chainActiveEl.checked = true;
|
||||
});
|
||||
.catch(() => { updateChainActiveDisplay(true); });
|
||||
|
||||
function renderVersions(rows) {
|
||||
if (!versionsListEl) return;
|
||||
versionsListEl.innerHTML = '';
|
||||
if (!(rows || []).length) {
|
||||
versionsListEl.innerHTML = '<li class="muted" style="font-size:.85rem;">Нет версий.</li>';
|
||||
return;
|
||||
}
|
||||
(rows || []).forEach((r) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'surface-card version-card-list__item';
|
||||
li.className = 'version-item';
|
||||
li.dataset.versionId = r.id;
|
||||
li.dataset.active = r.is_active ? '1' : '0';
|
||||
li.innerHTML = `
|
||||
<div class="version-card-list__row">
|
||||
<div class="version-card-list__main">
|
||||
<div class="version-card-list__title-line">
|
||||
<span class="font-headline" style="font-size:1rem;">v${r.version}</span>
|
||||
${r.is_active ? '<span class="code-inline" style="font-size:0.7rem;">текущая</span>' : ''}
|
||||
</div>
|
||||
<p class="muted mono" style="margin:.4rem 0 0; font-size:.8rem;">${fmtDt(r.created_at)}</p>
|
||||
<p class="muted" style="margin:.2rem 0 0; font-size:.8rem;">Активна: ${r.is_active ? 'да' : 'нет'}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
<span class="version-item__label">
|
||||
Версия ${r.version}
|
||||
${r.is_active ? '<span class="version-item__badge">активная</span>' : ''}
|
||||
</span>
|
||||
<span class="version-item__date muted">${fmtDt(r.created_at)}</span>
|
||||
${!r.is_active
|
||||
? `<button class="btn btn-ghost btn--sm version-item__activate" type="button"
|
||||
data-version-id="${escHtml(r.id)}">Сделать активной</button>`
|
||||
: '<span class="version-item__spacer"></span>'}`;
|
||||
versionsListEl.appendChild(li);
|
||||
});
|
||||
versionsListEl.querySelectorAll('.version-item__activate').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const vid = btn.dataset.versionId;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}/versions/${vid}/activate`, { method: 'POST' });
|
||||
if (!r.ok) throw new Error('Не удалось активировать');
|
||||
// обновить список
|
||||
const v = await fetch(`/api/tests/${TEST_ID}/versions`).then((x) => x.json()).catch(() => null);
|
||||
if (v && Array.isArray(v.versions)) renderVersions(v.versions);
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Сделать активной';
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderAttempts(rows) {
|
||||
if (!attemptsListEl) return;
|
||||
attemptsListEl.innerHTML = '';
|
||||
if (!(rows || []).length) {
|
||||
attemptsListEl.innerHTML = '<li class="muted" style="padding:.5rem 0; font-size:.85rem;">Прохождений ещё нет.</li>';
|
||||
return;
|
||||
}
|
||||
const statusLabel = {
|
||||
completed: null, // handled by score
|
||||
in_progress: 'Идёт',
|
||||
expired: 'Истекло',
|
||||
};
|
||||
(rows || []).forEach((a) => {
|
||||
const when = a.completedAt ? fmtDt(a.completedAt) : (a.startedAt ? fmtDt(a.startedAt) : '—');
|
||||
const result = a.status === 'completed' && a.totalQuestions != null
|
||||
? `${a.correctCount}/${a.totalQuestions}${a.passed ? ' · зачёт' : ' · незачёт'}`
|
||||
: a.status;
|
||||
let result;
|
||||
if (a.status === 'completed' && a.totalQuestions != null) {
|
||||
const verdict = a.passed ? '✓ Сдано' : '✗ Не сдано';
|
||||
const score = `${a.correctCount} из ${a.totalQuestions}`;
|
||||
result = `${verdict} · ${score}`;
|
||||
} else {
|
||||
result = statusLabel[a.status] || a.status;
|
||||
}
|
||||
const passedCls = a.status === 'completed'
|
||||
? (a.passed ? 'color:#166534;' : 'color:#991b1b;')
|
||||
: 'color:#6b7280;';
|
||||
const li = document.createElement('li');
|
||||
li.className = 'surface-card attempts-card-list__item';
|
||||
li.innerHTML = `
|
||||
<div class="attempts-card-list__row">
|
||||
<div class="attempts-card-list__main">
|
||||
<p class="muted mono" style="margin:0; font-size:.8rem;">${when}</p>
|
||||
<p style="margin:.35rem 0 0; font-weight:600;">${escHtml(a.attempterName || '—')}
|
||||
${a.attempterLogin ? `<span class="code-inline" style="font-size:11px; margin-left:6px;">${escHtml(a.attempterLogin)}</span>` : ''}
|
||||
</p>
|
||||
<p class="muted" style="margin:.25rem 0 0; font-size:.85rem;">v${a.testVersion} · ${escHtml(result)}</p>
|
||||
<p class="muted" style="margin:0; font-size:.8rem;">${when}</p>
|
||||
<p style="margin:.3rem 0 0; font-weight:600;">${escHtml(a.attempterName || a.attempterLogin || '—')}</p>
|
||||
<p style="margin:.2rem 0 0; font-size:.85rem; ${passedCls}">${escHtml(result)}</p>
|
||||
</div>
|
||||
${a.status === 'completed'
|
||||
? `<a class="btn btn-ghost btn--sm attempts-card-list__action" href="/tests/${TEST_ID}/attempts/${a.id}/review">Разбор</a>`
|
||||
: ''}
|
||||
: `<span class="muted" style="font-size:.8rem;">${statusLabel[a.status] || ''}</span>`}
|
||||
</div>`;
|
||||
attemptsListEl.appendChild(li);
|
||||
});
|
||||
@@ -720,7 +1278,7 @@
|
||||
<span class="assign-row__text">
|
||||
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span>
|
||||
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''}
|
||||
<span class="assign-row__meta">${escHtml(p.department || '—')}${p.clinicUserId ? ' · есть учётка' : ' · нет учётки (создадим при назначении)'}</span>
|
||||
<span class="assign-row__meta">${escHtml(p.department || '—')}</span>
|
||||
</span>`;
|
||||
const cb = row.querySelector('input');
|
||||
cb.addEventListener('change', () => {
|
||||
@@ -798,7 +1356,7 @@
|
||||
|
||||
if (visibilityBtn) {
|
||||
visibilityBtn.addEventListener('click', async () => {
|
||||
const next = !chainActiveEl.checked;
|
||||
const next = !chainActive;
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${TEST_ID}`, {
|
||||
method: 'PATCH',
|
||||
@@ -807,8 +1365,7 @@
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Ошибка изменения видимости');
|
||||
chainActiveEl.checked = !!next;
|
||||
chainActive = !!next;
|
||||
updateChainActiveDisplay(next);
|
||||
visibilityBtn.textContent = chainActive ? 'Скрыть из списка' : 'Снова показать в списке';
|
||||
} catch (e) {
|
||||
alert(e.message || 'Ошибка изменения видимости');
|
||||
@@ -816,6 +1373,35 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Создать шаблон ────────────────────────────────────────────
|
||||
const createTemplateBtn = $('#create-template');
|
||||
if (createTemplateBtn) {
|
||||
createTemplateBtn.addEventListener('click', () => {
|
||||
const qCount = Math.min(30, Math.max(1, parseInt($('#ai-q-count').value || '7', 10)));
|
||||
const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt($('#ai-o-count').value || '3', 10)));
|
||||
const existing = $$('#questions .q-item').length;
|
||||
if (existing > 0) {
|
||||
const ok = confirm(
|
||||
`Создать шаблон: ${qCount} вопросов × ${oCount} вариантов?\n` +
|
||||
'Текущие вопросы будут заменены.'
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
questionsEl.innerHTML = '';
|
||||
for (let qi = 0; qi < qCount; qi++) {
|
||||
const opts = [];
|
||||
for (let oi = 0; oi < oCount; oi++) {
|
||||
opts.push({ text: '', isCorrect: oi === 0 });
|
||||
}
|
||||
questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: false, options: opts }));
|
||||
}
|
||||
renumber();
|
||||
scheduleDirtyCheck();
|
||||
// Прокручиваем к первому вопросу
|
||||
questionsEl.firstElementChild?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
|
||||
fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null),
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Назначения — Тестирование персонала{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-4 sm:space-y-5 pb-10">
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-semibold text-ink-900">Назначения</h1>
|
||||
<a href="{{ url_for('main.index') }}" class="link-back text-sm">← Главная</a>
|
||||
</div>
|
||||
|
||||
{# Выбор теста #}
|
||||
<div class="cabinet-brick">
|
||||
<h2 class="font-semibold text-sm text-ink-700 mb-3">Тест</h2>
|
||||
<select id="assign-test-select" class="form-input">
|
||||
<option value="">— Выберите тест —</option>
|
||||
{% for t in tests %}
|
||||
<option value="{{ t.id }}">{{ t.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Панель назначения (скрыта пока не выбран тест) #}
|
||||
<div id="assign-panel" class="cabinet-brick hidden">
|
||||
<h2 class="font-semibold text-sm text-ink-700 mb-3">Кому выдать</h2>
|
||||
<p class="test-detail-hint">Список с учётом поиска и фильтров; можно отметить всех на экране.</p>
|
||||
<div class="assign-toolbar mt-3">
|
||||
<input id="assign-search" class="form-input assign-toolbar__search" type="text" placeholder="Поиск: ФИО, логин" />
|
||||
<select id="assign-dept" class="form-input"><option value="__all__">Все отделы</option></select>
|
||||
<select id="assign-clinic" class="form-input">
|
||||
<option value="all">Все сотрудники</option>
|
||||
<option value="with">Уже есть в системе</option>
|
||||
<option value="without">Ещё не в системе</option>
|
||||
</select>
|
||||
<button id="assign-select-all" class="btn btn-ghost btn--sm" type="button">Выбрать всех</button>
|
||||
</div>
|
||||
<div id="assign-list" class="assign-list mt-3"></div>
|
||||
<div class="inline-actions mt-3">
|
||||
<button id="assign-submit" class="btn btn-primary" type="button">Назначить выбранным</button>
|
||||
<span id="assign-status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const testSelect = $('#assign-test-select');
|
||||
const assignPanel = $('#assign-panel');
|
||||
const assignSearchEl = $('#assign-search');
|
||||
const assignDeptEl = $('#assign-dept');
|
||||
const assignClinicEl = $('#assign-clinic');
|
||||
const assignListEl = $('#assign-list');
|
||||
const assignSelectAllBtn = $('#assign-select-all');
|
||||
const assignSubmitBtn = $('#assign-submit');
|
||||
const assignStatusEl = $('#assign-status');
|
||||
|
||||
let currentTestId = null;
|
||||
let assignPeople = [];
|
||||
let assignSelected = new Set();
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function renderAssignList() {
|
||||
assignListEl.innerHTML = '';
|
||||
assignPeople.forEach((p) => {
|
||||
const row = document.createElement('label');
|
||||
row.className = `assign-row${assignSelected.has(String(p.staffId)) ? ' assign-row--selected' : ''}`;
|
||||
row.innerHTML = `
|
||||
<input type="checkbox" ${assignSelected.has(String(p.staffId)) ? 'checked' : ''} />
|
||||
<span class="assign-row__text">
|
||||
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span>
|
||||
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''}
|
||||
<span class="assign-row__meta">${escHtml(p.department || '—')}</span>
|
||||
</span>`;
|
||||
const cb = row.querySelector('input');
|
||||
cb.addEventListener('change', () => {
|
||||
const k = String(p.staffId);
|
||||
if (cb.checked) assignSelected.add(k); else assignSelected.delete(k);
|
||||
row.classList.toggle('assign-row--selected', cb.checked);
|
||||
});
|
||||
assignListEl.appendChild(row);
|
||||
});
|
||||
if (!assignPeople.length) assignListEl.innerHTML = '<p class="muted" style="padding:.75rem;">Никого не найдено.</p>';
|
||||
}
|
||||
|
||||
async function loadDirectory() {
|
||||
if (!currentTestId) return;
|
||||
assignStatusEl.textContent = 'Загружаем…';
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (assignSearchEl.value.trim()) params.set('q', assignSearchEl.value.trim());
|
||||
if (assignDeptEl.value && assignDeptEl.value !== '__all__') params.set('department', assignDeptEl.value);
|
||||
params.set('clinic', assignClinicEl.value || 'all');
|
||||
const r = await fetch(`/api/auth/dev/assignment-directory?${params.toString()}`);
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить сотрудников');
|
||||
assignPeople = data.people || [];
|
||||
const depts = data.departments || [];
|
||||
if (assignDeptEl.options.length <= 1) {
|
||||
depts.forEach((d) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = d; o.textContent = d;
|
||||
assignDeptEl.appendChild(o);
|
||||
});
|
||||
}
|
||||
assignSelected = new Set();
|
||||
renderAssignList();
|
||||
assignStatusEl.textContent = '';
|
||||
} catch (e) {
|
||||
assignStatusEl.textContent = e.message || 'Ошибка загрузки';
|
||||
}
|
||||
}
|
||||
|
||||
testSelect.addEventListener('change', () => {
|
||||
currentTestId = testSelect.value || null;
|
||||
if (currentTestId) {
|
||||
assignPanel.classList.remove('hidden');
|
||||
// Сброс фильтров
|
||||
assignDeptEl.innerHTML = '<option value="__all__">Все отделы</option>';
|
||||
assignSearchEl.value = '';
|
||||
loadDirectory();
|
||||
} else {
|
||||
assignPanel.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
let t = null;
|
||||
assignSearchEl.addEventListener('input', () => { clearTimeout(t); t = setTimeout(loadDirectory, 350); });
|
||||
assignDeptEl.addEventListener('change', loadDirectory);
|
||||
assignClinicEl.addEventListener('change', loadDirectory);
|
||||
|
||||
assignSelectAllBtn.addEventListener('click', () => {
|
||||
assignPeople.forEach((p) => assignSelected.add(String(p.staffId)));
|
||||
renderAssignList();
|
||||
});
|
||||
|
||||
assignSubmitBtn.addEventListener('click', async () => {
|
||||
if (!currentTestId) return;
|
||||
const selectedRows = assignPeople.filter((p) => assignSelected.has(String(p.staffId)));
|
||||
if (!selectedRows.length) { assignStatusEl.textContent = 'Никто не выбран'; return; }
|
||||
assignStatusEl.textContent = 'Назначаем…';
|
||||
try {
|
||||
const payload = selectedRows.map((p) => ({
|
||||
staffId: p.staffId,
|
||||
webLogin: p.webLogin || null,
|
||||
fio: p.fio || null,
|
||||
department: p.department || null,
|
||||
}));
|
||||
const r = await fetch(`/api/tests/${currentTestId}/assign`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ targets: payload }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Ошибка назначения');
|
||||
assignStatusEl.textContent = `Назначено: ${data.count ?? selectedRows.length}`;
|
||||
} catch (e) {
|
||||
assignStatusEl.textContent = e.message || 'Ошибка назначения';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -22,13 +22,21 @@
|
||||
{% endwith %}
|
||||
|
||||
<div class="login-card">
|
||||
{% if dev_fio_enabled %}
|
||||
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
|
||||
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
|
||||
</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('auth.login_submit') }}" novalidate>
|
||||
<input type="hidden" name="next" value="{{ next or '/' }}">
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="login-username">Логин</label>
|
||||
<label class="form-label" for="login-username">
|
||||
{% if dev_fio_enabled %}ФИО или логин{% else %}Логин{% endif %}
|
||||
</label>
|
||||
<input id="login-username" class="form-input" type="text" name="login"
|
||||
value="{{ login or '' }}" required autofocus autocomplete="username" />
|
||||
value="{{ login or '' }}" required autofocus autocomplete="username"
|
||||
placeholder="{% if dev_fio_enabled %}Иванов Иван Иванович{% endif %}" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
@@ -50,9 +58,12 @@
|
||||
<h1 class="text-xl font-semibold">Вход в систему</h1>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-ink-500">
|
||||
Используйте логин и пароль.
|
||||
{% if hr_auth_enabled %}
|
||||
{% if dev_fio_enabled %}
|
||||
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
|
||||
{% elif hr_auth_enabled %}
|
||||
Учётка кадровой системы (HR).
|
||||
{% else %}
|
||||
Используйте логин и пароль.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
@@ -74,8 +85,11 @@
|
||||
<input type="hidden" name="next" value="{{ next or '/' }}">
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-ink-700">Логин</span>
|
||||
<span class="text-sm font-medium text-ink-700">
|
||||
{% if dev_fio_enabled %}ФИО или логин{% else %}Логин{% endif %}
|
||||
</span>
|
||||
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
|
||||
placeholder="{% if dev_fio_enabled %}Иванов Иван Иванович{% endif %}"
|
||||
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||
</label>
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
text-ink-700 hover:bg-ink-100"
|
||||
title="Настройки" aria-label="Настройки">
|
||||
<span class="material-symbols-outlined text-base">settings</span>
|
||||
<span class="hidden sm:inline">Настройки</span>
|
||||
</a>
|
||||
<span class="hidden md:inline text-ink-500">
|
||||
{{ current_user.full_name or current_user.login }}
|
||||
|
||||
@@ -1,45 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Тестирование — главная{% endblock %}
|
||||
{% block title %}Главная — Тестирование персонала{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6">
|
||||
<h1 class="text-2xl font-semibold text-ink-900">Сервис тестирования персонала</h1>
|
||||
<p class="mt-2 text-ink-500">
|
||||
Этап 1 миграции: переход на единый стек (Flask + Jinja). Бизнес-функции
|
||||
переносятся последовательно — авторизация, каталог тестов, редактор,
|
||||
назначения, прохождение, импорт/AI.
|
||||
</p>
|
||||
<h1 class="text-2xl font-semibold text-ink-900 mb-5">Тестирование персонала</h1>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-2 text-sm">
|
||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white transition">
|
||||
<span class="material-symbols-outlined text-base">list_alt</span>
|
||||
Каталог тестов
|
||||
</a>
|
||||
<a href="{{ url_for('main.health') }}"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-50 text-brand-700 hover:bg-brand-100 transition">
|
||||
<span class="material-symbols-outlined text-base">monitoring</span>
|
||||
Health-check
|
||||
</a>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
||||
{# Статистика #}
|
||||
<a href="{{ url_for('main.stats_page') }}"
|
||||
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
|
||||
hover:shadow-md hover:border-brand-300 transition-all group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">bar_chart</span>
|
||||
<h2 class="font-semibold text-ink-900">Статистика</h2>
|
||||
</div>
|
||||
</section>
|
||||
<p class="text-sm text-ink-500 flex-1">Прохождения по отделам, общая динамика и последняя активность.</p>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
|
||||
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mt-4">
|
||||
{% for title, descr, icon in [
|
||||
('Авторизация', 'E1.1 — логин по HR/локальному пользователю.', 'login'),
|
||||
('Тесты', 'E1.2 — каталог, фильтры, карточки.', 'list_alt'),
|
||||
('Редактор', 'E1.3 — создание/правка теста, AI-помощник.', 'edit_note'),
|
||||
('Назначения', 'E1.4 — назначить сотрудникам, отслеживать.', 'assignment'),
|
||||
('Прохождение', 'E1.5 — UI прохождения теста сотрудником.', 'fact_check'),
|
||||
('Импорт/AI', 'E1.6 — генерация черновиков из документов.', 'auto_awesome'),
|
||||
] %}
|
||||
<article class="rounded-xl bg-white border border-ink-300/60 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-600">{{ icon }}</span>
|
||||
<h3 class="font-semibold">{{ title }}</h3>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-ink-500">{{ descr }}</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{# Тесты #}
|
||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
||||
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
|
||||
hover:shadow-md hover:border-brand-300 transition-all group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">list_alt</span>
|
||||
<h2 class="font-semibold text-ink-900">Тесты</h2>
|
||||
</div>
|
||||
<p class="text-sm text-ink-500 flex-1">Каталог тестов, создание, редактирование и прохождение.</p>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
|
||||
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{# Назначения #}
|
||||
<a href="{{ url_for('main.assignments_page') }}"
|
||||
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
|
||||
hover:shadow-md hover:border-brand-300 transition-all group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">assignment_ind</span>
|
||||
<h2 class="font-semibold text-ink-900">Назначения</h2>
|
||||
</div>
|
||||
<p class="text-sm text-ink-500 flex-1">Выдача тестов сотрудникам и отделам.</p>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
|
||||
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{# Настройки ИИ #}
|
||||
<a href="{{ url_for('settings.prompts_page') }}"
|
||||
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
|
||||
hover:shadow-md hover:border-brand-300 transition-all group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">psychology</span>
|
||||
<h2 class="font-semibold text-ink-900">Настройки ИИ</h2>
|
||||
</div>
|
||||
<p class="text-sm text-ink-500 flex-1">Редактор промптов — генерация вопросов, дистракторы, улучшение теста.</p>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
|
||||
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Настройки ИИ — промпты{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.pe-wrap {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.625rem;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pe-wrap:focus-within {
|
||||
border-color: #00645b;
|
||||
box-shadow: 0 0 0 3px rgba(0,100,91,0.12);
|
||||
}
|
||||
.pe-field {
|
||||
min-height: 72px;
|
||||
padding: 10px 12px;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
}
|
||||
.pe-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 9px 1px 7px;
|
||||
height: 20px;
|
||||
border-radius: 99px;
|
||||
background: #d9efec;
|
||||
color: #00574f;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #9bd7d0;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s, opacity 0.12s;
|
||||
}
|
||||
.pe-chip:hover { background: #bfe8e3; }
|
||||
.pe-chip.is-dragging { opacity: 0.3; cursor: grabbing; }
|
||||
.pe-caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.1em;
|
||||
background: #00645b;
|
||||
border-radius: 1px;
|
||||
vertical-align: middle;
|
||||
pointer-events: none;
|
||||
animation: pe-blink 0.7s steps(1) infinite;
|
||||
}
|
||||
@keyframes pe-blink { 50% { opacity: 0; } }
|
||||
.pc {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pc-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 13px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
.pc.open .pc-head { border-bottom-color: #e5e7eb; }
|
||||
.pc-chevron { font-size: 18px; color: #6b7280; transition: transform 0.2s; }
|
||||
.pc.open .pc-chevron { transform: rotate(90deg); }
|
||||
.pc-body { display: none; padding: 16px; gap: 14px; flex-direction: column; }
|
||||
.pc.open .pc-body { display: flex; }
|
||||
.pe-label {
|
||||
display: block;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .06em;
|
||||
text-transform: uppercase; color: #9ca3af;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.pe-palette {
|
||||
display: flex; flex-wrap: wrap; gap: 5px;
|
||||
padding: 7px 10px;
|
||||
background: #f3f8f9;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-radius: 0 0 0.625rem 0.625rem;
|
||||
}
|
||||
.pe-palette-chip {
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 99px;
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
font-size: 12px;
|
||||
border: 1px dashed #6ee7b7;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.pe-palette-chip:hover { background: #d1fae5; }
|
||||
.pc-badge { font-size: 11px; padding: 2px 8px; border-radius: 99px; }
|
||||
.pc-badge--ok { background: #dcfce7; color: #166534; }
|
||||
.pc-badge--err { background: #fee2e2; color: #991b1b; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Inject prompt data safely as JSON into JS scope #}
|
||||
<script>
|
||||
const PROMPTS_DATA = {{ prompts | tojson }};
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between mb-5 gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">psychology</span>
|
||||
<h1 class="text-2xl font-semibold text-ink-900">Настройки ИИ</h1>
|
||||
</div>
|
||||
<a href="{{ url_for('main.index') }}"
|
||||
class="inline-flex items-center gap-1 text-sm text-ink-500 hover:text-ink-800 transition">
|
||||
<span class="material-symbols-outlined text-base">arrow_back</span>
|
||||
Главная
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-ink-500 mb-5">
|
||||
Переменные отображаются как
|
||||
<span class="pe-chip" style="cursor:default; pointer-events:none;">Название теста</span>
|
||||
— перетащите их в нужное место или нажмите «+ переменная» внизу редактора для вставки.
|
||||
</p>
|
||||
|
||||
<div id="pc-list" class="flex flex-col gap-3">
|
||||
{% for pid, p in prompts.items() %}
|
||||
<div class="pc" data-pid="{{ pid }}">
|
||||
<div class="pc-head" onclick="this.closest('.pc').classList.toggle('open')">
|
||||
<span class="material-symbols-outlined pc-chevron">chevron_right</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-ink-900">{{ p.label }}</div>
|
||||
{% if p.description %}
|
||||
<div class="text-xs text-ink-400 mt-0.5">{{ p.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="pc-save-status pc-badge"></span>
|
||||
</div>
|
||||
|
||||
<div class="pc-body">
|
||||
{# System #}
|
||||
<div>
|
||||
<span class="pe-label">System</span>
|
||||
<div class="pe-wrap">
|
||||
<div class="pe-field" contenteditable="true" spellcheck="false"
|
||||
data-pid="{{ pid }}" data-field="system"></div>
|
||||
<div class="pe-palette" data-pid="{{ pid }}" data-field="system"></div>
|
||||
</div>
|
||||
</div>
|
||||
{# User #}
|
||||
<div>
|
||||
<span class="pe-label">User</span>
|
||||
<div class="pe-wrap">
|
||||
<div class="pe-field" contenteditable="true" spellcheck="false"
|
||||
data-pid="{{ pid }}" data-field="user"></div>
|
||||
<div class="pe-palette" data-pid="{{ pid }}" data-field="user"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition"
|
||||
onclick="savePrompt(this.closest('.pc'))">
|
||||
<span class="material-symbols-outlined text-base">save</span>Сохранить
|
||||
</button>
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-white border border-ink-300 hover:bg-ink-100 text-ink-700 text-sm transition"
|
||||
onclick="resetPrompt(this.closest('.pc'))">
|
||||
<span class="material-symbols-outlined text-base">restart_alt</span>Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
// ── DnD state ─────────────────────────────────────────────────────────
|
||||
let _drag = null;
|
||||
let _caret = null;
|
||||
|
||||
// ── Chip ──────────────────────────────────────────────────────────────
|
||||
function makeChip(varName, label) {
|
||||
const s = document.createElement('span');
|
||||
s.className = 'pe-chip';
|
||||
s.setAttribute('contenteditable', 'false');
|
||||
s.setAttribute('draggable', 'true');
|
||||
s.dataset.var = varName;
|
||||
s.textContent = label;
|
||||
bindChipDnD(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
function bindChipDnD(chip) {
|
||||
chip.addEventListener('dragstart', e => {
|
||||
_drag = chip;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', '');
|
||||
requestAnimationFrame(() => chip.classList.add('is-dragging'));
|
||||
});
|
||||
chip.addEventListener('dragend', () => {
|
||||
chip.classList.remove('is-dragging');
|
||||
removeCaret();
|
||||
_drag = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render / serialize ────────────────────────────────────────────────
|
||||
function renderText(el, text, vars) {
|
||||
el.innerHTML = '';
|
||||
const parts = text.split(/(\{[a-zA-Z_]+\})/g);
|
||||
parts.forEach(part => {
|
||||
const m = part.match(/^\{([a-zA-Z_]+)\}$/);
|
||||
if (m && vars[m[1]] !== undefined) {
|
||||
el.appendChild(makeChip(m[1], vars[m[1]]));
|
||||
} else {
|
||||
el.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function serialize(el) {
|
||||
let out = '';
|
||||
el.childNodes.forEach(n => {
|
||||
if (n.nodeType === Node.TEXT_NODE) out += n.textContent;
|
||||
else if (n.dataset && n.dataset.var) out += '{' + n.dataset.var + '}';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Editor DnD ────────────────────────────────────────────────────────
|
||||
function removeCaret() {
|
||||
if (_caret && _caret.parentNode) _caret.parentNode.removeChild(_caret);
|
||||
_caret = null;
|
||||
}
|
||||
|
||||
function caretAt(x, y) {
|
||||
if (document.caretRangeFromPoint) {
|
||||
const r = document.caretRangeFromPoint(x, y);
|
||||
if (r) return [r.startContainer, r.startOffset];
|
||||
}
|
||||
if (document.caretPositionFromPoint) {
|
||||
const p = document.caretPositionFromPoint(x, y);
|
||||
if (p) return [p.offsetNode, p.offset];
|
||||
}
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
function bindEditorDrop(el) {
|
||||
el.addEventListener('dragover', e => {
|
||||
if (!_drag) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
removeCaret();
|
||||
const [node, off] = caretAt(e.clientX, e.clientY);
|
||||
if (!node) return;
|
||||
const c = document.createElement('span');
|
||||
c.className = 'pe-caret';
|
||||
_caret = c;
|
||||
try {
|
||||
const r = document.createRange();
|
||||
r.setStart(node, Math.min(off, node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length));
|
||||
r.collapse(true);
|
||||
r.insertNode(c);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
el.addEventListener('dragleave', e => {
|
||||
if (!el.contains(e.relatedTarget)) removeCaret();
|
||||
});
|
||||
|
||||
el.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
removeCaret();
|
||||
if (!_drag) return;
|
||||
const [node, off] = caretAt(e.clientX, e.clientY);
|
||||
if (_drag.parentNode) _drag.parentNode.removeChild(_drag);
|
||||
if (node && el.contains(node)) {
|
||||
try {
|
||||
const r = document.createRange();
|
||||
r.setStart(node, Math.min(off, node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length));
|
||||
r.collapse(true);
|
||||
r.insertNode(_drag);
|
||||
} catch { el.appendChild(_drag); }
|
||||
} else {
|
||||
el.appendChild(_drag);
|
||||
}
|
||||
bindChipDnD(_drag);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Block deletion of chips ───────────────────────────────────────────
|
||||
function bindEditorKeys(el) {
|
||||
el.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!range.collapsed && range.cloneContents().querySelector('.pe-chip')) {
|
||||
e.preventDefault(); return;
|
||||
}
|
||||
if (range.collapsed) {
|
||||
const siblings = (container, offset, dir) => {
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
if (dir === 'prev' && offset > 0) return null;
|
||||
if (dir === 'next' && offset < container.length) return null;
|
||||
const arr = Array.from(container.parentNode.childNodes);
|
||||
const i = arr.indexOf(container);
|
||||
return dir === 'prev' ? arr[i - 1] : arr[i + 1];
|
||||
}
|
||||
return dir === 'prev' ? container.childNodes[offset - 1] : container.childNodes[offset];
|
||||
};
|
||||
const check = e.key === 'Backspace'
|
||||
? siblings(range.startContainer, range.startOffset, 'prev')
|
||||
: siblings(range.startContainer, range.startOffset, 'next');
|
||||
if (check && check.classList && check.classList.contains('pe-chip')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init from PROMPTS_DATA ────────────────────────────────────────────
|
||||
function initEditor(el) {
|
||||
const pid = el.dataset.pid;
|
||||
const field = el.dataset.field;
|
||||
const prompt = PROMPTS_DATA[pid];
|
||||
if (!prompt) return;
|
||||
const text = prompt[field] || '';
|
||||
const vars = prompt.vars || {};
|
||||
renderText(el, text, vars);
|
||||
bindEditorDrop(el);
|
||||
bindEditorKeys(el);
|
||||
}
|
||||
|
||||
function initPalette(palette) {
|
||||
const pid = palette.dataset.pid;
|
||||
const field = palette.dataset.field;
|
||||
const vars = (PROMPTS_DATA[pid] || {}).vars || {};
|
||||
palette.innerHTML = '';
|
||||
Object.entries(vars).forEach(([varName, label]) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'pe-palette-chip';
|
||||
chip.textContent = '+ ' + label;
|
||||
chip.title = 'вставить переменную «' + label + '»';
|
||||
chip.addEventListener('click', () => insertVar(pid, field, varName, label));
|
||||
palette.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function insertVar(pid, field, varName, label) {
|
||||
const el = document.querySelector(`.pe-field[data-pid="${pid}"][data-field="${field}"]`);
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const sel = window.getSelection();
|
||||
let range;
|
||||
if (sel && sel.rangeCount && el.contains(sel.getRangeAt(0).commonAncestorContainer)) {
|
||||
range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
} else {
|
||||
range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
}
|
||||
const chip = makeChip(varName, label);
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(chip);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.pe-field').forEach(initEditor);
|
||||
document.querySelectorAll('.pe-palette').forEach(initPalette);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────
|
||||
window.savePrompt = async function(card) {
|
||||
const pid = card.dataset.pid;
|
||||
const sysEl = card.querySelector('.pe-field[data-field="system"]');
|
||||
const usrEl = card.querySelector('.pe-field[data-field="user"]');
|
||||
const badge = card.querySelector('.pc-save-status');
|
||||
badge.textContent = '';
|
||||
badge.className = 'pc-save-status pc-badge';
|
||||
try {
|
||||
const r = await fetch('/api/ai/prompts/' + pid, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ system: serialize(sysEl), user: serialize(usrEl) }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || 'Ошибка');
|
||||
// Update in-memory data
|
||||
PROMPTS_DATA[pid].system = serialize(sysEl);
|
||||
PROMPTS_DATA[pid].user = serialize(usrEl);
|
||||
badge.textContent = '✓ Сохранено';
|
||||
badge.className = 'pc-save-status pc-badge pc-badge--ok';
|
||||
setTimeout(() => { badge.textContent = ''; badge.className = 'pc-save-status pc-badge'; }, 3000);
|
||||
} catch (e) {
|
||||
badge.textContent = e.message;
|
||||
badge.className = 'pc-save-status pc-badge pc-badge--err';
|
||||
}
|
||||
};
|
||||
|
||||
// ── Reset ─────────────────────────────────────────────────────────────
|
||||
window.resetPrompt = async function(card) {
|
||||
if (!confirm('Сбросить к последней сохранённой версии?')) return;
|
||||
const pid = card.dataset.pid;
|
||||
try {
|
||||
const r = await fetch('/api/ai/prompts');
|
||||
const d = await r.json();
|
||||
const p = d.prompts?.[pid];
|
||||
if (!p) return;
|
||||
PROMPTS_DATA[pid] = p;
|
||||
const sysEl = card.querySelector('.pe-field[data-field="system"]');
|
||||
const usrEl = card.querySelector('.pe-field[data-field="user"]');
|
||||
renderText(sysEl, p.system || '', p.vars || {});
|
||||
renderText(usrEl, p.user || '', p.vars || {});
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Статистика прохождений{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6 gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">bar_chart</span>
|
||||
<h1 class="text-2xl font-semibold text-ink-900">Статистика</h1>
|
||||
</div>
|
||||
<a href="{{ url_for('main.index') }}"
|
||||
class="inline-flex items-center gap-1 text-sm text-ink-500 hover:text-ink-800 transition">
|
||||
<span class="material-symbols-outlined text-base">arrow_back</span>
|
||||
Главная
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Сводные метрики #}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6">
|
||||
{% for icon, label, value in [
|
||||
('quiz', 'Тестов всего', stats.total_tests),
|
||||
('people', 'Пользователей', stats.total_users),
|
||||
('fact_check', 'Прохождений', stats.total_completed),
|
||||
('check_circle', 'Сдали', stats.total_passed),
|
||||
('percent', 'Успешность', stats.pass_rate | string + '\u2009%'),
|
||||
] %}
|
||||
<div class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-4 flex flex-col gap-1">
|
||||
<span class="material-symbols-outlined text-brand-500 text-xl">{{ icon }}</span>
|
||||
<div class="text-2xl font-bold text-ink-900 leading-tight">{{ value }}</div>
|
||||
<div class="text-xs text-ink-500">{{ label }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# По отделам + последние прохождения #}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
<section class="rounded-2xl bg-white border border-ink-300/50 shadow-sm overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-ink-100 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-500 text-base">corporate_fare</span>
|
||||
<h2 class="font-semibold text-sm text-ink-900">По отделам</h2>
|
||||
</div>
|
||||
{% if stats.dept_stats %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-ink-100 text-xs text-ink-500 uppercase tracking-wide">
|
||||
<th class="px-4 py-2 text-left font-medium">Отдел</th>
|
||||
<th class="px-4 py-2 text-right font-medium">Прошли</th>
|
||||
<th class="px-4 py-2 text-right font-medium">Сдали</th>
|
||||
<th class="px-4 py-2 text-right font-medium">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-ink-100/60">
|
||||
{% for d in stats.dept_stats %}
|
||||
<tr class="hover:bg-ink-100/40 transition-colors">
|
||||
<td class="px-4 py-2.5 text-ink-800 max-w-[180px] truncate">{{ d.name }}</td>
|
||||
<td class="px-4 py-2.5 text-right text-ink-700">{{ d.total }}</td>
|
||||
<td class="px-4 py-2.5 text-right text-ink-700">{{ d.passed }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<span class="inline-block min-w-[38px] text-center rounded-full px-2 py-0.5 text-xs font-semibold
|
||||
{% if d.rate >= 80 %}bg-green-50 text-green-700
|
||||
{% elif d.rate >= 50 %}bg-yellow-50 text-yellow-700
|
||||
{% else %}bg-red-50 text-red-700{% endif %}">
|
||||
{{ d.rate }}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="px-4 py-8 text-sm text-ink-500 text-center">Прохождений ещё нет.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white border border-ink-300/50 shadow-sm overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-ink-100 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-500 text-base">history</span>
|
||||
<h2 class="font-semibold text-sm text-ink-900">Последние прохождения</h2>
|
||||
</div>
|
||||
{% if stats.recent %}
|
||||
<ul class="divide-y divide-ink-100/60">
|
||||
{% for r in stats.recent %}
|
||||
<li class="px-4 py-2.5 flex items-start gap-3 hover:bg-ink-100/30 transition-colors">
|
||||
<span class="material-symbols-outlined text-base mt-0.5
|
||||
{% if r.passed == true %}text-green-500{% elif r.passed is none %}text-ink-300{% else %}text-red-400{% endif %}">
|
||||
{% if r.passed == true %}check_circle{% elif r.passed is none %}radio_button_unchecked{% else %}cancel{% endif %}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm text-ink-800 truncate font-medium">{{ r.test }}</div>
|
||||
<div class="text-xs text-ink-500 truncate">{{ r.user }} · {{ r.score }} · {{ r.at }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="px-4 py-8 text-sm text-ink-500 text-center">Прохождений ещё нет.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -6,6 +6,7 @@
|
||||
<p class="link-back"><a href="/tests">← к списку тестов</a></p>
|
||||
<h1 class="font-headline" style="font-size:1.35rem;margin-top:0;" id="attempt-title">Загрузка…</h1>
|
||||
<p class="text-muted" style="margin-top:0;" id="attempt-subtitle"></p>
|
||||
<p class="text-muted" id="attempt-timer" style="margin-top:0;display:none;font-weight:600;"></p>
|
||||
<p class="error-text" id="attempt-error" style="display:none;"></p>
|
||||
|
||||
<ol id="questions-list" style="padding-left:1.25rem;"></ol>
|
||||
@@ -15,6 +16,18 @@
|
||||
</div>
|
||||
|
||||
<div id="attempt-result" class="surface-card" style="display:none;margin-top:1rem;padding:1rem;"></div>
|
||||
|
||||
<dialog id="hint-modal" style="border:none;border-radius:14px;padding:0;max-width:480px;width:90%;box-shadow:0 12px 40px rgba(0,0,0,0.18);">
|
||||
<div style="padding:1rem 1.25rem;">
|
||||
<h3 id="hint-title" style="margin:0 0 0.5rem 0;font-size:1.05rem;">Подсказка</h3>
|
||||
<p id="hint-verdict" style="margin:0 0 0.5rem 0;font-weight:600;"></p>
|
||||
<p id="hint-correct" style="margin:0 0 0.5rem 0;font-size:0.9rem;color:#506965;"></p>
|
||||
<p id="hint-explanation" style="margin:0;font-size:0.95rem;line-height:1.4;"></p>
|
||||
<div class="inline-actions" style="margin-top:0.85rem;justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-primary btn--sm" id="hint-close-btn">Понятно</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -24,12 +37,23 @@
|
||||
const attemptId = root.dataset.attemptId;
|
||||
const titleEl = document.getElementById('attempt-title');
|
||||
const subEl = document.getElementById('attempt-subtitle');
|
||||
const timerEl = document.getElementById('attempt-timer');
|
||||
const errEl = document.getElementById('attempt-error');
|
||||
const listEl = document.getElementById('questions-list');
|
||||
const resultEl = document.getElementById('attempt-result');
|
||||
const submitBtn = document.getElementById('submit-attempt-btn');
|
||||
const hintModal = document.getElementById('hint-modal');
|
||||
const hintTitle = document.getElementById('hint-title');
|
||||
const hintVerdict = document.getElementById('hint-verdict');
|
||||
const hintCorrect = document.getElementById('hint-correct');
|
||||
const hintExplanation = document.getElementById('hint-explanation');
|
||||
const hintCloseBtn = document.getElementById('hint-close-btn');
|
||||
|
||||
let playData = null;
|
||||
const selections = {};
|
||||
const checked = {};
|
||||
let timerHandle = null;
|
||||
let deadlineMs = null;
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (m) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
@@ -51,38 +75,127 @@
|
||||
}
|
||||
selections[k] = [id];
|
||||
}
|
||||
function isImmediate() {
|
||||
return playData && playData.resultMode === 'immediate';
|
||||
}
|
||||
|
||||
function renderQuestions() {
|
||||
listEl.innerHTML = '';
|
||||
for (const q of (playData.questions || [])) {
|
||||
const qid = String(q.id);
|
||||
const isChecked = !!checked[qid];
|
||||
const li = document.createElement('li');
|
||||
li.style.marginBottom = '1.5rem';
|
||||
li.innerHTML = '<p style="margin-top:0;margin-bottom:0.5rem;">' + esc(q.text) + '</p>';
|
||||
li.dataset.qid = qid;
|
||||
let badge = '';
|
||||
if (isChecked) {
|
||||
const ok = checked[qid].isCorrect;
|
||||
badge = '<span style="margin-left:8px;font-size:0.8rem;font-weight:600;color:' +
|
||||
(ok ? '#1a7a4a' : '#b32d2d') + ';">' + (ok ? '✓ верно' : '✗ неверно') + '</span>';
|
||||
}
|
||||
li.innerHTML = '<p style="margin-top:0;margin-bottom:0.5rem;">' + esc(q.text) + badge + '</p>';
|
||||
const ul = document.createElement('ul');
|
||||
ul.style.listStyle = 'none';
|
||||
ul.style.padding = '0';
|
||||
ul.style.margin = '0';
|
||||
const correctSet = new Set((checked[qid] && checked[qid].correctOptionIds) || []);
|
||||
for (const o of (q.options || [])) {
|
||||
const oid = String(o.id);
|
||||
const row = document.createElement('li');
|
||||
row.style.marginBottom = '6px';
|
||||
const type = q.hasMultipleAnswers ? 'checkbox' : 'radio';
|
||||
const name = 'q-' + q.id;
|
||||
let mark = '';
|
||||
if (isChecked) {
|
||||
if (correctSet.has(oid)) mark = ' <span style="color:#1a7a4a;font-weight:600;">(верно)</span>';
|
||||
else if (isSelected(q.id, o.id)) mark = ' <span style="color:#b32d2d;">(ваш ответ)</span>';
|
||||
}
|
||||
row.innerHTML =
|
||||
'<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">' +
|
||||
'<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' + (isSelected(q.id, o.id) ? 'checked' : '') + ' />' +
|
||||
'<span>' + esc(o.text) + '</span>' +
|
||||
'<label style="display:flex;align-items:flex-start;gap:8px;cursor:' + (isChecked ? 'default' : 'pointer') + ';' +
|
||||
(isChecked ? 'opacity:0.85;' : '') + '">' +
|
||||
'<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' +
|
||||
(isSelected(q.id, o.id) ? 'checked' : '') + ' ' + (isChecked ? 'disabled' : '') + ' />' +
|
||||
'<span>' + esc(o.text) + mark + '</span>' +
|
||||
'</label>';
|
||||
const input = row.querySelector('input');
|
||||
input.addEventListener('change', () => {
|
||||
if (checked[qid]) return;
|
||||
toggle(q.id, o.id, q.hasMultipleAnswers);
|
||||
renderQuestions();
|
||||
});
|
||||
ul.appendChild(row);
|
||||
}
|
||||
li.appendChild(ul);
|
||||
|
||||
if (isImmediate() && !isChecked) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-ghost btn--sm';
|
||||
btn.textContent = 'Ответить';
|
||||
btn.style.marginTop = '0.4rem';
|
||||
const sel = selections[qid] || [];
|
||||
btn.disabled = sel.length === 0;
|
||||
btn.addEventListener('click', () => checkOne(q.id));
|
||||
li.appendChild(btn);
|
||||
}
|
||||
listEl.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOne(qid) {
|
||||
const k = String(qid);
|
||||
const sel = selections[k] || [];
|
||||
if (!sel.length) return;
|
||||
try {
|
||||
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ questionId: qid, selectedOptionIds: sel }),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.');
|
||||
checked[k] = data;
|
||||
renderQuestions();
|
||||
if (playData.hintsEnabled) {
|
||||
showHint(data);
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showHint(data) {
|
||||
hintVerdict.textContent = data.isCorrect ? 'Верно!' : 'Неверно.';
|
||||
hintVerdict.style.color = data.isCorrect ? '#1a7a4a' : '#b32d2d';
|
||||
const correct = (data.correctOptionTexts || []).join('; ');
|
||||
hintCorrect.textContent = correct ? ('Правильный ответ: ' + correct) : '';
|
||||
hintExplanation.textContent = data.explanation || 'Объяснение недоступно.';
|
||||
if (typeof hintModal.showModal === 'function') hintModal.showModal();
|
||||
else hintModal.setAttribute('open', '');
|
||||
}
|
||||
hintCloseBtn.addEventListener('click', () => {
|
||||
if (typeof hintModal.close === 'function') hintModal.close();
|
||||
else hintModal.removeAttribute('open');
|
||||
});
|
||||
|
||||
function startTimer(minutes) {
|
||||
if (!minutes || minutes <= 0) return;
|
||||
deadlineMs = Date.now() + minutes * 60 * 1000;
|
||||
timerEl.style.display = '';
|
||||
const tick = () => {
|
||||
const left = Math.max(0, deadlineMs - Date.now());
|
||||
const m = Math.floor(left / 60000);
|
||||
const s = Math.floor((left % 60000) / 1000);
|
||||
timerEl.textContent = 'Осталось: ' + m + ':' + String(s).padStart(2, '0');
|
||||
if (left <= 0) {
|
||||
clearInterval(timerHandle);
|
||||
submit(true);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
timerHandle = setInterval(tick, 500);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/play');
|
||||
@@ -90,22 +203,26 @@
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
|
||||
playData = data;
|
||||
titleEl.textContent = data.testTitle || 'Прохождение теста';
|
||||
subEl.textContent = 'Порог зачёта: ' + (data.passingThreshold ?? 0) + '%.';
|
||||
const parts = ['Порог зачёта: ' + (data.passingThreshold ?? 0) + '%'];
|
||||
if (data.resultMode === 'immediate') parts.push('Результат сразу после ответа');
|
||||
if (data.hintsEnabled) parts.push('С ИИ-подсказками');
|
||||
subEl.textContent = parts.join(' · ') + '.';
|
||||
if (!Array.isArray(data.questions) || !data.questions.length) {
|
||||
setErr('В активной версии нет вопросов.');
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
renderQuestions();
|
||||
if (data.timeLimit) startTimer(Number(data.timeLimit));
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
async function submit(auto) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Отправка…';
|
||||
submitBtn.textContent = auto ? 'Время вышло, отправка…' : 'Отправка…';
|
||||
try {
|
||||
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/submit', {
|
||||
method: 'POST',
|
||||
@@ -114,6 +231,8 @@
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось завершить попытку.');
|
||||
if (timerHandle) clearInterval(timerHandle);
|
||||
timerEl.style.display = 'none';
|
||||
resultEl.style.display = '';
|
||||
resultEl.innerHTML =
|
||||
'<h3 style="margin-top:0;">Результат</h3>' +
|
||||
@@ -129,7 +248,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.addEventListener('click', submit);
|
||||
submitBtn.addEventListener('click', () => submit(false));
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -8,16 +8,9 @@
|
||||
data-initial='{{ content | tojson | safe }}'>
|
||||
|
||||
<section class="cabinet-brick cabinet-brick--hero hero-brick">
|
||||
<div class="hero-brick__nav">
|
||||
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back">← к списку</a>
|
||||
<span class="hero-brick__meta">
|
||||
<span>Автор: <b id="intro-author">Вы</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Обновлён: <span id="intro-updated">—</span></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Версия <span id="intro-version">—</span></span>
|
||||
</span>
|
||||
</div>
|
||||
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back">← К тестам</a>
|
||||
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
||||
class="hero-brick__title font-headline"></textarea>
|
||||
|
||||
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
||||
class="hero-brick__title font-headline"></textarea>
|
||||
@@ -25,26 +18,92 @@
|
||||
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
|
||||
class="hero-brick__desc"></textarea>
|
||||
|
||||
<div class="hero-brick__chips">
|
||||
<label class="hero-brick__chip">
|
||||
<span>Порог зачёта</span>
|
||||
<input id="test-threshold" type="number" min="0" max="100" step="1" inputmode="numeric" />
|
||||
<span>%</span>
|
||||
</label>
|
||||
<span class="hero-brick__chip hero-brick__chip--readonly">
|
||||
Вопросов: <b id="q-count">0</b>
|
||||
</span>
|
||||
<label class="hero-brick__chip">
|
||||
<input id="chain-active" type="checkbox" />
|
||||
<span>Активна в каталоге</span>
|
||||
</label>
|
||||
<div class="hero-brick__meta-row">
|
||||
<span>Автор: <b id="intro-author">Вы</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Обновлён: <span id="intro-updated">—</span></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Версия <span id="intro-version">—</span></span>
|
||||
</div>
|
||||
|
||||
<div id="intro-fork-banner" class="callout callout--warning" data-fork-risk="{{ '1' if content.test.hasForkRisk else '0' }}" style="margin-top:0.85rem; display:none;">
|
||||
При сохранении будет создана новая версия теста.
|
||||
<div class="hero-brick__divider"></div>
|
||||
|
||||
<div class="hero-brick__meta-row">
|
||||
<span>Порог зачёта: <b id="threshold-mirror">—</b>%</span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Вопросов: <b id="q-count">0</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span id="chain-active-display">Активна в каталоге</span>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{# ── Версии ───────────────────────────────────────────────────── #}
|
||||
<details class="cabinet-disclosure cabinet-brick">
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Версии</span>
|
||||
<span class="cabinet-disclosure__summary-sub">История изменений; можно переключить активную версию</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<ul id="versions-list" class="version-card-list"></ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="cabinet-disclosure cabinet-brick">
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Параметры теста</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Порог, таймер, режим показа результата и подсказок</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<div class="settings-grid">
|
||||
<label class="settings-row">
|
||||
<span class="settings-row__label">Проходной балл, %</span>
|
||||
<input id="test-threshold" type="number" min="0" max="100" step="1" inputmode="numeric"
|
||||
class="settings-row__input" />
|
||||
</label>
|
||||
|
||||
<label class="settings-row">
|
||||
<span class="settings-row__label">
|
||||
Таймер, минут
|
||||
<span class="settings-row__hint">0 или пусто — без ограничения</span>
|
||||
</span>
|
||||
<input id="test-time-limit" type="number" min="0" max="600" step="1" inputmode="numeric"
|
||||
class="settings-row__input" placeholder="—" />
|
||||
</label>
|
||||
|
||||
<fieldset class="settings-row settings-row--block">
|
||||
<legend class="settings-row__label">Когда показывать результат</legend>
|
||||
<label class="settings-radio">
|
||||
<input type="radio" name="result-mode" value="end" />
|
||||
<span>В конце теста <span class="settings-row__hint">(подсказок не будет)</span></span>
|
||||
</label>
|
||||
<label class="settings-radio">
|
||||
<input type="radio" name="result-mode" value="immediate" />
|
||||
<span>Сразу после ответа <span class="settings-row__hint">(с ИИ-подсказкой)</span></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="settings-row settings-row--toggle" id="test-hints-row" style="display:none;">
|
||||
<span class="settings-row__label">
|
||||
Показывать подсказку после ответа
|
||||
<span class="settings-row__hint">Краткое объяснение во всплывающем окне</span>
|
||||
</span>
|
||||
<input id="test-hints-enabled" type="checkbox" />
|
||||
</label>
|
||||
|
||||
<div class="settings-row settings-row--block" style="padding-top:0.75rem; border-top:1px solid var(--outline-variant); margin-top:0.25rem;">
|
||||
<span class="settings-row__label">Видимость в каталоге</span>
|
||||
<p class="settings-row__hint" style="margin-bottom:0.5rem;">Скрытые тесты не показываются в общем списке; ссылку по-прежнему можно открыть.</p>
|
||||
<button id="btn-toggle-visibility" class="btn btn-ghost btn--sm" type="button">Скрыть из списка</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="cabinet-disclosure cabinet-brick" open>
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
@@ -54,13 +113,14 @@
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5 test-detail-ai-panel">
|
||||
|
||||
{# ── Создать шаблон ──────────────────────────────────────── #}
|
||||
<div class="question-editor-block question-editor-block--first">
|
||||
<h3 class="test-detail-subsection__title" style="margin-top:0;">Генерация сетки вопросов (ИИ)</h3>
|
||||
<label class="block">
|
||||
<span class="form-label">Тема</span>
|
||||
<input id="ai-topic" type="text" class="form-input" placeholder="Например: Введение про LLM" />
|
||||
</label>
|
||||
<div class="mt-3 flex flex-wrap items-end gap-3">
|
||||
<h3 class="test-detail-subsection__title">Структура теста</h3>
|
||||
<p class="muted text-xs mb-3">
|
||||
Укажите количество вопросов и вариантов — создайте шаблон и заполните его вручную или через ИИ.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="block">
|
||||
<span class="form-label">Вопросов</span>
|
||||
<input id="ai-q-count" type="number" min="1" max="30" step="1" value="7"
|
||||
@@ -71,106 +131,121 @@
|
||||
<input id="ai-o-count" type="number" min="2" max="8" step="1" value="3"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
<button id="ai-generate-test"
|
||||
<button id="create-template"
|
||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||
Сгенерировать тест (ИИ)
|
||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">grid_view</span>
|
||||
Создать шаблон
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Заполнить через ИИ по теме ──────────────────────────── #}
|
||||
<div class="question-editor-block">
|
||||
<h3 class="test-detail-subsection__title">Заполнить через ИИ</h3>
|
||||
<label class="block">
|
||||
<span class="form-label">Тема / промпт</span>
|
||||
<textarea id="ai-topic" rows="1" class="form-input"
|
||||
placeholder="Например: охрана труда на производстве"
|
||||
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
||||
</label>
|
||||
<div class="mt-2">
|
||||
<button id="ai-generate-test"
|
||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||
Сгенерировать вопросы (ИИ)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Проверить и улучшить ─────────────────────────────────── #}
|
||||
<div class="question-editor-block">
|
||||
<h3 class="test-detail-subsection__title">Проверить и улучшить</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button id="ai-check"
|
||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">fact_check</span>
|
||||
Проверить тест
|
||||
</button>
|
||||
<button id="ai-improve"
|
||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_fix_high</span>
|
||||
Предложить улучшение
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Документ в вопросы ──────────────────────────────────── #}
|
||||
<div class="question-editor-block test-detail-subsection test-detail-subsection--import">
|
||||
<h3 class="test-detail-subsection__title">Документ в вопросы</h3>
|
||||
<p class="muted test-detail-hint" style="margin-top:0;">
|
||||
PDF, Word или текст — вставьте в черновик вопросов.
|
||||
</p>
|
||||
<label class="mt-2 inline-flex w-full items-center justify-center gap-2 px-3 py-3
|
||||
rounded-lg bg-white border border-ink-300/60 hover:border-brand-300
|
||||
text-sm cursor-pointer min-h-11">
|
||||
<span class="material-symbols-outlined text-base text-brand-600">upload_file</span>
|
||||
<span>Загрузить документ (PDF, DOCX, TXT, MD)</span>
|
||||
<label id="ai-import-dropzone"
|
||||
class="import-dropzone mt-2 flex flex-col w-full items-center justify-center gap-1
|
||||
px-4 py-5 rounded-xl bg-white border-2 border-dashed border-ink-300/70
|
||||
hover:border-brand-400 hover:bg-brand-50/40 cursor-pointer transition-colors">
|
||||
<span class="material-symbols-outlined text-2xl text-brand-400">upload_file</span>
|
||||
<span id="ai-import-dropzone-label" class="text-sm font-medium text-ink-700">Перетащите файл сюда или нажмите</span>
|
||||
<span class="text-xs text-ink-400">PDF, DOCX, TXT, MD · до 16 МБ</span>
|
||||
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" />
|
||||
</label>
|
||||
<p class="mt-1.5 text-xs text-ink-500">
|
||||
До 16 МБ. AI извлечёт текст и предложит черновик теста.
|
||||
</p>
|
||||
<label class="block mt-3">
|
||||
<span class="form-label">Пожелания по содержанию <span class="text-ink-400 font-normal">(необязательно)</span></span>
|
||||
<textarea id="doc-user-hint" rows="1"
|
||||
class="form-input mt-1"
|
||||
placeholder="Например: акцент на разделе 3, не делать вопросы про даты"
|
||||
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
||||
</label>
|
||||
<button id="doc-generate-btn"
|
||||
class="btn btn-ghost mt-2" type="button" style="min-height:43px;">
|
||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span>
|
||||
Сгенерировать из документа
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# ── Модалка результата импорта документа ─────────────────── #}
|
||||
<dialog id="import-modal" class="save-modal">
|
||||
<div class="save-modal__inner" style="max-width:480px; width:100%;">
|
||||
<h3 id="import-modal-title" class="font-headline text-base font-semibold mb-2"></h3>
|
||||
<div id="import-modal-body" class="text-sm text-ink-600 mb-4 max-h-64 overflow-y-auto"></div>
|
||||
<div id="import-modal-actions" class="flex gap-2 justify-end flex-wrap"></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<p id="ai-status" class="mt-3 text-sm text-ink-500 min-h-[1.25rem]"></p>
|
||||
</section>
|
||||
|
||||
{# ── 3. Вопросы ─────────────────────────────────────────────── #}
|
||||
<section>
|
||||
<div class="flex items-center justify-between gap-2 px-1">
|
||||
<h2 class="font-semibold">Вопросы (<span id="q-count-mirror">0</span>)</h2>
|
||||
<section class="mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-ink-900">Вопросы (<span id="q-count-mirror">0</span>)</h2>
|
||||
</div>
|
||||
<ol id="questions" class="mt-3 space-y-4"></ol>
|
||||
<div class="mt-3 flex justify-center">
|
||||
<button id="add-question"
|
||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
|
||||
bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10
|
||||
btn btn-ghost btn--sm question-editor__add-question">
|
||||
btn btn-ghost question-editor__add-question">
|
||||
<span class="material-symbols-outlined text-base">add</span>
|
||||
<span class="hidden sm:inline">Добавить вопрос</span>
|
||||
<span class="sm:hidden">Добавить</span>
|
||||
<span>Добавить вопрос</span>
|
||||
</button>
|
||||
</div>
|
||||
<ol id="questions" class="mt-3 space-y-3"></ol>
|
||||
|
||||
{# Кнопка «Сохранить» под вопросами #}
|
||||
<div class="mt-5 flex items-center gap-3">
|
||||
<button id="save-draft-inline"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11 btn btn-primary">
|
||||
<span class="material-symbols-outlined text-base">save</span>
|
||||
Сохранить
|
||||
</button>
|
||||
<button id="btn-cancel-inline"
|
||||
class="inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
|
||||
Отмена
|
||||
</button>
|
||||
<p id="save-status-inline" class="text-xs text-ink-500"></p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="cabinet-disclosure cabinet-brick" open>
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">История</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Версии теста и кто проходил</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<div class="test-detail-subsection test-detail-subsection--tight">
|
||||
<h3 class="test-detail-subsection__title">Версии</h3>
|
||||
<ul id="versions-list" class="version-card-list"></ul>
|
||||
</div>
|
||||
<div class="test-detail-subsection">
|
||||
<h3 class="test-detail-subsection__title">Прохождения</h3>
|
||||
<ul id="attempts-list" class="attempts-card-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="cabinet-disclosure cabinet-brick" open>
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Показ в каталоге</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Видимость в списке и выдача сотрудникам</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<div class="test-detail-subsection test-detail-subsection--tight">
|
||||
<h3 class="test-detail-subsection__title">Видимость</h3>
|
||||
<p class="test-detail-hint">Скрытые тесты в общем списке не показываются; ссылку на тест по-прежнему можно открыть.</p>
|
||||
<div class="publication-visibility__actions">
|
||||
<button id="btn-toggle-visibility" class="btn btn-ghost" type="button">Скрыть из списка</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="test-detail-subsection">
|
||||
<h3 class="test-detail-subsection__title">Кому выдать</h3>
|
||||
<p class="test-detail-hint">Список с учётом поиска и фильтров; можно отметить всех на экране.</p>
|
||||
<div class="assign-toolbar">
|
||||
<input id="assign-search" class="form-input assign-toolbar__search" type="text" placeholder="Поиск: ФИО, логин" />
|
||||
<select id="assign-dept" class="form-input"><option value="__all__">Все отделы</option></select>
|
||||
<select id="assign-clinic" class="form-input">
|
||||
<option value="all">Все</option>
|
||||
<option value="with">С учёткой в модуле</option>
|
||||
<option value="without">Без учётки (создадим при назначении)</option>
|
||||
</select>
|
||||
<button id="assign-select-all" class="btn btn-ghost btn--sm" type="button">Выбрать всех</button>
|
||||
</div>
|
||||
<div id="assign-list" class="assign-list"></div>
|
||||
<div class="inline-actions" style="margin-top:0.75rem;">
|
||||
<button id="assign-submit" class="btn btn-primary" type="button">Назначить выбранных</button>
|
||||
<span id="assign-status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{# Прохождения перенесены на /stats. Назначения перенесены на /assignments #}
|
||||
</div>
|
||||
|
||||
{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #}
|
||||
@@ -178,12 +253,16 @@
|
||||
pb-[env(safe-area-inset-bottom)]">
|
||||
<div class="mx-auto {% if ui_variant == 'legacy' %}max-w-2xl{% else %}max-w-6xl{% endif %} px-4 py-3
|
||||
flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-ink-500 truncate">Активность цепочки и поля теста — в шапке.</span>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
||||
class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
|
||||
К каталогу
|
||||
</a>
|
||||
<div id="intro-fork-banner" class="callout callout--warning text-xs sm:text-sm"
|
||||
data-fork-risk="{{ '1' if content.test.hasForkRisk else '0' }}"
|
||||
style="display:none; margin:0; padding:0.4rem 0.6rem; flex:1 1 0; min-width:0; white-space:normal; word-break:break-word; line-height:1.25;">
|
||||
При сохранении будет создана новая версия теста.
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-auto shrink-0">
|
||||
<button id="btn-cancel"
|
||||
class="inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
|
||||
Отмена
|
||||
</button>
|
||||
<button id="save-draft"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11 btn btn-primary">
|
||||
@@ -197,12 +276,31 @@
|
||||
|
||||
{# ── Шаблон вопроса ─────────────────────────────────────────────── #}
|
||||
<template id="tpl-question">
|
||||
<li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item question-editor-block">
|
||||
{# Шапка карточки вопроса: номер слева, кнопки справа. #}
|
||||
<li class="relative rounded-xl bg-white border border-ink-300/60 p-4 sm:p-5 q-item" draggable="true">
|
||||
|
||||
{# Оверлей загрузки AI #}
|
||||
<div class="q-ai-overlay hidden absolute inset-0 rounded-xl z-10
|
||||
bg-white/80 backdrop-blur-[2px] flex flex-col items-center justify-center gap-2">
|
||||
<span class="q-ai-spinner inline-block w-7 h-7 rounded-full
|
||||
border-[3px] border-brand-200 border-t-brand-600 animate-spin"></span>
|
||||
<span class="text-xs text-ink-500 font-medium">Генерирую…</span>
|
||||
</div>
|
||||
|
||||
{# Шапка карточки вопроса #}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-md
|
||||
bg-brand-50 text-brand-700 text-xs font-medium q-num">Вопрос #</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<button class="q-drag p-2 rounded hover:bg-ink-100 min-w-10 min-h-10 cursor-grab"
|
||||
title="Перетащить" aria-label="Перетащить" type="button">
|
||||
<span class="material-symbols-outlined text-base">drag_indicator</span>
|
||||
</button>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-md
|
||||
bg-brand-50 text-brand-700 text-xs font-medium q-num">Вопрос #</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button class="q-clear p-2 rounded hover:bg-ink-100 min-w-10 min-h-10 text-ink-400"
|
||||
title="Очистить вопрос" aria-label="Очистить вопрос" type="button">
|
||||
<span class="material-symbols-outlined text-base">backspace</span>
|
||||
</button>
|
||||
<button class="q-up p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
|
||||
title="Выше" aria-label="Поднять выше">
|
||||
<span class="material-symbols-outlined text-base">arrow_upward</span>
|
||||
@@ -218,55 +316,80 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="question-editor-block__header">
|
||||
<h4 class="question-editor-block__title q-num">Вопрос #</h4>
|
||||
<button class="q-ai btn btn-ghost btn--sm question-editor-block__ai-btn">
|
||||
Сгенерировать вопрос (ИИ)
|
||||
</button>
|
||||
<div class="mt-2 relative">
|
||||
<textarea class="q-text w-full rounded-lg border border-ink-300 px-3 py-2
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
rows="1" placeholder="Формулировка вопроса" maxlength="500"
|
||||
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
||||
<span class="q-text-counter absolute bottom-1.5 right-2 text-xs text-ink-400 pointer-events-none select-none"></span>
|
||||
</div>
|
||||
|
||||
<textarea class="q-text mt-2 w-full rounded-lg border border-ink-300 px-3 py-2
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
rows="2" placeholder="Формулировка вопроса"></textarea>
|
||||
|
||||
<div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm">
|
||||
<label class="inline-flex items-center gap-2 min-h-9">
|
||||
<input type="checkbox"
|
||||
class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
<span>Несколько правильных ответов</span>
|
||||
</label>
|
||||
<button class="q-ai btn btn-ghost btn--sm q-ai-btn" style="font-size:0.75rem; padding:0.3rem 0.7rem;">
|
||||
<span class="material-symbols-outlined q-ai-icon" style="font-size:0.9rem; vertical-align:-2px;">auto_fix_high</span>
|
||||
<span class="q-ai-label">Сгенерировать</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="q-options mt-3 space-y-2"></ul>
|
||||
<button class="q-add-option mt-2 inline-flex items-center gap-1 px-2 py-2 rounded
|
||||
text-sm text-brand-700 hover:bg-brand-50 min-h-10 btn btn-ghost btn--sm">
|
||||
<span class="material-symbols-outlined text-base">add</span>
|
||||
Добавить вариант
|
||||
</button>
|
||||
<p class="mt-4 mb-2 text-xs text-ink-400 font-medium">Отметьте правильные варианты</p>
|
||||
<ul class="q-options space-y-2"></ul>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<button class="q-add-option inline-flex items-center gap-1 px-2 py-2 rounded
|
||||
text-sm text-brand-700 hover:bg-brand-50 min-h-10 btn btn-ghost btn--sm">
|
||||
<span class="material-symbols-outlined text-base">add</span>
|
||||
<span class="q-add-option-label">Добавить вариант</span>
|
||||
</button>
|
||||
<span class="q-options-count text-xs text-ink-400"></span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
{# ── Шаблон варианта ────────────────────────────────────────────── #}
|
||||
<template id="tpl-option">
|
||||
<li class="flex items-center gap-2 opt-item question-option-row">
|
||||
{# Чекбокс «Правильный» — обёрнут в большой tap-target. #}
|
||||
<label class="inline-flex items-center justify-center w-10 h-10 shrink-0 cursor-pointer
|
||||
rounded hover:bg-ink-100" title="Правильный ответ">
|
||||
<li class="flex items-start gap-2 opt-item">
|
||||
{# Чекбокс «Правильный» — выровнен по первой строке textarea #}
|
||||
<label class="shrink-0 w-10 inline-flex items-center justify-center cursor-pointer
|
||||
rounded hover:bg-ink-100 pt-1.5" style="min-height:2.5rem;" title="Правильный ответ">
|
||||
<input type="checkbox"
|
||||
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500 question-option-row__mark" />
|
||||
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
</label>
|
||||
<input type="text"
|
||||
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2 question-option-row__text
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
placeholder="Вариант ответа" />
|
||||
<button class="opt-delete shrink-0 w-10 h-10 inline-flex items-center justify-center
|
||||
rounded hover:bg-red-50 text-red-600 question-option-remove"
|
||||
<textarea rows="1"
|
||||
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
placeholder="Вариант ответа"
|
||||
style="resize:none; overflow:hidden; font-family:inherit; line-height:1.55;"></textarea>
|
||||
<button class="opt-delete shrink-0 w-10 inline-flex items-center justify-center
|
||||
rounded hover:bg-red-50 text-red-600 pt-1.5"
|
||||
style="min-height:2.5rem;"
|
||||
title="Удалить" aria-label="Удалить вариант">
|
||||
<span class="material-symbols-outlined text-base">close</span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
{# ── Модалка успешного сохранения (компактная, сверху) ──────────── #}
|
||||
<dialog id="save-modal" class="save-modal">
|
||||
<div class="save-modal__inner">
|
||||
<h3 class="text-base font-semibold mb-1" id="save-modal-title">Сохранено</h3>
|
||||
<p id="save-modal-msg" class="text-sm text-ink-700">Изменения сохранены.</p>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<button id="save-modal-stay" type="button"
|
||||
class="px-3 py-2 rounded-lg bg-ink-100 hover:bg-ink-200 text-sm btn btn-ghost">
|
||||
К редактору
|
||||
</button>
|
||||
<button id="save-modal-go" type="button"
|
||||
class="px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm btn btn-primary">
|
||||
К каталогу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{# ── Модалка результата AI-проверки/улучшения (fullscreen на мобиле) ── #}
|
||||
<dialog id="ai-modal"
|
||||
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-3xl sm:w-full sm:max-h-[90vh]
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<span class="list-row__title">{{ t.title }}</span>
|
||||
<span class="list-row__meta">
|
||||
{{ t.author_full_name or '—' }}
|
||||
<span class="list-row__meta-tail"> · v{{ t.version }}</span>
|
||||
<span class="list-row__meta-tail"> · Версия {{ t.version }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@
|
||||
<span class="list-row__title">{{ t.title }}</span>
|
||||
<span class="list-row__meta">
|
||||
{{ t.author_full_name or '—' }}
|
||||
<span class="list-row__meta-tail"> · v{{ t.version }} · скрыт</span>
|
||||
<span class="list-row__meta-tail"> · Версия {{ t.version }} · скрыт</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1>
|
||||
<p class="mt-1 text-sm text-ink-500">Активные тесты, к которым у вас есть доступ.</p>
|
||||
<p class="mt-1 text-sm text-ink-500">Все активные тесты.</p>
|
||||
</div>
|
||||
<button id="btn-create-test"
|
||||
class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg
|
||||
@@ -80,13 +80,13 @@
|
||||
class="block p-4 active:bg-ink-100/40">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="font-semibold text-ink-900 line-clamp-2 min-w-0">{{ t.title }}</h3>
|
||||
<span class="text-xs text-ink-500 shrink-0 mt-0.5">v{{ t.version }}</span>
|
||||
<span class="text-xs text-ink-500 shrink-0 mt-0.5 whitespace-nowrap">Версия {{ t.version }}</span>
|
||||
</div>
|
||||
{% if t.description %}
|
||||
<p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500">
|
||||
<span class="truncate">Автор: {{ t.author_full_name or '—' }}</span>
|
||||
<span class="truncate">{{ t.author_full_name or '—' }}</span>
|
||||
<span class="inline-flex items-center gap-1 text-brand-700">
|
||||
<span class="material-symbols-outlined text-sm">edit_note</span>
|
||||
Открыть
|
||||
@@ -103,7 +103,7 @@
|
||||
{% if hidden %}
|
||||
<details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4">
|
||||
<summary class="cursor-pointer font-medium text-ink-700">
|
||||
Скрытые вами цепочки ({{ hidden|length }})
|
||||
Скрытые из каталога ({{ hidden|length }})
|
||||
</summary>
|
||||
<ul class="mt-3 space-y-2">
|
||||
{% for t in hidden %}
|
||||
|
||||
+166
-143
@@ -19,13 +19,16 @@ UI-страницы:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid as _uuid
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..auth.decorators import current_user, login_required
|
||||
from ..db import get_engine
|
||||
from ..db import get_session
|
||||
from ..messages import RU
|
||||
from ..models import Test, TestVersion, User
|
||||
from ..services.ai_editor import (
|
||||
HttpError as AiHttpError,
|
||||
check_test_quality,
|
||||
@@ -44,6 +47,9 @@ from ..services.draft_validator import LlmError
|
||||
from ..services.editor_content import HttpError as EditorHttpError, get_editor_content
|
||||
from ..services.test_attempt import (
|
||||
HttpError as AttemptHttpError,
|
||||
check_question_for_attempt,
|
||||
count_missing_hints,
|
||||
generate_missing_hints_for_test,
|
||||
get_attempt_review_for_user,
|
||||
get_play_content,
|
||||
list_test_attempts_for_author,
|
||||
@@ -63,10 +69,9 @@ log = logging.getLogger(__name__)
|
||||
tests_bp = Blueprint('tests', __name__)
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _stringify_uuids(d: dict) -> dict:
|
||||
"""Преобразует UUID-поля в строки для безопасной JSON-сериализации."""
|
||||
out = {}
|
||||
for k, v in d.items():
|
||||
if hasattr(v, 'hex') and not isinstance(v, (str, bytes)):
|
||||
@@ -76,26 +81,29 @@ def _stringify_uuids(d: dict) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _check_test_author_or_404(test_id: str, user_id: str) -> dict:
|
||||
"""Загружает {id, created_by}; 404 если нет, 403 если не автор."""
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text('SELECT id, created_by FROM tests WHERE id = :id'),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
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
|
||||
|
||||
|
||||
def _check_test_author_or_404(test_id: str, user_id: str) -> Test:
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
test = session.get(Test, tid) if tid else None
|
||||
if not test:
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
raise NotFound(RU['notFound'])
|
||||
if not is_test_author(row['created_by'], user_id):
|
||||
if not is_test_author(test.created_by, user_id):
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
raise Forbidden('Доступ запрещён.')
|
||||
return dict(row)
|
||||
return test
|
||||
|
||||
|
||||
# ─── JSON API ────────────────────────────────────────────────────────
|
||||
# ─── JSON API ────────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/', methods=['GET'])
|
||||
@tests_bp.route('/api/tests', methods=['GET'])
|
||||
@@ -127,58 +135,50 @@ def api_create_test():
|
||||
@login_required
|
||||
def api_test_summary(test_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active,
|
||||
t.created_by, t.created_at, t.updated_at,
|
||||
tv.id AS active_version_id, tv.version,
|
||||
u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
LEFT JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
||||
LEFT JOIN users u ON u.id = t.created_by
|
||||
WHERE t.id = :id
|
||||
"""
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not row:
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
if not tid:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
is_author = is_test_author(row['created_by'], user.id)
|
||||
if not row['chain_active'] and not is_author:
|
||||
test = (
|
||||
session.query(Test)
|
||||
.options(selectinload(Test.author), selectinload(Test.versions))
|
||||
.filter(Test.id == tid)
|
||||
.first()
|
||||
)
|
||||
if not test:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
is_author = is_test_author(test.created_by, user.id)
|
||||
if not test.is_active and not is_author:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
if not is_author:
|
||||
from ..services.test_access import user_has_test_access
|
||||
|
||||
acc = user_has_test_access(user.id, test_id)
|
||||
if not acc.ok:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
has_attempts = False
|
||||
with eng.connect() as conn:
|
||||
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||
active_version = next((v for v in test.versions if v.is_active), None)
|
||||
has_attempts = has_any_attempt_for_test(session, tid)
|
||||
|
||||
return jsonify(
|
||||
test={
|
||||
'id': str(row['id']),
|
||||
'title': row['title'],
|
||||
'description': row['description'],
|
||||
'passingThreshold': row['passing_threshold'],
|
||||
'chainActive': row['chain_active'],
|
||||
'activeVersionId': str(row['active_version_id']) if row['active_version_id'] else None,
|
||||
'version': row['version'],
|
||||
'createdAt': row['created_at'].isoformat() if row['created_at'] else None,
|
||||
'updatedAt': row['updated_at'].isoformat() if row['updated_at'] else None,
|
||||
'createdBy': str(row['created_by']) if row['created_by'] else None,
|
||||
'authorFullName': row['author_full_name'],
|
||||
'id': str(test.id),
|
||||
'title': test.title,
|
||||
'description': test.description,
|
||||
'passingThreshold': test.passing_threshold,
|
||||
'chainActive': test.is_active,
|
||||
'activeVersionId': str(active_version.id) if active_version else None,
|
||||
'version': active_version.version if active_version else None,
|
||||
'createdAt': test.created_at.isoformat() if test.created_at else None,
|
||||
'updatedAt': test.updated_at.isoformat() if test.updated_at else None,
|
||||
'createdBy': str(test.created_by) if test.created_by else None,
|
||||
'authorFullName': test.author.full_name if test.author else None,
|
||||
'hasAttempts': bool(has_attempts),
|
||||
},
|
||||
isAuthor=is_author,
|
||||
hasActiveVersion=row['active_version_id'] is not None,
|
||||
hasActiveVersion=active_version is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -186,54 +186,45 @@ def api_test_summary(test_id):
|
||||
@login_required
|
||||
def api_test_versions(test_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
with eng.connect() as conn:
|
||||
t = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at,
|
||||
t.description, u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
INNER JOIN users u ON u.id = t.created_by
|
||||
WHERE t.id = :id
|
||||
"""
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().first()
|
||||
if not t:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
if not is_test_author(t['created_by'], user.id):
|
||||
return jsonify(error='Доступ запрещён.'), 403
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
if not tid:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
rows = conn.execute(
|
||||
text(
|
||||
'SELECT id, version, is_active, parent_id, created_at '
|
||||
'FROM test_versions WHERE test_id = :id ORDER BY version'
|
||||
),
|
||||
{'id': test_id},
|
||||
).mappings().all()
|
||||
has_attempts = has_any_attempt_for_test(conn, test_id)
|
||||
test = (
|
||||
session.query(Test)
|
||||
.options(selectinload(Test.author), selectinload(Test.versions))
|
||||
.filter(Test.id == tid)
|
||||
.first()
|
||||
)
|
||||
if not test:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
if not is_test_author(test.created_by, user.id):
|
||||
return jsonify(error='Доступ запрещён.'), 403
|
||||
|
||||
has_attempts = has_any_attempt_for_test(session, tid)
|
||||
sorted_versions = sorted(test.versions, key=lambda v: v.version)
|
||||
|
||||
return jsonify(
|
||||
test={
|
||||
'id': str(t['id']),
|
||||
'title': t['title'],
|
||||
'description': t['description'],
|
||||
'chainActive': t['is_active'],
|
||||
'createdAt': t['created_at'].isoformat() if t['created_at'] else None,
|
||||
'updatedAt': t['updated_at'].isoformat() if t['updated_at'] else None,
|
||||
'createdBy': str(t['created_by']) if t['created_by'] else None,
|
||||
'authorFullName': t['author_full_name'],
|
||||
'id': str(test.id),
|
||||
'title': test.title,
|
||||
'description': test.description,
|
||||
'chainActive': test.is_active,
|
||||
'createdAt': test.created_at.isoformat() if test.created_at else None,
|
||||
'updatedAt': test.updated_at.isoformat() if test.updated_at else None,
|
||||
'createdBy': str(test.created_by) if test.created_by else None,
|
||||
'authorFullName': test.author.full_name if test.author else None,
|
||||
},
|
||||
versions=[
|
||||
{
|
||||
'id': str(r['id']),
|
||||
'version': r['version'],
|
||||
'is_active': r['is_active'],
|
||||
'parent_id': str(r['parent_id']) if r['parent_id'] else None,
|
||||
'created_at': r['created_at'].isoformat() if r['created_at'] else None,
|
||||
'id': str(v.id),
|
||||
'version': v.version,
|
||||
'is_active': v.is_active,
|
||||
'parent_id': str(v.parent_id) if v.parent_id else None,
|
||||
'created_at': v.created_at.isoformat() if v.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
for v in sorted_versions
|
||||
],
|
||||
hasAttempts=has_attempts,
|
||||
)
|
||||
@@ -267,22 +258,22 @@ def api_save_draft(test_id):
|
||||
def api_activate_version(test_id, version_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
v = conn.execute(
|
||||
text('SELECT id FROM test_versions WHERE test_id = :t AND id = :v'),
|
||||
{'t': test_id, 'v': version_id},
|
||||
).first()
|
||||
if not v:
|
||||
return jsonify(error='Версия не найдена.'), 404
|
||||
conn.execute(
|
||||
text('UPDATE test_versions SET is_active = false WHERE test_id = :t'),
|
||||
{'t': test_id},
|
||||
)
|
||||
conn.execute(
|
||||
text('UPDATE test_versions SET is_active = true WHERE id = :v'),
|
||||
{'v': version_id},
|
||||
)
|
||||
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
vid = _to_uuid(version_id)
|
||||
|
||||
version = session.query(TestVersion).filter(
|
||||
TestVersion.test_id == tid, TestVersion.id == vid
|
||||
).first()
|
||||
if not version:
|
||||
return jsonify(error='Версия не найдена.'), 404
|
||||
|
||||
session.query(TestVersion).filter(TestVersion.test_id == tid).update(
|
||||
{TestVersion.is_active: False}, synchronize_session='fetch'
|
||||
)
|
||||
version.is_active = True
|
||||
session.commit()
|
||||
return jsonify(ok=True, activeVersionId=str(version_id))
|
||||
|
||||
|
||||
@@ -294,15 +285,10 @@ def api_patch_test(test_id):
|
||||
chain = body.get('chainActive', body.get('isActive'))
|
||||
if not isinstance(chain, bool):
|
||||
return jsonify(error='Передайте chainActive: true/false в теле запроса.'), 400
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
eng = get_engine()
|
||||
with eng.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
'UPDATE tests SET is_active = :v, updated_at = CURRENT_TIMESTAMP WHERE id = :id'
|
||||
),
|
||||
{'v': chain, 'id': test_id},
|
||||
)
|
||||
test = _check_test_author_or_404(test_id, user.id)
|
||||
session = get_session()
|
||||
test.is_active = chain
|
||||
session.commit()
|
||||
return jsonify(id=test_id, chainActive=chain)
|
||||
|
||||
|
||||
@@ -310,9 +296,8 @@ def api_patch_test(test_id):
|
||||
@login_required
|
||||
def api_start_attempt(test_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
out = start_attempt(eng, user.id, test_id)
|
||||
out = start_attempt(None, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out), 201
|
||||
@@ -322,9 +307,8 @@ def api_start_attempt(test_id):
|
||||
@login_required
|
||||
def api_attempt_play(test_id, attempt_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
out = get_play_content(eng, user.id, test_id, attempt_id)
|
||||
out = get_play_content(None, user.id, test_id, attempt_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
@@ -334,10 +318,45 @@ def api_attempt_play(test_id, attempt_id):
|
||||
@login_required
|
||||
def api_attempt_submit(test_id, attempt_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
out = submit_attempt(eng, user.id, test_id, attempt_id, body.get('answers'))
|
||||
out = submit_attempt(None, user.id, test_id, attempt_id, body.get('answers'))
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/hints/status', methods=['GET'])
|
||||
@login_required
|
||||
def api_test_hints_status(test_id):
|
||||
out = count_missing_hints(None, test_id)
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/hints/generate', methods=['POST'])
|
||||
@login_required
|
||||
def api_test_hints_generate(test_id):
|
||||
user = current_user()
|
||||
try:
|
||||
out = generate_missing_hints_for_test(None, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/check', methods=['POST'])
|
||||
@login_required
|
||||
def api_attempt_check_question(test_id, attempt_id):
|
||||
user = current_user()
|
||||
body = request.get_json(silent=True) or {}
|
||||
qid = body.get('questionId')
|
||||
sel = body.get('selectedOptionIds') or []
|
||||
if not qid:
|
||||
return jsonify(error='questionId обязателен.'), 400
|
||||
if not isinstance(sel, list):
|
||||
return jsonify(error='selectedOptionIds должен быть массивом.'), 400
|
||||
try:
|
||||
out = check_question_for_attempt(None, user.id, test_id, attempt_id, qid, sel)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
@@ -347,9 +366,8 @@ def api_attempt_submit(test_id, attempt_id):
|
||||
@login_required
|
||||
def api_attempt_review(test_id, attempt_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
out = get_attempt_review_for_user(eng, user.id, test_id, attempt_id)
|
||||
out = get_attempt_review_for_user(None, user.id, test_id, attempt_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
@@ -359,9 +377,8 @@ def api_attempt_review(test_id, attempt_id):
|
||||
@login_required
|
||||
def api_attempts_list(test_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
rows = list_test_attempts_for_author(eng, user.id, test_id)
|
||||
rows = list_test_attempts_for_author(None, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(
|
||||
@@ -385,7 +402,7 @@ def api_attempts_list(test_id):
|
||||
)
|
||||
|
||||
|
||||
# ─── AI ──────────────────────────────────────────────────────────────
|
||||
# ─── AI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST'])
|
||||
@login_required
|
||||
@@ -418,16 +435,15 @@ def api_ai_generate_question(test_id):
|
||||
body.get('questionText') or '',
|
||||
body.get('optionsCount'),
|
||||
bool(body.get('hasMultipleAnswers')),
|
||||
mode=body.get('mode'),
|
||||
existing_options=body.get('existingOptions'),
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, **out)
|
||||
|
||||
|
||||
# ─── AI v2 (E1.8) ────────────────────────────────────────────────────
|
||||
|
||||
def _ai_error_response(e):
|
||||
"""Единый JSON-формат ошибки для AI-эндпоинтов."""
|
||||
if isinstance(e, AiHttpError):
|
||||
return jsonify(error=e.message), e.status
|
||||
if isinstance(e, LlmError):
|
||||
@@ -495,15 +511,12 @@ def api_ai_improve_test(test_id):
|
||||
return jsonify(ok=True, **out)
|
||||
|
||||
|
||||
# ─── Импорт документа (E1.3) ────────────────────────────────────────
|
||||
# ─── Импорт документа (E1.3) ─────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/import/document', methods=['POST'])
|
||||
@login_required
|
||||
def api_import_document():
|
||||
"""PDF/DOCX/TXT/MD → извлечённый текст + AI-черновик (если задан LLM-ключ).
|
||||
|
||||
Ограничения: размер файла — `MAX_CONTENT_LENGTH = 16 МБ` (см. фабрику).
|
||||
"""
|
||||
"""Шаг 1: загрузить файл и извлечь текст. Генерация — отдельным запросом."""
|
||||
f = request.files.get('file')
|
||||
if f is None or not f.filename:
|
||||
return jsonify(error='Прикрепите файл к полю file.'), 400
|
||||
@@ -515,7 +528,6 @@ def api_import_document():
|
||||
log.exception('extract_text_from_file failed')
|
||||
return jsonify(error='Не удалось разобрать файл.'), 500
|
||||
|
||||
generation = generation_for_import_document(extracted)
|
||||
return jsonify(
|
||||
received=True,
|
||||
originalName=f.filename,
|
||||
@@ -523,11 +535,23 @@ def api_import_document():
|
||||
size=len(extracted.encode('utf-8')),
|
||||
extractedText=extracted,
|
||||
textLength=len(extracted),
|
||||
generation=generation,
|
||||
)
|
||||
|
||||
|
||||
# ─── UI (Jinja) ──────────────────────────────────────────────────────
|
||||
@tests_bp.route('/api/tests/generate-from-extracted', methods=['POST'])
|
||||
@login_required
|
||||
def api_generate_from_extracted():
|
||||
"""Шаг 2: сгенерировать черновик теста из ранее извлечённого текста + подсказки автора."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
extracted = (body.get('extractedText') or '').strip()
|
||||
user_hint = (body.get('userHint') or '').strip()
|
||||
if not extracted:
|
||||
return jsonify(error='Нет текста для генерации.'), 400
|
||||
generation = generation_for_import_document(extracted, user_hint=user_hint)
|
||||
return jsonify(generation=generation)
|
||||
|
||||
|
||||
# ─── UI (Jinja) ───────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/tests', methods=['GET'])
|
||||
@login_required
|
||||
@@ -567,9 +591,8 @@ def tests_attempt_page(test_id, attempt_id):
|
||||
@login_required
|
||||
def tests_attempt_review_page(test_id, attempt_id):
|
||||
user = current_user()
|
||||
eng = get_engine()
|
||||
try:
|
||||
review = get_attempt_review_for_user(eng, user.id, test_id, attempt_id)
|
||||
review = get_attempt_review_for_user(None, user.id, test_id, attempt_id)
|
||||
except AttemptHttpError as e:
|
||||
if e.status == 404:
|
||||
return render_template('404.html'), 404
|
||||
|
||||
@@ -6,6 +6,7 @@ waitress>=3.0.0
|
||||
# SQLAlchemy + psycopg2 драйвер для PostgreSQL.
|
||||
SQLAlchemy>=2.0.0,<3
|
||||
psycopg2-binary>=2.9.0,<3
|
||||
alembic>=1.13.0,<2
|
||||
|
||||
# Этап 1 (E1.1): авторизация. bcrypt — для локальных хешей в clinic_tests.users.
|
||||
# Werkzeug-хеши (scrypt/pbkdf2) проверяет встроенный werkzeug.security.
|
||||
|
||||
Reference in New Issue
Block a user