Browse Source

блоки 2 и 3 доработки интерфейса системы тестирования

dev
Константин Лебединский 2 weeks ago
parent
commit
bba96f8f9f
  1. BIN
      .DS_Store
  2. 2
      docker-compose.dev.yml
  3. 43
      flask_app/alembic.ini
  4. 58
      flask_app/alembic/env.py
  5. 27
      flask_app/alembic/script.py.mako
  6. 30
      flask_app/alembic/versions/0001_baseline.py
  7. 6
      flask_app/app/__init__.py
  8. 72
      flask_app/app/auth/routes.py
  9. 249
      flask_app/app/auth/services.py
  10. 135
      flask_app/app/blueprints/main.py
  11. 37
      flask_app/app/blueprints/settings.py
  12. 5
      flask_app/app/config.py
  13. 111
      flask_app/app/data/prompts.json
  14. 104
      flask_app/app/db.py
  15. 315
      flask_app/app/models.py
  16. 105
      flask_app/app/services/ai_editor.py
  17. 5
      flask_app/app/services/document_gen.py
  18. 133
      flask_app/app/services/editor_content.py
  19. 9
      flask_app/app/services/llm_client.py
  20. 79
      flask_app/app/services/prompt_store.py
  21. 163
      flask_app/app/services/test_access.py
  22. 658
      flask_app/app/services/test_attempt.py
  23. 35
      flask_app/app/services/test_chain.py
  24. 336
      flask_app/app/services/test_draft.py
  25. 390
      flask_app/app/static/css/app.css
  26. 750
      flask_app/app/static/js/editor.js
  27. 169
      flask_app/app/templates/assignments.html
  28. 24
      flask_app/app/templates/auth/login.html
  29. 1
      flask_app/app/templates/base.html
  30. 91
      flask_app/app/templates/index.html
  31. 433
      flask_app/app/templates/settings_prompts.html
  32. 103
      flask_app/app/templates/stats.html
  33. 135
      flask_app/app/templates/tests/attempt.html
  34. 383
      flask_app/app/templates/tests/editor.html
  35. 12
      flask_app/app/templates/tests/list.html
  36. 299
      flask_app/app/tests/routes.py
  37. 1
      flask_app/requirements.txt

BIN
.DS_Store vendored

Binary file not shown.

2
docker-compose.dev.yml

@ -32,6 +32,7 @@ services:
HR_AUTH: ${HR_AUTH:-1} HR_AUTH: ${HR_AUTH:-1}
HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test} HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test}
UI_VARIANT: ${UI_VARIANT_MODERN:-modern} UI_VARIANT: ${UI_VARIANT_MODERN:-modern}
DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-}
# LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта. # LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта.
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
@ -59,6 +60,7 @@ services:
HR_AUTH: ${HR_AUTH:-1} HR_AUTH: ${HR_AUTH:-1}
HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test} HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test}
UI_VARIANT: ${UI_VARIANT_LEGACY:-legacy} UI_VARIANT: ${UI_VARIANT_LEGACY:-legacy}
DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-}
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_BASE_URL: ${LLM_BASE_URL:-}

43
flask_app/alembic.ini

@ -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

58
flask_app/alembic/env.py

@ -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()

27
flask_app/alembic/script.py.mako

@ -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"}

30
flask_app/alembic/versions/0001_baseline.py

@ -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

6
flask_app/app/__init__.py

@ -69,6 +69,12 @@ def create_app() -> Flask:
app.register_blueprint(tests_bp) app.register_blueprint(tests_bp)
app.register_blueprint(settings_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 .config import is_assignment_feature_enabled, is_dev_ui, is_hr_auth_enabled
from .auth.decorators import current_user as _current_user from .auth.decorators import current_user as _current_user

72
flask_app/app/auth/routes.py

@ -14,7 +14,7 @@ from flask import (
url_for, 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 ..messages import RU
from .decorators import login_required, current_user from .decorators import login_required, current_user
from .services import AuthError, authenticate_credentials from .services import AuthError, authenticate_credentials
@ -46,7 +46,11 @@ def _do_login(login: str, password: str):
def login_page(): def login_page():
if current_user() is not None: if current_user() is not None:
return redirect(_safe_next('/')) 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']) @auth_bp.route('/login', methods=['POST'])
@ -57,11 +61,21 @@ def login_submit():
_do_login(login, password) _do_login(login, password)
except AuthError as e: except AuthError as e:
flash(e.message, 'error') 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: except Exception:
log.exception('login_submit failed') log.exception('login_submit failed')
flash(RU['loginFailed'], 'error') 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('/')) return redirect(_safe_next('/'))
@ -96,6 +110,56 @@ def api_logout():
return jsonify(message=RU['loggedOut']) 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']) @auth_bp.route('/api/auth/me', methods=['GET'])
@login_required @login_required
def api_me(): def api_me():

249
flask_app/app/auth/services.py

@ -1,16 +1,13 @@
"""Бизнес-логика логина — порт `backend/src/routes/auth.js` + `utils/auth.js`. """Бизнес-логика логина (ORM-версия).
Поддерживает оба режима: Поддерживает оба режима:
- Локальный (по умолчанию): bcrypt в `clinic_tests.users.password_hash`. - Локальный (по умолчанию): bcrypt / Werkzeug в `clinic_tests.users.password_hash`.
- HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (Werkzeug scrypt/pbkdf2), - HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (raw SQL, внешняя схема),
затем находим запись `staff_members` по `web_login` и UPSERT-им в затем UPSERT в `clinic_tests.users` по `staff_id`.
`clinic_tests.users` по `staff_id`.
Werkzeug-хеши проверяются через `werkzeug.security.check_password_hash`,
bcrypt-хеши через пакет `bcrypt`.
""" """
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@ -20,16 +17,18 @@ from werkzeug.security import check_password_hash as _werkzeug_check
from ..config import ( from ..config import (
HR_MANAGED_PASSWORD_PLACEHOLDER, HR_MANAGED_PASSWORD_PLACEHOLDER,
get_dev_fio_password,
is_hr_auth_enabled, is_hr_auth_enabled,
) )
from ..db import get_engine, get_hr_engine from ..db import get_engine, get_session
from ..messages import RU from ..messages import RU
from ..models import User
from .hr_role import map_hr_role_to_app from .hr_role import map_hr_role_to_app
@dataclass @dataclass
class AuthUser: class AuthUser:
id: str # UUID в виде строки id: str
login: str login: str
full_name: str | None full_name: str | None
role: str role: str
@ -37,20 +36,17 @@ class AuthUser:
staff_id: Optional[int] staff_id: Optional[int]
def to_public_dict(self) -> dict: def to_public_dict(self) -> dict:
out = { return {
'id': str(self.id), 'id': str(self.id),
'login': self.login, 'login': self.login,
'fullName': self.full_name, 'fullName': self.full_name,
'role': self.role, '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): class AuthError(Exception):
"""Ошибка авторизации с HTTP-кодом и сообщением для пользователя."""
def __init__(self, status: int, message: str) -> None: def __init__(self, status: int, message: str) -> None:
super().__init__(message) super().__init__(message)
self.status = status self.status = status
@ -58,7 +54,6 @@ class AuthError(Exception):
def _verify_password(plain: str, hashed: str | None) -> bool: def _verify_password(plain: str, hashed: str | None) -> bool:
"""Универсальная проверка пароля: bcrypt + Werkzeug (scrypt/pbkdf2)."""
if not hashed: if not hashed:
return False return False
if hashed == HR_MANAGED_PASSWORD_PLACEHOLDER: if hashed == HR_MANAGED_PASSWORD_PLACEHOLDER:
@ -79,53 +74,136 @@ def _verify_password(plain: str, hashed: str | None) -> bool:
return False 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: def authenticate_credentials(login: str, password: str) -> AuthUser:
"""Главная точка входа. Возвращает AuthUser или поднимает AuthError."""
login = (login or '').strip() login = (login or '').strip()
password = password or '' password = password or ''
if not login or not password: if not login or not password:
raise AuthError(400, RU['loginAndPasswordRequired']) 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(): if is_hr_auth_enabled():
return _authenticate_via_hr(login, password) return _authenticate_via_hr(login, password)
return _authenticate_local(login, password) return _authenticate_local(login, password)
# ─── локальный режим ──────────────────────────────────────────────── def _authenticate_dev_fio(fio_raw: str) -> AuthUser:
"""Dev-режим: найти сотрудника по ФИО в HR-системе и создать/обновить локального пользователя."""
def _authenticate_local(login: str, password: str) -> AuthUser: from ..db import get_hr_engine
eng = get_engine() hr_eng = get_hr_engine()
with eng.connect() as conn: if hr_eng is None:
row = conn.execute( raise AuthError(500, RU['hrDatabaseUrlMissing'])
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: fio_needle = _normalize_fio(fio_raw)
if not fio_needle:
raise AuthError(401, RU['invalidCredentials']) raise AuthError(401, RU['invalidCredentials'])
if row['password_hash'] == HR_MANAGED_PASSWORD_PLACEHOLDER: needle = fio_needle.lower()
raise AuthError(401, RU['useHrLogin'])
if not _verify_password(password, row['password_hash']): 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']) raise AuthError(401, RU['invalidCredentials'])
s = rows[0]
return AuthUser( web_login = (s['web_login'] or '').strip() or f'fio_{s["id"]}'
id=str(row['id']),
login=row['login'], staff_id = int(s['id'])
full_name=row['full_name'], full_name = s['fio'] or fio_raw
role=row['role'],
department_id=row['department_id'], session = get_session()
staff_id=row['staff_id'],
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:
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: def _authenticate_via_hr(login: str, password: str) -> AuthUser:
from ..db import get_hr_engine
hr_eng = get_hr_engine() hr_eng = get_hr_engine()
if hr_eng is None: if hr_eng is None:
raise AuthError(500, RU['hrDatabaseUrlMissing']) raise AuthError(500, RU['hrDatabaseUrlMissing'])
@ -157,61 +235,48 @@ def _authenticate_via_hr(login: str, password: str) -> AuthUser:
fio = s['fio'] or login fio = s['fio'] or login
app_role = map_hr_role_to_app(u['role']) app_role = map_hr_role_to_app(u['role'])
eng = get_engine() # UPSERT через ORM: ищем по staff_id, затем по login, затем создаём
with eng.begin() as conn: session = get_session()
row = conn.execute( user = session.query(User).filter(User.staff_id == staff_id).first()
text( if not user:
""" # при первом входе staff_id ещё не проставлен — ищем по login
INSERT INTO users (login, password_hash, full_name, role, user = session.query(User).filter(User.login == login).first()
department_id, is_active, staff_id) if user:
VALUES (:login, :ph, :fn, :role, NULL, true, :staff_id) user.login = login
ON CONFLICT (staff_id) DO UPDATE SET user.full_name = fio
login = EXCLUDED.login, user.role = app_role
full_name = EXCLUDED.full_name, user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER
role = EXCLUDED.role, user.staff_id = staff_id
password_hash = EXCLUDED.password_hash user.is_active = True
RETURNING id, login, full_name, role, department_id, staff_id else:
""" user = User(
), login=login,
{ password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER,
'login': login, full_name=fio,
'ph': HR_MANAGED_PASSWORD_PLACEHOLDER, role=app_role,
'fn': fio, is_active=True,
'role': app_role, staff_id=staff_id,
'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'],
) )
session.add(user)
session.commit()
session.refresh(user)
return _user_to_auth(user)
def load_user_by_id(user_id: str) -> Optional[AuthUser]: def load_user_by_id(user_id: str) -> Optional[AuthUser]:
"""Догружает пользователя из `clinic_tests.users` (используется при каждом запросе)."""
if not user_id: if not user_id:
return None return None
eng = get_engine() session = get_session()
with eng.connect() as conn: try:
row = conn.execute( import uuid as _uuid
text( uid = _uuid.UUID(user_id)
'SELECT id, login, full_name, role, department_id, staff_id ' except (ValueError, AttributeError):
'FROM users WHERE id = :id AND is_active = true'
),
{'id': user_id},
).mappings().first()
if not row:
return None return None
return AuthUser( user = (
id=str(row['id']), session.query(User)
login=row['login'], .filter(User.id == uid, User.is_active.is_(True))
full_name=row['full_name'], .first()
role=row['role'],
department_id=row['department_id'],
staff_id=row['staff_id'],
) )
if not user:
return None
return _user_to_auth(user)

135
flask_app/app/blueprints/main.py

@ -1,18 +1,117 @@
"""Главный blueprint — посадочная страница и health-чек. """Главный blueprint — посадочная страница, статистика и health-чек."""
В E1.0 здесь нет бизнес-логики; страницы и эндпоинты добавляются в следующих
спринтах (E1.1 auth, E1.2 тесты, и т.д.).
"""
from __future__ import annotations from __future__ import annotations
from flask import Blueprint, jsonify, render_template from flask import Blueprint, jsonify, render_template
from .. import db as app_db from .. import db as app_db
from ..auth.decorators import login_required from ..auth.decorators import login_required
from ..auth.decorators import current_user as get_current_user
main_bp = Blueprint('main', __name__) 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') @main_bp.route('/health')
def health(): def health():
"""Smoke-проверка приложения и подключений к БД (без авторизации).""" """Smoke-проверка приложения и подключений к БД (без авторизации)."""
@ -29,3 +128,29 @@ def health():
@login_required @login_required
def index(): def index():
return render_template('index.html') 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
])

37
flask_app/app/blueprints/settings.py

@ -1,15 +1,11 @@
"""Страница настроек: статус LLM-ключа и проверка подключения (E1.8). """Настройки: LLM-статус, пинг и редактор промптов."""
Ключ общий, читается из ENV (`DEEPSEEK_API_KEY` / `OPENAI_API_KEY`). Здесь
только просмотр статуса и smoke-проверка. Изменение ключа через `.env` и
рестарт процесса.
"""
from __future__ import annotations 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 ..auth.decorators import login_required
from ..services.llm_client import get_llm_config, ping_llm from ..services.llm_client import get_llm_config, ping_llm
from ..services import prompt_store
settings_bp = Blueprint('settings', __name__) 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']) @settings_bp.route('/api/llm/ping', methods=['POST', 'GET'])
@login_required @login_required
def api_llm_ping(): def api_llm_ping():
return jsonify(ping_llm()) 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)

5
flask_app/app/config.py

@ -37,3 +37,8 @@ def is_dev_ui() -> bool:
if (os.environ.get('FLASK_ENV') or '').lower() == 'development': if (os.environ.get('FLASK_ENV') or '').lower() == 'development':
return True return True
return (os.environ.get('FLASK_DEBUG') or '').strip() == '1' 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

111
flask_app/app/data/prompts.json

@ -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": "Выбранные пользователем варианты"
}
}
}

104
flask_app/app/db.py

@ -1,7 +1,7 @@
"""Подключение к PostgreSQL — тот же паттерн, что в HR_TG_Bot/tgFlaskForm/db/session.py. """Подключение к PostgreSQL и ORM-сессии.
В Этапе 1 работаем с БД `clinic_tests` (схема не меняется). Опционально доступна Основная БД `clinic_tests`.
вторая БД `hr_bot_test` для HR-аутентификации (см. .env.example, флаг HR_AUTH). Опциональная вторая БД `hr_bot_test` (когда HR_AUTH=1).
""" """
from __future__ import annotations from __future__ import annotations
@ -11,54 +11,50 @@ from typing import Optional
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import Session, scoped_session, sessionmaker
from sqlalchemy.pool import QueuePool from sqlalchemy.pool import QueuePool
_lock = threading.Lock() _engine_lock = threading.Lock()
_session_lock = threading.Lock()
_hr_engine_lock = threading.Lock()
_engine: Optional[Engine] = None _engine: Optional[Engine] = None
_session_local: Optional[sessionmaker] = None _session_factory: Optional[scoped_session] = None
_hr_engine: Optional[Engine] = None _hr_engine: Optional[Engine] = None
_hr_session_local: Optional[sessionmaker] = None
def get_database_url() -> str: # ─── URL helpers ─────────────────────────────────────────────────────────────
"""URL основной БД (`clinic_tests`).
Приоритет: DATABASE_URL отдельные DB_*-переменные. def get_database_url() -> str:
"""
if db_url := os.environ.get('DATABASE_URL'): if db_url := os.environ.get('DATABASE_URL'):
return db_url.strip() return db_url.strip()
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}'
db_host = os.environ.get('DB_HOST', 'localhost')
db_port = os.environ.get('DB_PORT', '5432') def _hr_auth_enabled() -> bool:
db_name = os.environ.get('DB_NAME', 'clinic_tests') return (os.environ.get('HR_AUTH') or '').strip().lower() in ('1', 'true', 'yes', 'on')
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]: def get_hr_database_url() -> Optional[str]:
"""URL БД HR (`hr_bot_test`) — только если включён HR_AUTH."""
if not _hr_auth_enabled(): if not _hr_auth_enabled():
return None return None
if url := os.environ.get('HR_DATABASE_URL'): url = (os.environ.get('HR_DATABASE_URL') or '').strip()
return url.strip() return url or None
return None
def _hr_auth_enabled() -> bool: # ─── Main engine ─────────────────────────────────────────────────────────────
val = (os.environ.get('HR_AUTH') or '').strip().lower()
return val in ('1', 'true', 'yes', 'on')
def get_engine() -> Engine: def get_engine() -> Engine:
"""Возвращает общий engine основной БД (singleton на процесс)."""
global _engine global _engine
if _engine is not None: if _engine is not None:
return _engine return _engine
with _lock: with _engine_lock:
if _engine is not None: if _engine is None:
return _engine
_engine = create_engine( _engine = create_engine(
get_database_url(), get_database_url(),
poolclass=QueuePool, poolclass=QueuePool,
@ -69,18 +65,31 @@ def get_engine() -> Engine:
return _engine return _engine
def get_session(): # ─── Scoped 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()
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]: def get_hr_engine() -> Optional[Engine]:
"""Engine для HR-БД. Возвращает None, если HR_AUTH не включён."""
if not _hr_auth_enabled(): if not _hr_auth_enabled():
return None return None
global _hr_engine global _hr_engine
@ -89,9 +98,8 @@ def get_hr_engine() -> Optional[Engine]:
url = get_hr_database_url() url = get_hr_database_url()
if not url: if not url:
return None return None
with _lock: with _hr_engine_lock:
if _hr_engine is not None: if _hr_engine is None:
return _hr_engine
_hr_engine = create_engine( _hr_engine = create_engine(
url, url,
poolclass=QueuePool, poolclass=QueuePool,
@ -102,21 +110,9 @@ def get_hr_engine() -> Optional[Engine]:
return _hr_engine return _hr_engine
def get_hr_session(): # ─── Smoke check ─────────────────────────────────────────────────────────────
"""Сессия для 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()
def ping() -> dict: def ping() -> dict:
"""Smoke-проверка подключения к БД (используется в /health)."""
out: dict = {'main': 'unknown'} out: dict = {'main': 'unknown'}
try: try:
with get_engine().connect() as conn: with get_engine().connect() as conn:

315
flask_app/app/models.py

@ -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")

105
flask_app/app/services/ai_editor.py

@ -290,13 +290,80 @@ def improve_test_full(test_title: str, test_description: str, questions: list[di
return {'items': items} 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( def generate_or_rephrase_question(
test_title: str, test_title: str,
test_description: str, test_description: str,
question_text: str, question_text: str,
options_count: Any, options_count: Any,
has_multiple_answers: bool, has_multiple_answers: bool,
mode: str | None = None,
existing_options: list[dict] | None = None,
) -> dict: ) -> dict:
import json as _json
cfg = _require_cfg() cfg = _require_cfg()
try: try:
n = int(float(options_count)) 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() topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip()
qt = (question_text or '').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: if qt:
system = ( system = (
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — ' 'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — '
@ -325,6 +429,7 @@ def generate_or_rephrase_question(
raise LlmError('Пустой text в ответе модели.', code='llm_shape') raise LlmError('Пустой text в ответе модели.', code='llm_shape')
return {'mode': 'rephrase', 'text': text} return {'mode': 'rephrase', 'text': text}
# ── Полная генерация: вопрос пуст ────────────────────────────────────────
system = ( system = (
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", ' 'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", '
'"options": [{ "text", "isCorrect" }]}. Все на русском.' '"options": [{ "text", "isCorrect" }]}. Все на русском.'

5
flask_app/app/services/document_gen.py

@ -11,7 +11,7 @@ from .llm_client import LlmError, chat_completion_text_content, get_llm_config
MAX_EXTRACT_CHARS = 14000 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() text = (extracted_text or '').strip()
if not text: if not text:
return { return {
@ -41,9 +41,10 @@ def generation_for_import_document(extracted_text: str) -> dict:
'Для одиночного выбора ровно один isCorrect: true. ' 'Для одиночного выбора ровно один isCorrect: true. '
'Текст и формулировки — на русском, по содержанию входного материала.' 'Текст и формулировки — на русском, по содержанию входного материала.'
) )
hint_block = f'\n\nДополнительные инструкции от автора теста:\n{user_hint.strip()}' if user_hint and user_hint.strip() else ''
user = ( user = (
'Составь тест с вопросами с одним или несколькими правильными ответами ' 'Составь тест с вопросами с одним или несколькими правильными ответами '
'на основе текста:\n\n' + slice_ 'на основе текста:\n\n' + slice_ + hint_block
) )
raw = chat_completion_text_content(cfg, system, user, 0.25) raw = chat_completion_text_content(cfg, system, user, 0.25)
parsed = parse_json_from_llm_text(raw) parsed = parse_json_from_llm_text(raw)

133
flask_app/app/services/editor_content.py

@ -1,14 +1,14 @@
"""Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами. """Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами."""
Порт `getEditorContent` + `loadQuestionsForVersion` (только includeCorrect=true вариант)
из `services/testAttemptService.js`.
"""
from __future__ import annotations from __future__ import annotations
from sqlalchemy import text import uuid as _uuid
from sqlalchemy import func
from sqlalchemy.orm import selectinload
from ..db import get_engine from ..db import get_session
from ..messages import RU from ..messages import RU
from ..models import AnswerOption, Question, Test, TestVersion
from .test_access import is_test_author from .test_access import is_test_author
from .test_chain import has_any_attempt_for_test from .test_chain import has_any_attempt_for_test
@ -20,86 +20,85 @@ class HttpError(Exception):
self.message = message self.message = message
def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]: def load_questions_for_version(session, test_version_id, *, include_correct: bool) -> list[dict]:
qrows = conn.execute( if not isinstance(test_version_id, _uuid.UUID):
text( try:
'SELECT id, text, question_order, has_multiple_answers ' test_version_id = _uuid.UUID(str(test_version_id))
'FROM questions WHERE test_version_id = :v ORDER BY question_order' except (ValueError, AttributeError):
), return []
{'v': test_version_id},
).mappings().all() questions = (
session.query(Question)
.options(selectinload(Question.options))
.filter(Question.test_version_id == test_version_id)
.order_by(Question.question_order)
.all()
)
out = [] out = []
for r in qrows: for q in questions:
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()
options = [] options = []
for o in orows: for o in sorted(q.options, key=lambda x: x.option_order):
base = { base = {
'id': str(o['id']), 'id': str(o.id),
'text': o['text'], 'text': o.text,
'optionOrder': o['option_order'], 'optionOrder': o.option_order,
} }
if include_correct: if include_correct:
base['isCorrect'] = bool(o['is_correct']) base['isCorrect'] = bool(o.is_correct)
options.append(base) options.append(base)
out.append( out.append({
{ 'id': str(q.id),
'id': str(r['id']), 'text': q.text,
'text': r['text'], 'questionOrder': q.question_order,
'questionOrder': r['question_order'], 'hasMultipleAnswers': bool(q.has_multiple_answers),
'hasMultipleAnswers': bool(r['has_multiple_answers']),
'options': options, 'options': options,
} })
)
return out return out
def get_editor_content(user_id: str, test_id: str) -> dict: def get_editor_content(user_id: str, test_id: str) -> dict:
eng = get_engine() session = get_session()
with eng.connect() as conn: try:
tr = conn.execute( tid = _uuid.UUID(test_id)
text( except (ValueError, AttributeError):
'SELECT id, title, description, passing_threshold, created_by '
'FROM tests WHERE id = :id'
),
{'id': test_id},
).mappings().first()
if not tr:
raise HttpError(404, 'Тест не найден.') raise HttpError(404, 'Тест не найден.')
if not is_test_author(tr['created_by'], user_id):
test = session.get(Test, tid)
if not test:
raise HttpError(404, 'Тест не найден.')
if not is_test_author(test.created_by, user_id):
raise HttpError(403, 'Доступ запрещён.') raise HttpError(403, 'Доступ запрещён.')
tv = conn.execute(
text( active_version = (
'SELECT id FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1' session.query(TestVersion)
), .filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
{'id': test_id}, .first()
).mappings().first() )
if not tv: if not active_version:
raise HttpError(400, 'Нет активной версии теста.') raise HttpError(400, 'Нет активной версии теста.')
version_id = tv['id']
version_count_row = conn.execute( version_count = (
text('SELECT COUNT(*) AS n FROM test_versions WHERE test_id = :id'), session.query(func.count(TestVersion.id))
{'id': test_id}, .filter(TestVersion.test_id == tid)
).mappings().first() .scalar() or 0
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) questions = load_questions_for_version(session, active_version.id, include_correct=True)
has_attempts = has_any_attempt_for_test(session, tid)
return { return {
'test': { 'test': {
'id': str(tr['id']), 'id': str(test.id),
'title': tr['title'], 'title': test.title,
'description': tr['description'], 'description': test.description,
'passingThreshold': tr['passing_threshold'], 'passingThreshold': test.passing_threshold,
'timeLimit': test.time_limit,
'hintsEnabled': bool(test.hints_enabled),
'resultMode': test.result_mode or 'end',
'hasAttempts': bool(has_attempts), 'hasAttempts': bool(has_attempts),
'versionCount': version_count, 'versionCount': version_count,
'hasForkRisk': bool(has_attempts) or version_count > 1, 'hasForkRisk': bool(has_attempts) or version_count > 1,
}, },
'activeVersionId': str(version_id), 'activeVersionId': str(active_version.id),
'questions': questions, 'questions': questions,
} }

9
flask_app/app/services/llm_client.py

@ -51,8 +51,13 @@ def chat_completion_text_content(
user: str, user: str,
temperature: float = 0.25, temperature: float = 0.25,
timeout: int = 120, timeout: int = 120,
as_json: bool = True,
) -> str: ) -> str:
"""Возвращает `assistant.message.content` (строку).""" """Возвращает `assistant.message.content` (строку).
`as_json=True` (по умолчанию) включает `response_format: json_object`. Для свободного
текста (например, объяснения к вопросу) передайте `as_json=False`.
"""
body: dict = { body: dict = {
'model': cfg.model, 'model': cfg.model,
'messages': [ 'messages': [
@ -61,7 +66,7 @@ def chat_completion_text_content(
], ],
'temperature': temperature, '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'} body['response_format'] = {'type': 'json_object'}
req = urllib.request.Request( req = urllib.request.Request(

79
flask_app/app/services/prompt_store.py

@ -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)

163
flask_app/app/services/test_access.py

@ -1,15 +1,15 @@
"""Кто видит тест: автор + назначенные пользователи (порт `services/testAccessService.js`).""" """Кто видит тест: автор + назначенные пользователи."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass 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: def is_test_author(created_by, user_id) -> bool:
"""`tests.created_by` — UUID. Сравниваем по строковому представлению."""
if created_by is None or user_id is None: if created_by is None or user_id is None:
return False return False
return str(created_by) == str(user_id) 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: def user_has_test_access(user_id: str, test_id: str) -> AccessResult:
eng = get_engine() import uuid as _uuid
with eng.connect() as conn: session = get_session()
row = conn.execute( try:
text('SELECT created_by FROM tests WHERE id = :id'), tid = _uuid.UUID(test_id)
{'id': test_id}, uid = _uuid.UUID(user_id)
).mappings().first() except (ValueError, AttributeError):
if not row:
return AccessResult(ok=False, is_author=False, not_found=True) return AccessResult(ok=False, is_author=False, not_found=True)
if is_test_author(row['created_by'], user_id):
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) return AccessResult(ok=True, is_author=True, not_found=False)
ar = conn.execute(
text( assigned = session.query(
""" exists().where(
SELECT 1 TestAssignmentTarget.target_type == 'user',
FROM test_assignments ta TestAssignmentTarget.target_id == uid,
INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id TestAssignmentTarget.assignment_id == TestAssignment.id,
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id TestAssignment.test_version_id == TestVersion.id,
WHERE tv_a.test_id = :test_id TestVersion.test_id == tid,
AND tat.target_type = 'user' )
AND tat.target_id = :user_id ).scalar()
LIMIT 1
""" return AccessResult(ok=bool(assigned), is_author=False, not_found=False)
),
{'test_id': test_id, 'user_id': user_id},
).first()
return AccessResult(ok=ar is not None, is_author=False, not_found=False)
def list_visible_tests(user_id: str) -> list[dict]: def list_visible_tests(user_id: str) -> list[dict]:
"""Каталог: только активная цепочка + (автор OR назначен).""" """В dev-режиме возвращает все активные тесты независимо от назначения."""
eng = get_engine() session = get_session()
with eng.connect() as conn:
rows = conn.execute( rows = (
text( session.query(Test, TestVersion, User)
""" .join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
SELECT DISTINCT t.id, t.title, t.description, t.is_active AS chain_active, .outerjoin(User, User.id == Test.created_by)
t.created_at, t.updated_at, .filter(Test.is_active.is_(True))
tv.id AS active_version_id, tv.version, .order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
t.created_by, u.full_name AS author_full_name .all()
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
""" return [
), {
{'uid': user_id}, 'id': str(t.id),
).mappings().all() 'title': t.title,
return [dict(r) for r in rows] '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]: def list_hidden_by_author(user_id: str) -> list[dict]:
"""Скрытые автором цепочки (`is_active = false`) — видны только автору.""" import uuid as _uuid
eng = get_engine() session = get_session()
with eng.connect() as conn: try:
rows = conn.execute( uid = _uuid.UUID(user_id)
text( except (ValueError, AttributeError):
""" return []
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, rows = (
t.created_by, u.full_name AS author_full_name session.query(Test, TestVersion, User)
FROM tests t .join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true .join(User, User.id == Test.created_by)
INNER JOIN users u ON u.id = t.created_by .filter(Test.is_active.is_(False), Test.created_by == uid)
WHERE t.is_active = false AND t.created_by = :uid .order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC .all()
""" )
),
{'uid': user_id}, return [
).mappings().all() {
return [dict(r) for r in rows] '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
]

658
flask_app/app/services/test_attempt.py

@ -1,8 +1,23 @@
"""Сервис прохождения теста."""
from __future__ import annotations 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): class HttpError(Exception):
@ -17,212 +32,218 @@ def _sort_uuid_strings(items) -> list[str]:
def _same_selection(selected, correct_ids) -> bool: def _same_selection(selected, correct_ids) -> bool:
a = _sort_uuid_strings(selected) return _sort_uuid_strings(selected) == _sort_uuid_strings(correct_ids)
b = _sort_uuid_strings(correct_ids)
return a == b
def _to_uuid(val) -> _uuid.UUID | None:
if isinstance(val, _uuid.UUID):
def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]: return val
qrows = conn.execute( try:
text( return _uuid.UUID(str(val))
'SELECT id, text, question_order, has_multiple_answers ' except (ValueError, AttributeError):
'FROM questions WHERE test_version_id = :v ORDER BY question_order' return None
),
{'v': test_version_id},
).mappings().all() # ─── 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 = [] out = []
for q in qrows: for q in questions:
orows = conn.execute( options = []
text( for o in sorted(q.options, key=lambda x: x.option_order):
'SELECT id, text, is_correct, option_order ' base = {'id': str(o.id), 'text': o.text, 'optionOrder': o.option_order}
'FROM answer_options WHERE question_id = :q ORDER BY option_order'
),
{'q': q['id']},
).mappings().all()
opts = []
for o in orows:
base = {
'id': str(o['id']),
'text': o['text'],
'optionOrder': o['option_order'],
}
if include_correct: if include_correct:
base['isCorrect'] = bool(o['is_correct']) base['isCorrect'] = bool(o.is_correct)
opts.append(base) options.append(base)
out.append( out.append({
{ 'id': str(q.id),
'id': str(q['id']), 'text': q.text,
'text': q['text'], 'questionOrder': q.question_order,
'questionOrder': q['question_order'], 'hasMultipleAnswers': bool(q.has_multiple_answers),
'hasMultipleAnswers': bool(q['has_multiple_answers']), 'options': options,
'options': opts, })
}
)
return out 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) acc = user_has_test_access(user_id, test_id)
if not acc.ok: if not acc.ok:
raise HttpError(404, 'Тест не найден.') raise HttpError(404, 'Тест не найден.')
with eng.begin() as conn:
tv = conn.execute( session = get_session()
text( tid = _to_uuid(test_id)
'SELECT id AS test_version_id FROM test_versions ' uid = _to_uuid(user_id)
'WHERE test_id = :id AND is_active = true LIMIT 1'
), active_version = (
{'id': test_id}, session.query(TestVersion)
).mappings().first() .filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
if not tv: .first()
)
if not active_version:
raise HttpError(404, 'Нет активной версии теста.') raise HttpError(404, 'Нет активной версии теста.')
version_id = tv['test_version_id']
mx = conn.execute( max_n = (
text( session.query(func.coalesce(func.max(TestAttempt.attempt_number), 0))
'SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts ' .filter(
'WHERE test_version_id = :v AND user_id = :u' TestAttempt.test_version_id == active_version.id,
), TestAttempt.user_id == uid,
{'v': version_id, 'u': user_id}, )
).mappings().first() .scalar() or 0
next_n = int(mx['n'] or 0) + 1 )
a = conn.execute( attempt = TestAttempt(
text( test_version_id=active_version.id,
"INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status) " user_id=uid,
"VALUES (:v, :u, :n, 'in_progress') " attempt_number=int(max_n) + 1,
'RETURNING id, test_version_id, user_id, attempt_number, status, started_at' status='in_progress',
), )
{'v': version_id, 'u': user_id, 'n': next_n}, session.add(attempt)
).mappings().first() session.commit()
return {'attempt': dict(a)} session.refresh(attempt)
return {
'attempt': {
def get_play_content(eng, user_id: str, test_id: str, attempt_id: str) -> dict: 'id': str(attempt.id),
with eng.connect() as conn: 'test_version_id': str(attempt.test_version_id),
a = conn.execute( 'user_id': str(attempt.user_id),
text( 'attempt_number': attempt.attempt_number,
'SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, ' 'status': attempt.status,
't.title, t.passing_threshold ' 'started_at': attempt.started_at.isoformat() if attempt.started_at else None,
'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'
), # ─── play ────────────────────────────────────────────────────────────────────
{'a': attempt_id},
).mappings().first() def get_play_content(session_or_eng, user_id: str, test_id: str, attempt_id: str) -> dict:
if not a: 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, 'Попытка не найдена.') raise HttpError(404, 'Попытка не найдена.')
if str(a['test_id']) != str(test_id): if attempt.test_version.test_id != tid:
raise HttpError(404, 'Попытка не найдена.') raise HttpError(404, 'Попытка не найдена.')
if str(a['user_id']) != str(user_id): if attempt.user_id != uid:
raise HttpError(403, 'Доступ запрещён.') raise HttpError(403, 'Доступ запрещён.')
if a['status'] != 'in_progress': if attempt.status != 'in_progress':
raise HttpError(400, 'Попытка уже завершена.') raise HttpError(400, 'Попытка уже завершена.')
qs = load_questions_for_version(conn, a['test_version_id'], include_correct=False)
test = attempt.test_version.test
qs = load_questions_for_version(session, attempt.test_version_id, include_correct=False)
return { return {
'testTitle': a['title'], 'testTitle': test.title,
'passingThreshold': a['passing_threshold'], 'passingThreshold': test.passing_threshold,
'attemptId': str(a['id']), 'timeLimit': test.time_limit,
'hintsEnabled': bool(test.hints_enabled),
'resultMode': test.result_mode or 'end',
'attemptId': str(attempt.id),
'questions': qs, '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 {} answers = raw_answers if isinstance(raw_answers, dict) else {}
with eng.begin() as conn: session = get_session()
a = conn.execute( aid = _to_uuid(attempt_id)
text( uid = _to_uuid(user_id)
'SELECT id, user_id, status, test_version_id ' tid = _to_uuid(test_id)
'FROM test_attempts WHERE id = :a FOR UPDATE'
), attempt = (
{'a': attempt_id}, session.query(TestAttempt)
).mappings().first() .options(selectinload(TestAttempt.test_version).selectinload(TestVersion.test))
if not a: .filter(TestAttempt.id == aid)
.with_for_update()
.first()
)
if not attempt:
raise HttpError(404, 'Попытка не найдена.') raise HttpError(404, 'Попытка не найдена.')
link = conn.execute( if attempt.test_version.test_id != tid:
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, 'Попытка не найдена.') raise HttpError(404, 'Попытка не найдена.')
if str(a['user_id']) != str(user_id): if attempt.user_id != uid:
raise HttpError(403, 'Доступ запрещён.') raise HttpError(403, 'Доступ запрещён.')
if a['status'] != 'in_progress': if attempt.status != 'in_progress':
raise HttpError(400, 'Попытка уже завершена.') raise HttpError(400, 'Попытка уже завершена.')
qrows = conn.execute( test = attempt.test_version.test
text('SELECT id FROM questions WHERE test_version_id = :v'), questions = (
{'v': a['test_version_id']}, session.query(Question)
).mappings().all() .options(selectinload(Question.options))
if not qrows: .filter(Question.test_version_id == attempt.test_version_id)
.all()
)
if not questions:
raise HttpError(400, 'В тесте нет вопросов.') raise HttpError(400, 'В тесте нет вопросов.')
opts = conn.execute( by_q: dict[str, dict] = {}
text( for q in questions:
'SELECT a.id, a.question_id, a.is_correct ' qid = str(q.id)
'FROM answer_options a ' 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]}
'INNER JOIN questions q ON q.id = a.question_id '
'WHERE q.test_version_id = :v'
),
{'v': a['test_version_id']},
).mappings().all()
by_q = {}
for o in opts:
qid = str(o['question_id'])
if qid not in by_q:
by_q[qid] = {'all': set(), 'correct': []}
by_q[qid]['all'].add(str(o['id']))
if o['is_correct']:
by_q[qid]['correct'].append(str(o['id']))
correct_count = 0 correct_count = 0
for q in qrows: for q in questions:
qid = str(q['id']) qid = str(q.id)
selected = answers.get(qid, []) selected = answers.get(qid, [])
if not isinstance(selected, list): if not isinstance(selected, list):
selected = [str(selected)] selected = [str(selected)]
selected = [str(x) for x in selected] selected = [str(x) for x in selected]
g = by_q.get(qid, {'all': set(), 'correct': []}) g = by_q[qid]
for sid in selected: for sid in selected:
if sid not in g['all']: if sid not in g['all']:
raise HttpError(400, 'Некорректный вариант ответа.') raise HttpError(400, 'Некорректный вариант ответа.')
if _same_selection(selected, g['correct']): if _same_selection(selected, g['correct']):
correct_count += 1 correct_count += 1
total = len(qrows) total = len(questions)
percent = (correct_count / total) * 100 if total else 0 percent = (correct_count / total) * 100 if total else 0
threshold = int(link['passing_threshold'] or 0) threshold = int(test.passing_threshold or 0)
passed = percent + 1e-9 >= threshold passed = percent + 1e-9 >= threshold
conn.execute(text('DELETE FROM user_answers WHERE attempt_id = :a'), {'a': attempt_id}) # удаляем старые ответы и записываем новые
for q in qrows: session.query(UserAnswer).filter(UserAnswer.attempt_id == aid).delete(synchronize_session='fetch')
qid = str(q['id']) for q in questions:
qid = str(q.id)
selected = answers.get(qid, []) selected = answers.get(qid, [])
if not isinstance(selected, list): if not isinstance(selected, list):
selected = [str(selected)] selected = [str(selected)]
selected = [str(x) for x in selected] selected_uuids = [_to_uuid(x) for x in selected if _to_uuid(x) is not None]
conn.execute( session.add(UserAnswer(
text( attempt_id=aid,
'INSERT INTO user_answers (attempt_id, question_id, selected_options) ' question_id=q.id,
'VALUES (:a, :q, :s::uuid[])' selected_options=selected_uuids,
), ))
{'a': attempt_id, 'q': q['id'], 's': selected},
) attempt.status = 'completed'
conn.execute( attempt.completed_at = datetime.now(timezone.utc)
text( attempt.correct_count = correct_count
"UPDATE test_attempts SET status = 'completed', completed_at = CURRENT_TIMESTAMP, " attempt.total_questions = total
'correct_count = :c, total_questions = :t, passed = :p WHERE id = :a' attempt.passed = passed
), session.commit()
{'a': attempt_id, 'c': correct_count, 't': total, 'p': passed},
) review = build_review_from_db(session, attempt_id)
review = build_review_from_db(eng, attempt_id)
return { return {
'attemptId': attempt_id, 'attemptId': attempt_id,
'correctCount': correct_count, 'correctCount': correct_count,
@ -234,43 +255,42 @@ 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: # ─── review ──────────────────────────────────────────────────────────────────
with eng.connect() as conn:
a = conn.execute( def build_review_from_db(session: Session, attempt_id: str) -> dict:
text( aid = _to_uuid(attempt_id)
'SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions, ' attempt = (
'ta.passed, ta.started_at, ta.completed_at, ' session.query(TestAttempt)
't.id AS test_id, t.title, t.passing_threshold, ' .options(
'u.full_name AS attempter_name, u.login AS attempter_login ' selectinload(TestAttempt.test_version).selectinload(TestVersion.test),
'FROM test_attempts ta ' selectinload(TestAttempt.user),
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' selectinload(TestAttempt.user_answers),
'INNER JOIN tests t ON t.id = tv.test_id ' )
'INNER JOIN users u ON u.id = ta.user_id ' .filter(TestAttempt.id == aid)
'WHERE ta.id = :a' .first()
), )
{'a': attempt_id}, if not attempt:
).mappings().first()
if not a:
raise HttpError(404, 'Попытка не найдена.') raise HttpError(404, 'Попытка не найдена.')
if a['status'] != 'completed': if attempt.status != 'completed':
raise HttpError(400, 'Попытка не завершена.') raise HttpError(400, 'Попытка не завершена.')
questions = load_questions_for_version(conn, a['test_version_id'], include_correct=True)
uans = conn.execute(
text('SELECT question_id, selected_options FROM user_answers WHERE attempt_id = :a'),
{'a': attempt_id},
).mappings().all()
sel_by_q = {str(r['question_id']): [str(x) for x in (r['selected_options'] or [])] for r in uans} test = attempt.test_version.test
total = int(a['total_questions'] or len(questions)) questions = load_questions_for_version(session, attempt.test_version_id, include_correct=True)
percent = round(((a['correct_count'] or 0) / total) * 100, 1) if total else 0
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 = [] q_out = []
for q in questions: 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')]) correct = _sort_uuid_strings([o['id'] for o in q['options'] if o.get('isCorrect')])
selected_set = set(selected) selected_set = set(selected)
q_out.append( q_out.append({
{
'id': q['id'], 'id': q['id'],
'text': q['text'], 'text': q['text'],
'hasMultipleAnswers': q['hasMultipleAnswers'], 'hasMultipleAnswers': q['hasMultipleAnswers'],
@ -284,71 +304,219 @@ def build_review_from_db(eng, attempt_id: str) -> dict:
} }
for o in q['options'] for o in q['options']
], ],
} })
)
return { return {
'attemptId': str(a['id']), 'attemptId': str(attempt.id),
'testId': str(a['test_id']), 'testId': str(test.id),
'testTitle': a['title'], 'testTitle': test.title,
'passingThreshold': int(a['passing_threshold'] or 0), 'passingThreshold': int(test.passing_threshold or 0),
'correctCount': int(a['correct_count'] or 0), 'correctCount': int(attempt.correct_count or 0),
'totalQuestions': total, 'totalQuestions': total,
'percent': percent, 'percent': percent,
'passed': bool(a['passed']), 'passed': bool(attempt.passed),
'startedAt': a['started_at'].isoformat() if a['started_at'] else None, 'startedAt': attempt.started_at.isoformat() if attempt.started_at else None,
'completedAt': a['completed_at'].isoformat() if a['completed_at'] else None, 'completedAt': attempt.completed_at.isoformat() if attempt.completed_at else None,
'attempterUserId': str(a['user_id']), 'attempterUserId': str(attempt.user_id),
'attempterName': a['attempter_name'], 'attempterName': attempt.user.full_name,
'attempterLogin': a['attempter_login'], 'attempterLogin': attempt.user.login,
'questions': q_out, 'questions': q_out,
} }
def get_attempt_review_for_user(eng, current_user_id: str, test_id: str, attempt_id: str) -> dict: def get_attempt_review_for_user(session_or_eng, current_user_id: str, test_id: str,
with eng.connect() as conn: attempt_id: str) -> dict:
row = conn.execute( session = get_session()
text( aid = _to_uuid(attempt_id)
'SELECT ta.user_id, t.created_by, tv.test_id ' tid = _to_uuid(test_id)
'FROM test_attempts ta '
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' attempt = (
'INNER JOIN tests t ON t.id = tv.test_id ' session.query(TestAttempt)
'WHERE ta.id = :a' .options(selectinload(TestAttempt.test_version).selectinload(TestVersion.test))
), .filter(TestAttempt.id == aid)
{'a': attempt_id}, .first()
).mappings().first() )
if not row: if not attempt:
raise HttpError(404, 'Попытка не найдена.') raise HttpError(404, 'Попытка не найдена.')
if str(row['test_id']) != str(test_id): if attempt.test_version.test_id != tid:
raise HttpError(404, 'Попытка не найдена.') 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: if not is_owner and not is_author:
raise HttpError(403, 'Доступ запрещён.') raise HttpError(403, 'Доступ запрещён.')
return build_review_from_db(eng, attempt_id) return build_review_from_db(session, attempt_id)
# ─── 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 = ''
def list_test_attempts_for_author(eng, author_id: str, test_id: str) -> list[dict]: return {
with eng.connect() as conn: 'questionId': str(question.id),
t = conn.execute( 'isCorrect': is_correct,
text('SELECT id, created_by FROM tests WHERE id = :id'), 'correctOptionIds': correct_ids,
{'id': test_id}, 'correctOptionTexts': correct_texts,
).mappings().first() 'explanation': explanation,
if not t: }
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, 'Тест не найден.') raise HttpError(404, 'Тест не найден.')
if not is_test_author(t['created_by'], author_id): if not is_test_author(test.created_by, author_id):
raise HttpError(403, 'Доступ запрещён.') raise HttpError(403, 'Доступ запрещён.')
rows = conn.execute(
text( rows = (
'SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at, ' session.query(TestAttempt, TestVersion, User)
'ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version, ' .join(TestVersion, TestAttempt.test_version_id == TestVersion.id)
'u.full_name AS attempter_name, u.login AS attempter_login ' .join(User, TestAttempt.user_id == User.id)
'FROM test_attempts ta ' .filter(TestVersion.test_id == tid)
'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' .order_by(TestAttempt.started_at.desc().nullslast())
'INNER JOIN users u ON u.id = ta.user_id ' .limit(200)
'WHERE tv.test_id = :id ' .all()
'ORDER BY ta.started_at DESC NULLS LAST LIMIT 200' )
),
{'id': test_id}, return [
).mappings().all() {
return [dict(r) for r in rows] '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
]

35
flask_app/app/services/test_chain.py

@ -1,22 +1,23 @@
"""Утилиты по цепочке теста (попытки/версии).""" """Утилиты по цепочке теста (попытки/версии)."""
from __future__ import annotations 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().""" def has_any_attempt_for_test(session: Session, test_id) -> bool:
row = conn.execute( """Возвращает True, если для теста есть хотя бы одна попытка."""
text( import uuid as _uuid
""" if not isinstance(test_id, _uuid.UUID):
SELECT EXISTS ( try:
SELECT 1 test_id = _uuid.UUID(str(test_id))
FROM test_attempts ta except (ValueError, AttributeError):
INNER JOIN test_versions tv ON ta.test_version_id = tv.id return False
WHERE tv.test_id = :test_id
) AS has_any return session.query(
""" session.query(TestAttempt)
), .join(TestVersion, TestAttempt.test_version_id == TestVersion.id)
{'test_id': test_id}, .filter(TestVersion.test_id == test_id)
).first() .exists()
return bool(row[0]) ).scalar()

336
flask_app/app/services/test_draft.py

@ -1,12 +1,15 @@
"""Создание/правка теста, fork версии при наличии попыток (порт `testDraftService.js`).""" """Создание/правка теста, fork версии при наличии попыток."""
from __future__ import annotations from __future__ import annotations
import uuid as _uuid
from typing import Any 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 ..messages import RU
from ..models import AnswerOption, Question, Test, TestVersion
from .test_access import is_test_author from .test_access import is_test_author
from .test_chain import has_any_attempt_for_test 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: def create_test_with_version(author_id: str, *, title: str, description: str | None) -> dict:
eng = get_engine() session = get_session()
with eng.begin() as conn: try:
t = conn.execute( uid = _uuid.UUID(author_id)
text( except (ValueError, AttributeError):
""" raise HttpError(400, 'Некорректный user_id.')
INSERT INTO tests (title, description, created_by, is_active, is_versioned)
VALUES (:title, :desc, :uid, true, true) RETURNING id test = Test(
""" title=title,
), description=description or None,
{'title': title, 'desc': description or None, 'uid': author_id}, created_by=uid,
).mappings().first() is_active=True,
test_id = t['id'] is_versioned=True,
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'])}
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 _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()
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']},
) )
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 _replace_version_content(conn, test_version_id, payload: dict) -> None: def _get_active_version(session: Session, test_id: _uuid.UUID) -> TestVersion | None:
conn.execute( return (
text( session.query(TestVersion)
""" .filter(TestVersion.test_id == test_id, TestVersion.is_active.is_(True))
DELETE FROM answer_options WHERE question_id IN ( .first()
SELECT id FROM questions WHERE test_version_id = :v
) )
"""
),
{'v': test_version_id}, 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()
) )
conn.execute( for q in questions:
text('DELETE FROM questions WHERE test_version_id = :v'), new_q = Question(
{'v': test_version_id}, 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,
) )
questions = payload.get('questions') or [] session.add(new_q)
for i, q in enumerate(questions): session.flush()
ins_q = conn.execute( for o in sorted(q.options, key=lambda x: x.option_order):
text( session.add(AnswerOption(
""" question_id=new_q.id,
INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers) text=o.text,
VALUES (:v, :text, :ord, :multi) RETURNING id is_correct=o.is_correct,
""" option_order=o.option_order,
), ))
{
'v': test_version_id,
'text': q.get('text'), def _fork_new_version(session: Session, test_id: _uuid.UUID) -> TestVersion:
'ord': q.get('question_order') or (i + 1), av = _get_active_version(session, test_id)
'multi': bool(q.get('hasMultipleAnswers')), if not av:
}, raise HttpError(500, RU['internal'] if 'internal' in RU else 'Внутренняя ошибка.')
).mappings().first()
qid = ins_q['id'] max_ver = (
opts = q.get('options') or [] session.query(func.coalesce(func.max(TestVersion.version), 0))
for j, o in enumerate(opts): .filter(TestVersion.test_id == test_id)
conn.execute( .scalar() or 0
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),
},
) )
next_v = int(max_ver) + 1
# деактивируем все версии
session.query(TestVersion).filter(TestVersion.test_id == test_id).update(
{TestVersion.is_active: False}, synchronize_session='fetch'
)
def _fork_new_version(conn, test_id: str) -> dict: new_version = TestVersion(
av = _get_active_version_row(conn, test_id) test_id=test_id,
if not av: version=next_v,
raise HttpError(500, RU['internal']) # invariant: должна быть активная версия is_active=True,
mx = conn.execute( parent_id=av.id,
text( )
'SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = :t' session.add(new_version)
), session.flush()
{'t': test_id}, _copy_question_tree(session, av.id, new_version.id)
).mappings().first() return new_version
next_v = (mx['v'] or 0) + 1
conn.execute(
text('UPDATE test_versions SET is_active = false WHERE test_id = :t'), def _replace_version_content(session: Session, version: TestVersion, payload: dict) -> None:
{'t': test_id}, # Снимок 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),
) )
nv = conn.execute( session.add(new_q)
text( session.flush()
""" for j, op in enumerate(qp.get('options') or []):
INSERT INTO test_versions (test_id, version, is_active, parent_id) session.add(AnswerOption(
VALUES (:t, :ver, true, :parent) RETURNING * question_id=new_q.id,
""" text=(op.get('text') or '').strip(),
), is_correct=bool(op.get('isCorrect')),
{'t': test_id, 'ver': next_v, 'parent': av['id']}, option_order=op.get('option_order') or (j + 1),
).mappings().first() ))
_copy_question_tree(conn, av['id'], nv['id'])
return dict(nv)
def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict: def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict:
if not isinstance(payload, dict): if not isinstance(payload, dict):
payload = {} payload = {}
eng = get_engine() session = get_session()
with eng.begin() as conn: try:
t = conn.execute( tid = _uuid.UUID(test_id)
text('SELECT id, created_by FROM tests WHERE id = :id'), except (ValueError, AttributeError):
{'id': test_id}, raise HttpError(404, 'Тест не найден.')
).mappings().first()
if not t: test = session.get(Test, tid)
raise HttpError(404, RU['testNotFound'] if 'testNotFound' in RU else 'Тест не найден.') if not test:
if not is_test_author(t['created_by'], author_id): raise HttpError(404, 'Тест не найден.')
if not is_test_author(test.created_by, author_id):
raise HttpError(403, 'Доступ запрещён.') raise HttpError(403, 'Доступ запрещён.')
if payload.get('title') is not None or payload.get('description') is not None: if payload.get('title') is not None:
conn.execute( test.title = payload['title']
text( if payload.get('description') is not None:
""" test.description = payload['description'] or None
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: if payload.get('passingThreshold') is not None:
try: try:
raw = float(payload['passingThreshold']) test.passing_threshold = max(0, min(100, round(float(payload['passingThreshold']))))
pt = max(0, min(100, round(raw))) except (TypeError, ValueError):
conn.execute( pass
text(
'UPDATE tests SET passing_threshold = :pt, updated_at = CURRENT_TIMESTAMP WHERE id = :id' if 'timeLimit' in payload:
), tl = payload.get('timeLimit')
{'pt': pt, 'id': test_id}, try:
) test.time_limit = None if tl in (None, '', 0) else max(0, int(tl))
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
has_attempts = has_any_attempt_for_test(conn, test_id) if 'hintsEnabled' in payload:
version_row = _get_active_version_row(conn, test_id) test.hints_enabled = bool(payload['hintsEnabled'])
if not version_row:
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, 'Нет активной версии теста.') raise HttpError(500, 'Нет активной версии теста.')
forked = False forked = False
if has_attempts and 'questions' in payload and payload.get('questions') is not None: if has_attempts and 'questions' in payload and payload.get('questions') is not None:
version_row = _fork_new_version(conn, test_id) active_version = _fork_new_version(session, tid)
forked = True forked = True
if payload.get('questions') is not None: if payload.get('questions') is not None:
_replace_version_content(conn, version_row['id'], payload) _replace_version_content(session, active_version, payload)
return {'testId': test_id, 'versionId': str(version_row['id']), 'forked': forked} session.commit()
return {'testId': test_id, 'versionId': str(active_version.id), 'forked': forked}

390
flask_app/app/static/css/app.css

@ -264,6 +264,20 @@ body.ui-legacy .btn-ghost:hover {
text-decoration: none; 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 { body.ui-legacy .text-muted {
color: var(--on-surface-variant); color: var(--on-surface-variant);
font-size: 0.875rem; font-size: 0.875rem;
@ -365,6 +379,52 @@ body.ui-legacy .callout--warning {
color: #92400e; 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 .muted,
body.ui-legacy .text-muted, body.ui-legacy .text-muted,
body.ui-legacy .text-secondary { 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); box-shadow: 0 0 0 3px rgba(0, 113, 104, 0.12);
background: #fff; 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 { body.ui-legacy .surface-card {
background: var(--surface); background: var(--surface);
@ -421,23 +491,53 @@ body.ui-legacy .cabinet-brick--hero {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.hero-brick__nav { .hero-brick__meta-row {
display: flex; display: flex;
justify-content: space-between;
align-items: baseline;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; align-items: baseline;
font-size: 0.85rem; gap: 0.35rem;
margin-top: 0.5rem;
font-size: 0.82rem;
color: var(--ink-500, #6b7280); color: var(--ink-500, #6b7280);
} }
.hero-brick__meta { .hero-brick__sep { opacity: 0.45; }
display: inline-flex;
.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; flex-wrap: wrap;
gap: 0.4rem; gap: 0.45rem;
align-items: baseline; align-items: center;
color: var(--ink-500, #6b7280); }
.hero-brick__tag {
display: inline-flex;
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 { .hero-brick__title {
display: block; display: block;
@ -510,6 +610,41 @@ body.ui-legacy .cabinet-brick--hero {
} }
.hero-brick__chip input[type="checkbox"] { accent-color: var(--primary, #0d9488); } .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 { body.ui-legacy .cabinet-disclosure {
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
border-radius: 1rem; border-radius: 1rem;
@ -567,10 +702,167 @@ body.ui-legacy .cabinet-disclosure__summary-sub {
} }
body.ui-legacy .cabinet-disclosure__body { 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); 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 { body.ui-legacy .test-detail-subsection {
margin-top: 1.25rem; margin-top: 1.25rem;
padding-top: 1.15rem; padding-top: 1.15rem;
@ -584,8 +876,8 @@ body.ui-legacy .test-detail-subsection--tight {
} }
body.ui-legacy .test-detail-subsection__title { body.ui-legacy .test-detail-subsection__title {
margin: 0 0 0.35rem; margin: 0 0 0.5rem;
font-size: 0.95rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
} }
@ -597,8 +889,8 @@ body.ui-legacy .test-detail-hint {
} }
body.ui-legacy .test-detail-ai-panel { body.ui-legacy .test-detail-ai-panel {
padding: 0.9rem 1rem; padding: 1rem 1.1rem 1.1rem;
margin-bottom: 1.15rem; margin-bottom: 1.25rem;
background: var(--surface-container-low); background: var(--surface-container-low);
border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent); border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
border-radius: 0.85rem; border-radius: 0.85rem;
@ -666,3 +958,69 @@ body.ui-legacy .attempts-card-list {
flex-direction: column; flex-direction: column;
gap: 0.5rem; 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;
}

750
flask_app/app/static/js/editor.js

@ -17,6 +17,8 @@
const $ = (sel, parent = document) => parent.querySelector(sel); const $ = (sel, parent = document) => parent.querySelector(sel);
const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel)); const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel));
const MAX_OPTIONS = 8;
const titleEl = $('#test-title'); const titleEl = $('#test-title');
const descEl = $('#test-description'); const descEl = $('#test-description');
const thresholdEl = $('#test-threshold'); const thresholdEl = $('#test-threshold');
@ -24,7 +26,8 @@
const qCountEl = $('#q-count'); const qCountEl = $('#q-count');
const saveStatusEl = $('#save-status'); const saveStatusEl = $('#save-status');
const aiStatusEl = $('#ai-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 aiTopicEl = $('#ai-topic');
const aiQCountEl = $('#ai-q-count'); const aiQCountEl = $('#ai-q-count');
const aiOCountEl = $('#ai-o-count'); const aiOCountEl = $('#ai-o-count');
@ -100,30 +103,78 @@
$('.q-multi', node).checked = !!q.hasMultipleAnswers; $('.q-multi', node).checked = !!q.hasMultipleAnswers;
const optsEl = $('.q-options', node); const optsEl = $('.q-options', node);
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o))); (q.options || []).forEach((o) => optsEl.appendChild(renderOption(o, node)));
bindQuestionEvents(node); bindQuestionEvents(node);
syncOptionInputTypes(node); syncOptionInputTypes(node);
updateOptionsCounter(node);
updateAiButtonLabel(node);
return node; return node;
} }
function renderOption(o) { function renderOption(o, qNode) {
const node = tplO.content.firstElementChild.cloneNode(true); 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; $('.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', () => { $('.opt-delete', node).addEventListener('click', () => {
node.remove(); node.remove();
if (qNode) updateOptionsCounter(qNode);
scheduleDirtyCheck();
}); });
return node; return node;
} }
function bindQuestionEvents(node) { let dragSrc = null;
$('.q-delete', node).addEventListener('click', () => { function bindDragEvents(node) {
if (!confirm('Удалить вопрос?')) return; const handle = $('.q-drag', node);
node.remove(); 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(); renumber();
scheduleDirtyCheck(); 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', () => { $('.q-up', node).addEventListener('click', () => {
if (node.previousElementSibling) { if (node.previousElementSibling) {
node.parentNode.insertBefore(node, node.previousElementSibling); node.parentNode.insertBefore(node, node.previousElementSibling);
@ -138,26 +189,122 @@
scheduleDirtyCheck(); scheduleDirtyCheck();
} }
}); });
$('.q-add-option', node).addEventListener('click', () => { const addOptBtn = $('.q-add-option', node);
$('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false })); 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); 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(); scheduleDirtyCheck();
}); });
}
// Умная кнопка AI — label зависит от наличия текста
const qTextEl2 = $('.q-text', node);
if (qTextEl2) {
qTextEl2.addEventListener('input', () => updateAiButtonLabel(node));
}
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node)); $('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
$('.q-multi', node).addEventListener('change', () => { $('.q-multi', node).addEventListener('change', () => {
syncOptionInputTypes(node); syncOptionInputTypes(node);
scheduleDirtyCheck(); 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() { function renumber() {
$$('#questions .q-item').forEach((li, i) => { let i = 0;
$('.q-num', li).textContent = `Вопрос #${i + 1}`; $$('#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 = i;
if (qCountEl) qCountEl.textContent = n;
const mirror = document.getElementById('q-count-mirror'); 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) { function autoResize(el) {
@ -166,20 +313,71 @@
el.style.height = el.scrollHeight + 'px'; 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() { function loadInitial() {
titleEl.value = initial.test.title || ''; titleEl.value = initial.test.title || '';
descEl.value = initial.test.description || ''; descEl.value = initial.test.description || '';
autoResize(titleEl); autoResize(titleEl);
autoResize(descEl); autoResize(descEl);
if (thresholdEl) {
thresholdEl.addEventListener('input', syncThresholdMirror);
thresholdEl.addEventListener('change', syncThresholdMirror);
}
if (titleEl && titleEl.tagName === 'TEXTAREA') { 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) => { titleEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') e.preventDefault(); 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 (descEl) descEl.addEventListener('input', () => autoResize(descEl));
if (docUserHint) docUserHint.addEventListener('input', () => autoResize(docUserHint));
thresholdEl.value = thresholdEl.value =
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold); 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 = ''; questionsEl.innerHTML = '';
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); (initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
@ -187,6 +385,7 @@
if (aiTopicEl && !aiTopicEl.value.trim()) { if (aiTopicEl && !aiTopicEl.value.trim()) {
aiTopicEl.value = initial.test.title || ''; aiTopicEl.value = initial.test.title || '';
} }
if (aiTopicEl) requestAnimationFrame(() => autoResize(aiTopicEl));
} }
function fmtDt(iso) { function fmtDt(iso) {
@ -207,7 +406,7 @@
// ─── collect ─────────────────────────────────────────────────────── // ─── collect ───────────────────────────────────────────────────────
function collectPayload() { 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(), text: $('.q-text', li).value.trim(),
question_order: i + 1, question_order: i + 1,
hasMultipleAnswers: $('.q-multi', li).checked, hasMultipleAnswers: $('.q-multi', li).checked,
@ -224,6 +423,17 @@
}; };
const t = thresholdEl.value; const t = thresholdEl.value;
if (t !== '' && Number.isFinite(Number(t))) payload.passingThreshold = Number(t); 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; return payload;
} }
@ -271,11 +481,48 @@
}); });
if (r2.ok) chainActive = chainActiveEl.checked; if (r2.ok) chainActive = chainActiveEl.checked;
} }
saveStatusEl.textContent = data.forked
? 'Сохранено (создана новая версия — есть попытки прохождения).'
: 'Сохранено.';
resetBaselineDraft(); 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) { } catch (e) {
saveStatusEl.textContent = ''; saveStatusEl.textContent = '';
alert(e.message || 'Не удалось сохранить.'); alert(e.message || 'Не удалось сохранить.');
@ -288,6 +535,16 @@
alert('Укажите тему.'); alert('Укажите тему.');
return; 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 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 nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
const shape = Array.from({ length: nQ }, () => ({ const shape = Array.from({ length: nQ }, () => ({
@ -325,50 +582,227 @@
} }
}); });
// ─── импорт документа (E1.3) ─────────────────────────────────── // ─── импорт документа с drag-and-drop (E1.3) ──────────────────
$('#ai-import-file').addEventListener('change', async (ev) => { const importDropzone = $('#ai-import-dropzone');
const file = ev.target.files && ev.target.files[0]; const importDropzoneLabel = $('#ai-import-dropzone-label');
ev.target.value = ''; 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; if (!file) return;
aiStatusEl.textContent = `Загружаем «${file.name}»…`; aiStatusEl.textContent = `Загружаем «${file.name}»…`;
importDropzone.classList.add('import-dropzone--loading');
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd }); const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd });
const data = await r.json(); const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось импортировать.'); if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.');
const g = data.generation || {}; _extractedText = data.extractedText || '';
if (!g.available) { _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 = ''; aiStatusEl.textContent = '';
const msg = g.message || 'AI недоступен.'; openImportModal(
const preview = (g.textPreview || data.extractedText || '').slice(0, 600); 'Ошибка загрузки',
alert(msg + (preview ? '\n\nПервые 600 символов:\n' + preview : '')); `<p class="text-red-700">${escHtml(e.message || 'Не удалось загрузить файл.')}</p>`,
return; [{ label: 'Закрыть', onClick: () => importModal.close() }],
}
const ok = confirm(
`${g.message}\n\nПрименить как новый черновик?\n` +
`Текущие вопросы будут заменены.`,
); );
if (!ok) { } 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 = ''; 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; return;
} }
const draft = g.draft;
if (draft.title) titleEl.value = draft.title; const draft = g.draft || {};
if (draft.description) descEl.value = draft.description; 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 = ''; questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); qs.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber(); renumber();
scheduleDirtyCheck(); scheduleDirtyCheck();
aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`; aiStatusEl.textContent = `Применено: ${qs.length} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000); 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) { } catch (e) {
aiStatusEl.textContent = ''; aiStatusEl.textContent = '';
alert(e.message || 'Не удалось импортировать.'); 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 ───────── // ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
function aiAlert(data, fallback) { function aiAlert(data, fallback) {
@ -388,6 +822,34 @@
const modalActions = $('#ai-modal-actions'); const modalActions = $('#ai-modal-actions');
$('#ai-modal-close').addEventListener('click', () => modal.close()); $('#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) { function openModal(title, bodyHtml, actions) {
modalTitle.textContent = title; modalTitle.textContent = title;
modalBody.innerHTML = bodyHtml; modalBody.innerHTML = bodyHtml;
@ -610,11 +1072,39 @@
}); });
async function aiGenerateQuestion(node) { async function aiGenerateQuestion(node) {
const qText = $('.q-text', node).value.trim(); const qTextEl = $('.q-text', node);
const optsCount = Math.max(2, $$('.opt-item', node).length || 4); const qText = qTextEl.value.trim();
const existingOpts = $$('.opt-item', node);
const optsCount = Math.max(2, existingOpts.length || 4);
const multi = $('.q-multi', node).checked; const multi = $('.q-multi', node).checked;
aiStatusEl.textContent = 'AI: один вопрос…'; const overlay = $('.q-ai-overlay', node);
// Показываем оверлей
overlay?.classList.remove('hidden');
node.style.pointerEvents = 'none';
try { 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`, { const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -624,86 +1114,154 @@
questionText: qText, questionText: qText,
optionsCount: optsCount, optionsCount: optsCount,
hasMultipleAnswers: multi, hasMultipleAnswers: multi,
mode: requestMode,
existingOptions: qText ? existingOptions : undefined,
}), }),
}); });
const data = await r.json(); const data = await r.json();
if (!r.ok) throw new Error(data.error || 'AI: ошибка.'); if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
$('.q-text', node).value = data.text || '';
if (data.mode === 'full' && Array.isArray(data.options)) { // Обновляем текст вопроса (кроме режима дистракторов — текст не меняем)
if (data.mode !== 'distractors') {
qTextEl.value = data.text || qText;
autoResize(qTextEl);
}
const optsEl = $('.q-options', node); const optsEl = $('.q-options', node);
if (data.mode === 'full' && Array.isArray(data.options) && data.options.length) {
// Полная замена вариантов
optsEl.innerHTML = ''; optsEl.innerHTML = '';
data.options.forEach((o) => optsEl.appendChild(renderOption(o))); data.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
$('.q-multi', node).checked = !!data.hasMultipleAnswers; $('.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(); scheduleDirtyCheck();
aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.';
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) { } catch (e) {
aiStatusEl.textContent = ''; aiStatusEl.textContent = '';
alert(e.message || 'AI: ошибка.'); alert(e.message || 'AI: ошибка.');
} finally {
overlay?.classList.add('hidden');
node.style.pointerEvents = '';
} }
} }
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ─── // ─── 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`) fetch(`/api/tests/${TEST_ID}/summary`)
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
if (data && data.test && typeof data.test.chainActive === 'boolean') { if (data && data.test && typeof data.test.chainActive === 'boolean') {
chainActive = data.test.chainActive; updateChainActiveDisplay(data.test.chainActive);
chainActiveEl.checked = chainActive;
} else { } else {
chainActiveEl.checked = true; updateChainActiveDisplay(true);
chainActive = true;
} }
}) })
.catch(() => { .catch(() => { updateChainActiveDisplay(true); });
chainActiveEl.checked = true;
});
function renderVersions(rows) { function renderVersions(rows) {
if (!versionsListEl) return; if (!versionsListEl) return;
versionsListEl.innerHTML = ''; versionsListEl.innerHTML = '';
if (!(rows || []).length) {
versionsListEl.innerHTML = '<li class="muted" style="font-size:.85rem;">Нет версий.</li>';
return;
}
(rows || []).forEach((r) => { (rows || []).forEach((r) => {
const li = document.createElement('li'); 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 = ` li.innerHTML = `
<div class="version-card-list__row"> <span class="version-item__label">
<div class="version-card-list__main"> Версия ${r.version}
<div class="version-card-list__title-line"> ${r.is_active ? '<span class="version-item__badge">активная</span>' : ''}
<span class="font-headline" style="font-size:1rem;">v${r.version}</span> </span>
${r.is_active ? '<span class="code-inline" style="font-size:0.7rem;">текущая</span>' : ''} <span class="version-item__date muted">${fmtDt(r.created_at)}</span>
</div> ${!r.is_active
<p class="muted mono" style="margin:.4rem 0 0; font-size:.8rem;">${fmtDt(r.created_at)}</p> ? `<button class="btn btn-ghost btn--sm version-item__activate" type="button"
<p class="muted" style="margin:.2rem 0 0; font-size:.8rem;">Активна: ${r.is_active ? 'да' : 'нет'}</p> data-version-id="${escHtml(r.id)}">Сделать активной</button>`
</div> : '<span class="version-item__spacer"></span>'}`;
</div>`;
versionsListEl.appendChild(li); 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) { function renderAttempts(rows) {
if (!attemptsListEl) return; if (!attemptsListEl) return;
attemptsListEl.innerHTML = ''; 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) => { (rows || []).forEach((a) => {
const when = a.completedAt ? fmtDt(a.completedAt) : (a.startedAt ? fmtDt(a.startedAt) : '—'); const when = a.completedAt ? fmtDt(a.completedAt) : (a.startedAt ? fmtDt(a.startedAt) : '—');
const result = a.status === 'completed' && a.totalQuestions != null let result;
? `${a.correctCount}/${a.totalQuestions}${a.passed ? ' · зачёт' : ' · незачёт'}` if (a.status === 'completed' && a.totalQuestions != null) {
: a.status; 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'); const li = document.createElement('li');
li.className = 'surface-card attempts-card-list__item'; li.className = 'surface-card attempts-card-list__item';
li.innerHTML = ` li.innerHTML = `
<div class="attempts-card-list__row"> <div class="attempts-card-list__row">
<div class="attempts-card-list__main"> <div class="attempts-card-list__main">
<p class="muted mono" style="margin:0; font-size:.8rem;">${when}</p> <p class="muted" style="margin:0; font-size:.8rem;">${when}</p>
<p style="margin:.35rem 0 0; font-weight:600;">${escHtml(a.attempterName || '—')} <p style="margin:.3rem 0 0; font-weight:600;">${escHtml(a.attempterName || a.attempterLogin || '—')}</p>
${a.attempterLogin ? `<span class="code-inline" style="font-size:11px; margin-left:6px;">${escHtml(a.attempterLogin)}</span>` : ''} <p style="margin:.2rem 0 0; font-size:.85rem; ${passedCls}">${escHtml(result)}</p>
</p>
<p class="muted" style="margin:.25rem 0 0; font-size:.85rem;">v${a.testVersion} · ${escHtml(result)}</p>
</div> </div>
${a.status === 'completed' ${a.status === 'completed'
? `<a class="btn btn-ghost btn--sm attempts-card-list__action" href="/tests/${TEST_ID}/attempts/${a.id}/review">Разбор</a>` ? `<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>`; </div>`;
attemptsListEl.appendChild(li); attemptsListEl.appendChild(li);
}); });
@ -720,7 +1278,7 @@
<span class="assign-row__text"> <span class="assign-row__text">
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span> <span class="assign-row__fio">${escHtml(p.fio || '—')}</span>
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</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>`; </span>`;
const cb = row.querySelector('input'); const cb = row.querySelector('input');
cb.addEventListener('change', () => { cb.addEventListener('change', () => {
@ -798,7 +1356,7 @@
if (visibilityBtn) { if (visibilityBtn) {
visibilityBtn.addEventListener('click', async () => { visibilityBtn.addEventListener('click', async () => {
const next = !chainActiveEl.checked; const next = !chainActive;
try { try {
const r = await fetch(`/api/tests/${TEST_ID}`, { const r = await fetch(`/api/tests/${TEST_ID}`, {
method: 'PATCH', method: 'PATCH',
@ -807,8 +1365,7 @@
}); });
const data = await r.json(); const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка изменения видимости'); if (!r.ok) throw new Error(data.error || 'Ошибка изменения видимости');
chainActiveEl.checked = !!next; updateChainActiveDisplay(next);
chainActive = !!next;
visibilityBtn.textContent = chainActive ? 'Скрыть из списка' : 'Снова показать в списке'; visibilityBtn.textContent = chainActive ? 'Скрыть из списка' : 'Снова показать в списке';
} catch (e) { } catch (e) {
alert(e.message || 'Ошибка изменения видимости'); 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([ Promise.all([
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null), fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null), fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null),

169
flask_app/app/templates/assignments.html

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 %}

24
flask_app/app/templates/auth/login.html

@ -22,13 +22,21 @@
{% endwith %} {% endwith %}
<div class="login-card"> <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> <form method="post" action="{{ url_for('auth.login_submit') }}" novalidate>
<input type="hidden" name="next" value="{{ next or '/' }}"> <input type="hidden" name="next" value="{{ next or '/' }}">
<div class="form-field"> <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" <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>
<div class="form-field"> <div class="form-field">
@ -50,9 +58,12 @@
<h1 class="text-xl font-semibold">Вход в систему</h1> <h1 class="text-xl font-semibold">Вход в систему</h1>
</div> </div>
<p class="mt-1 text-sm text-ink-500"> <p class="mt-1 text-sm text-ink-500">
Используйте логин и пароль. {% if dev_fio_enabled %}
{% if hr_auth_enabled %} Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
{% elif hr_auth_enabled %}
Учётка кадровой системы (HR). Учётка кадровой системы (HR).
{% else %}
Используйте логин и пароль.
{% endif %} {% endif %}
</p> </p>
@ -74,8 +85,11 @@
<input type="hidden" name="next" value="{{ next or '/' }}"> <input type="hidden" name="next" value="{{ next or '/' }}">
<label class="block"> <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" <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 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" /> focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label> </label>

1
flask_app/app/templates/base.html

@ -107,7 +107,6 @@
text-ink-700 hover:bg-ink-100" text-ink-700 hover:bg-ink-100"
title="Настройки" aria-label="Настройки"> title="Настройки" aria-label="Настройки">
<span class="material-symbols-outlined text-base">settings</span> <span class="material-symbols-outlined text-base">settings</span>
<span class="hidden sm:inline">Настройки</span>
</a> </a>
<span class="hidden md:inline text-ink-500"> <span class="hidden md:inline text-ink-500">
{{ current_user.full_name or current_user.login }} {{ current_user.full_name or current_user.login }}

91
flask_app/app/templates/index.html

@ -1,45 +1,66 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Тестирование — главная{% endblock %} {% block title %}Главная — Тестирование персонала{% endblock %}
{% block content %} {% 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 mb-5">Тестирование персонала</h1>
<h1 class="text-2xl font-semibold text-ink-900">Сервис тестирования персонала</h1>
<p class="mt-2 text-ink-500">
Этап 1 миграции: переход на единый стек (Flask + Jinja). Бизнес-функции
переносятся последовательно — авторизация, каталог тестов, редактор,
назначения, прохождение, импорт/AI.
</p>
<div class="mt-5 flex flex-wrap gap-2 text-sm"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<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 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>
<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>
<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> <a href="{{ url_for('tests.tests_list_page') }}"
Health-check 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>
{# Назначения #}
<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> </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 [ <a href="{{ url_for('settings.prompts_page') }}"
('Авторизация', 'E1.1 — логин по HR/локальному пользователю.', 'login'), class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
('Тесты', 'E1.2 — каталог, фильтры, карточки.', 'list_alt'), hover:shadow-md hover:border-brand-300 transition-all group">
('Редактор', 'E1.3 — создание/правка теста, AI-помощник.', 'edit_note'), <div class="flex items-center gap-3">
('Назначения', 'E1.4 — назначить сотрудникам, отслеживать.', 'assignment'), <span class="material-symbols-outlined text-brand-500 text-2xl">psychology</span>
('Прохождение', 'E1.5 — UI прохождения теста сотрудником.', 'fact_check'), <h2 class="font-semibold text-ink-900">Настройки ИИ</h2>
('Импорт/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> </div>
<p class="mt-1 text-sm text-ink-500">{{ descr }}</p> <p class="text-sm text-ink-500 flex-1">Редактор промптов — генерация вопросов, дистракторы, улучшение теста.</p>
</article> <span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
{% endfor %} Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
</section> </span>
</a>
</div>
{% endblock %} {% endblock %}

433
flask_app/app/templates/settings_prompts.html

@ -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>
— перетащите их в нужное место или нажмите «+&nbsp;переменная» внизу редактора для вставки.
</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 %}

103
flask_app/app/templates/stats.html

@ -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 %}

135
flask_app/app/templates/tests/attempt.html

@ -6,6 +6,7 @@
<p class="link-back"><a href="/tests">← к списку тестов</a></p> <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> <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" 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> <p class="error-text" id="attempt-error" style="display:none;"></p>
<ol id="questions-list" style="padding-left:1.25rem;"></ol> <ol id="questions-list" style="padding-left:1.25rem;"></ol>
@ -15,6 +16,18 @@
</div> </div>
<div id="attempt-result" class="surface-card" style="display:none;margin-top:1rem;padding:1rem;"></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> </div>
<script> <script>
@ -24,12 +37,23 @@
const attemptId = root.dataset.attemptId; const attemptId = root.dataset.attemptId;
const titleEl = document.getElementById('attempt-title'); const titleEl = document.getElementById('attempt-title');
const subEl = document.getElementById('attempt-subtitle'); const subEl = document.getElementById('attempt-subtitle');
const timerEl = document.getElementById('attempt-timer');
const errEl = document.getElementById('attempt-error'); const errEl = document.getElementById('attempt-error');
const listEl = document.getElementById('questions-list'); const listEl = document.getElementById('questions-list');
const resultEl = document.getElementById('attempt-result'); const resultEl = document.getElementById('attempt-result');
const submitBtn = document.getElementById('submit-attempt-btn'); 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; let playData = null;
const selections = {}; const selections = {};
const checked = {};
let timerHandle = null;
let deadlineMs = null;
function esc(s) { function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (m) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); return String(s ?? '').replace(/[&<>"']/g, (m) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
@ -51,38 +75,127 @@
} }
selections[k] = [id]; selections[k] = [id];
} }
function isImmediate() {
return playData && playData.resultMode === 'immediate';
}
function renderQuestions() { function renderQuestions() {
listEl.innerHTML = ''; listEl.innerHTML = '';
for (const q of (playData.questions || [])) { for (const q of (playData.questions || [])) {
const qid = String(q.id);
const isChecked = !!checked[qid];
const li = document.createElement('li'); const li = document.createElement('li');
li.style.marginBottom = '1.5rem'; 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'); const ul = document.createElement('ul');
ul.style.listStyle = 'none'; ul.style.listStyle = 'none';
ul.style.padding = '0'; ul.style.padding = '0';
ul.style.margin = '0'; ul.style.margin = '0';
const correctSet = new Set((checked[qid] && checked[qid].correctOptionIds) || []);
for (const o of (q.options || [])) { for (const o of (q.options || [])) {
const oid = String(o.id);
const row = document.createElement('li'); const row = document.createElement('li');
row.style.marginBottom = '6px'; row.style.marginBottom = '6px';
const type = q.hasMultipleAnswers ? 'checkbox' : 'radio'; const type = q.hasMultipleAnswers ? 'checkbox' : 'radio';
const name = 'q-' + q.id; 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 = row.innerHTML =
'<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">' + '<label style="display:flex;align-items:flex-start;gap:8px;cursor:' + (isChecked ? 'default' : 'pointer') + ';' +
'<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' + (isSelected(q.id, o.id) ? 'checked' : '') + ' />' + (isChecked ? 'opacity:0.85;' : '') + '">' +
'<span>' + esc(o.text) + '</span>' + '<input type="' + type + '" ' + (q.hasMultipleAnswers ? '' : ('name="' + name + '"')) + ' ' +
(isSelected(q.id, o.id) ? 'checked' : '') + ' ' + (isChecked ? 'disabled' : '') + ' />' +
'<span>' + esc(o.text) + mark + '</span>' +
'</label>'; '</label>';
const input = row.querySelector('input'); const input = row.querySelector('input');
input.addEventListener('change', () => { input.addEventListener('change', () => {
if (checked[qid]) return;
toggle(q.id, o.id, q.hasMultipleAnswers); toggle(q.id, o.id, q.hasMultipleAnswers);
renderQuestions(); renderQuestions();
}); });
ul.appendChild(row); ul.appendChild(row);
} }
li.appendChild(ul); 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); 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() { async function load() {
try { try {
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/play'); const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/play');
@ -90,22 +203,26 @@
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.'); if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
playData = data; playData = data;
titleEl.textContent = data.testTitle || 'Прохождение теста'; 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) { if (!Array.isArray(data.questions) || !data.questions.length) {
setErr('В активной версии нет вопросов.'); setErr('В активной версии нет вопросов.');
submitBtn.disabled = true; submitBtn.disabled = true;
return; return;
} }
renderQuestions(); renderQuestions();
if (data.timeLimit) startTimer(Number(data.timeLimit));
} catch (e) { } catch (e) {
setErr(e.message); setErr(e.message);
submitBtn.disabled = true; submitBtn.disabled = true;
} }
} }
async function submit() { async function submit(auto) {
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Отправка…'; submitBtn.textContent = auto ? 'Время вышло, отправка…' : 'Отправка…';
try { try {
const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/submit', { const r = await fetch('/api/tests/' + testId + '/attempts/' + attemptId + '/submit', {
method: 'POST', method: 'POST',
@ -114,6 +231,8 @@
}); });
const data = await r.json().catch(() => ({})); const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось завершить попытку.'); if (!r.ok) throw new Error(data.error || 'Не удалось завершить попытку.');
if (timerHandle) clearInterval(timerHandle);
timerEl.style.display = 'none';
resultEl.style.display = ''; resultEl.style.display = '';
resultEl.innerHTML = resultEl.innerHTML =
'<h3 style="margin-top:0;">Результат</h3>' + '<h3 style="margin-top:0;">Результат</h3>' +
@ -129,7 +248,7 @@
} }
} }
submitBtn.addEventListener('click', submit); submitBtn.addEventListener('click', () => submit(false));
load(); load();
})(); })();
</script> </script>

383
flask_app/app/templates/tests/editor.html

@ -8,42 +8,101 @@
data-initial='{{ content | tojson | safe }}'> data-initial='{{ content | tojson | safe }}'>
<section class="cabinet-brick cabinet-brick--hero hero-brick"> <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>
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back">← к списку</a> <textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
<span class="hero-brick__meta"> class="hero-brick__title font-headline"></textarea>
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
class="hero-brick__title font-headline"></textarea>
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
class="hero-brick__desc"></textarea>
<div class="hero-brick__meta-row">
<span>Автор: <b id="intro-author">Вы</b></span> <span>Автор: <b id="intro-author">Вы</b></span>
<span class="hero-brick__sep">·</span> <span class="hero-brick__sep">·</span>
<span>Обновлён: <span id="intro-updated"></span></span> <span>Обновлён: <span id="intro-updated"></span></span>
<span class="hero-brick__sep">·</span> <span class="hero-brick__sep">·</span>
<span>Версия <span id="intro-version"></span></span> <span>Версия <span id="intro-version"></span></span>
</div>
<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> </span>
</summary>
<div class="cabinet-disclosure__body">
<ul id="versions-list" class="version-card-list"></ul>
</div> </div>
</details>
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста" <details class="cabinet-disclosure cabinet-brick">
class="hero-brick__title font-headline"></textarea> <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>
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)" <label class="settings-row">
class="hero-brick__desc"></textarea> <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>
<div class="hero-brick__chips"> <fieldset class="settings-row settings-row--block">
<label class="hero-brick__chip"> <legend class="settings-row__label">Когда показывать результат</legend>
<span>Порог зачёта</span> <label class="settings-radio">
<input id="test-threshold" type="number" min="0" max="100" step="1" inputmode="numeric" /> <input type="radio" name="result-mode" value="end" />
<span>%</span> <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> </label>
<span class="hero-brick__chip hero-brick__chip--readonly"> </fieldset>
Вопросов: <b id="q-count">0</b>
<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> </span>
<label class="hero-brick__chip"> <input id="test-hints-enabled" type="checkbox" />
<input id="chain-active" type="checkbox" />
<span>Активна в каталоге</span>
</label> </label>
</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="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>
</section> </div>
</div>
</details>
<details class="cabinet-disclosure cabinet-brick" open> <details class="cabinet-disclosure cabinet-brick" open>
<summary class="cabinet-disclosure__summary"> <summary class="cabinet-disclosure__summary">
@ -54,13 +113,14 @@
</summary> </summary>
<div class="cabinet-disclosure__body"> <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"> <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"> <div class="question-editor-block question-editor-block--first">
<h3 class="test-detail-subsection__title" style="margin-top:0;">Генерация сетки вопросов (ИИ)</h3> <h3 class="test-detail-subsection__title">Структура теста</h3>
<label class="block"> <p class="muted text-xs mb-3">
<span class="form-label">Тема</span> Укажите количество вопросов и вариантов — создайте шаблон и заполните его вручную или через ИИ.
<input id="ai-topic" type="text" class="form-input" placeholder="Например: Введение про LLM" /> </p>
</label> <div class="flex flex-wrap items-end gap-3">
<div class="mt-3 flex flex-wrap items-end gap-3">
<label class="block"> <label class="block">
<span class="form-label">Вопросов</span> <span class="form-label">Вопросов</span>
<input id="ai-q-count" type="number" min="1" max="30" step="1" value="7" <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" <input id="ai-o-count" type="number" min="2" max="8" step="1" value="3"
class="form-input" style="width:90px;" /> class="form-input" style="width:90px;" />
</label> </label>
<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" <button id="ai-generate-test"
class="btn btn-ghost" type="button" style="min-height:43px;"> 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> </button>
</div> </div>
</div> </div>
{# ── Документ в вопросы ──────────────────────────────────── #}
<div class="question-editor-block test-detail-subsection test-detail-subsection--import"> <div class="question-editor-block test-detail-subsection test-detail-subsection--import">
<h3 class="test-detail-subsection__title">Документ в вопросы</h3> <h3 class="test-detail-subsection__title">Документ в вопросы</h3>
<p class="muted test-detail-hint" style="margin-top:0;"> <label id="ai-import-dropzone"
PDF, Word или текст — вставьте в черновик вопросов. class="import-dropzone mt-2 flex flex-col w-full items-center justify-center gap-1
</p> px-4 py-5 rounded-xl bg-white border-2 border-dashed border-ink-300/70
<label class="mt-2 inline-flex w-full items-center justify-center gap-2 px-3 py-3 hover:border-brand-400 hover:bg-brand-50/40 cursor-pointer transition-colors">
rounded-lg bg-white border border-ink-300/60 hover:border-brand-300 <span class="material-symbols-outlined text-2xl text-brand-400">upload_file</span>
text-sm cursor-pointer min-h-11"> <span id="ai-import-dropzone-label" class="text-sm font-medium text-ink-700">Перетащите файл сюда или нажмите</span>
<span class="material-symbols-outlined text-base text-brand-600">upload_file</span> <span class="text-xs text-ink-400">PDF, DOCX, TXT, MD · до 16 МБ</span>
<span>Загрузить документ (PDF, DOCX, TXT, MD)</span>
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" /> <input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" />
</label> </label>
<p class="mt-1.5 text-xs text-ink-500"> <label class="block mt-3">
До 16 МБ. AI извлечёт текст и предложит черновик теста. <span class="form-label">Пожелания по содержанию <span class="text-ink-400 font-normal">(необязательно)</span></span>
</p> <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> </div>
</dialog>
<p id="ai-status" class="mt-3 text-sm text-ink-500 min-h-[1.25rem]"></p> <p id="ai-status" class="mt-3 text-sm text-ink-500 min-h-[1.25rem]"></p>
</section> </section>
{# ── 3. Вопросы ─────────────────────────────────────────────── #} {# ── 3. Вопросы ─────────────────────────────────────────────── #}
<section> <section class="mt-1">
<div class="flex items-center justify-between gap-2 px-1"> <div class="flex items-center gap-2">
<h2 class="font-semibold">Вопросы (<span id="q-count-mirror">0</span>)</h2> <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" <button id="add-question"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg 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 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="material-symbols-outlined text-base">add</span>
<span class="hidden sm:inline">Добавить вопрос</span> <span>Добавить вопрос</span>
<span class="sm:hidden">Добавить</span>
</button> </button>
</div> </div>
<ol id="questions" class="mt-3 space-y-3"></ol>
</section>
</div>
</details>
<details class="cabinet-disclosure cabinet-brick" open> {# Кнопка «Сохранить» под вопросами #}
<summary class="cabinet-disclosure__summary"> <div class="mt-5 flex items-center gap-3">
<span class="cabinet-disclosure__summary-text"> <button id="save-draft-inline"
<span class="cabinet-disclosure__summary-title font-headline">История</span> class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg
<span class="cabinet-disclosure__summary-sub">Версии теста и кто проходил</span> bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11 btn btn-primary">
</span> <span class="material-symbols-outlined text-base">save</span>
</summary> Сохранить
<div class="cabinet-disclosure__body"> </button>
<div class="test-detail-subsection test-detail-subsection--tight"> <button id="btn-cancel-inline"
<h3 class="test-detail-subsection__title">Версии</h3> class="inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
<ul id="versions-list" class="version-card-list"></ul> Отмена
</div> </button>
<div class="test-detail-subsection"> <p id="save-status-inline" class="text-xs text-ink-500"></p>
<h3 class="test-detail-subsection__title">Прохождения</h3>
<ul id="attempts-list" class="attempts-card-list"></ul>
</div> </div>
</section>
</div> </div>
</details> </details>
<details class="cabinet-disclosure cabinet-brick" open> {# Прохождения перенесены на /stats. Назначения перенесены на /assignments #}
<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>
</div> </div>
{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #} {# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #}
@ -178,12 +253,16 @@
pb-[env(safe-area-inset-bottom)]"> 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 <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"> flex items-center justify-between gap-3">
<span class="text-xs text-ink-500 truncate">Активность цепочки и поля теста — в шапке.</span> <div id="intro-fork-banner" class="callout callout--warning text-xs sm:text-sm"
<div class="flex items-center gap-2 shrink-0"> data-fork-risk="{{ '1' if content.test.hasForkRisk else '0' }}"
<a href="{{ url_for('tests.tests_list_page') }}" 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;">
class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost"> При сохранении будет создана новая версия теста.
К каталогу </div>
</a> <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" <button id="save-draft"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg 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"> 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"> <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"> <div class="flex items-center justify-between gap-2">
<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 <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> bg-brand-50 text-brand-700 text-xs font-medium q-num">Вопрос #</span>
</span>
<div class="flex items-center gap-0.5"> <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" <button class="q-up p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
title="Выше" aria-label="Поднять выше"> title="Выше" aria-label="Поднять выше">
<span class="material-symbols-outlined text-base">arrow_upward</span> <span class="material-symbols-outlined text-base">arrow_upward</span>
@ -218,16 +316,13 @@
</div> </div>
</div> </div>
<div class="question-editor-block__header"> <div class="mt-2 relative">
<h4 class="question-editor-block__title q-num">Вопрос #</h4> <textarea class="q-text w-full rounded-lg border border-ink-300 px-3 py-2
<button class="q-ai btn btn-ghost btn--sm question-editor-block__ai-btn">
Сгенерировать вопрос (ИИ)
</button>
</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" focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
rows="2" placeholder="Формулировка вопроса"></textarea> 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>
<div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm"> <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"> <label class="inline-flex items-center gap-2 min-h-9">
@ -235,38 +330,66 @@
class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
<span>Несколько правильных ответов</span> <span>Несколько правильных ответов</span>
</label> </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> </div>
<ul class="q-options mt-3 space-y-2"></ul> <p class="mt-4 mb-2 text-xs text-ink-400 font-medium">Отметьте правильные варианты</p>
<button class="q-add-option mt-2 inline-flex items-center gap-1 px-2 py-2 rounded <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"> 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="material-symbols-outlined text-base">add</span>
Добавить вариант <span class="q-add-option-label">Добавить вариант</span>
</button> </button>
<span class="q-options-count text-xs text-ink-400"></span>
</div>
</li> </li>
</template> </template>
{# ── Шаблон варианта ────────────────────────────────────────────── #} {# ── Шаблон варианта ────────────────────────────────────────────── #}
<template id="tpl-option"> <template id="tpl-option">
<li class="flex items-center gap-2 opt-item question-option-row"> <li class="flex items-start gap-2 opt-item">
{# Чекбокс «Правильный» — обёрнут в большой tap-target. #} {# Чекбокс «Правильный» — выровнен по первой строке textarea #}
<label class="inline-flex items-center justify-center w-10 h-10 shrink-0 cursor-pointer <label class="shrink-0 w-10 inline-flex items-center justify-center cursor-pointer
rounded hover:bg-ink-100" title="Правильный ответ"> rounded hover:bg-ink-100 pt-1.5" style="min-height:2.5rem;" title="Правильный ответ">
<input type="checkbox" <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> </label>
<input type="text" <textarea rows="1"
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2 question-option-row__text 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" focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
placeholder="Вариант ответа" /> placeholder="Вариант ответа"
<button class="opt-delete shrink-0 w-10 h-10 inline-flex items-center justify-center style="resize:none; overflow:hidden; font-family:inherit; line-height:1.55;"></textarea>
rounded hover:bg-red-50 text-red-600 question-option-remove" <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="Удалить вариант"> title="Удалить" aria-label="Удалить вариант">
<span class="material-symbols-outlined text-base">close</span> <span class="material-symbols-outlined text-base">close</span>
</button> </button>
</li> </li>
</template> </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 на мобиле) ── #} {# ── Модалка результата AI-проверки/улучшения (fullscreen на мобиле) ── #}
<dialog id="ai-modal" <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] class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-3xl sm:w-full sm:max-h-[90vh]

12
flask_app/app/templates/tests/list.html

@ -20,7 +20,7 @@
<span class="list-row__title">{{ t.title }}</span> <span class="list-row__title">{{ t.title }}</span>
<span class="list-row__meta"> <span class="list-row__meta">
{{ t.author_full_name or '—' }} {{ 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> </span>
</a> </a>
</div> </div>
@ -44,7 +44,7 @@
<span class="list-row__title">{{ t.title }}</span> <span class="list-row__title">{{ t.title }}</span>
<span class="list-row__meta"> <span class="list-row__meta">
{{ t.author_full_name or '—' }} {{ 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> </span>
</a> </a>
</div> </div>
@ -61,7 +61,7 @@
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1> <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> </div>
<button id="btn-create-test" <button id="btn-create-test"
class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg 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"> class="block p-4 active:bg-ink-100/40">
<div class="flex items-start justify-between gap-2"> <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> <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> </div>
{% if t.description %} {% if t.description %}
<p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p> <p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p>
{% endif %} {% endif %}
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500"> <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="inline-flex items-center gap-1 text-brand-700">
<span class="material-symbols-outlined text-sm">edit_note</span> <span class="material-symbols-outlined text-sm">edit_note</span>
Открыть Открыть
@ -103,7 +103,7 @@
{% if hidden %} {% if hidden %}
<details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4"> <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"> <summary class="cursor-pointer font-medium text-ink-700">
Скрытые вами цепочки ({{ hidden|length }}) Скрытые из каталога ({{ hidden|length }})
</summary> </summary>
<ul class="mt-3 space-y-2"> <ul class="mt-3 space-y-2">
{% for t in hidden %} {% for t in hidden %}

299
flask_app/app/tests/routes.py

@ -19,13 +19,16 @@ UI-страницы:
from __future__ import annotations from __future__ import annotations
import logging import logging
import uuid as _uuid
from flask import Blueprint, jsonify, render_template, request 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 ..auth.decorators import current_user, login_required
from ..db import get_engine from ..db import get_session
from ..messages import RU from ..messages import RU
from ..models import Test, TestVersion, User
from ..services.ai_editor import ( from ..services.ai_editor import (
HttpError as AiHttpError, HttpError as AiHttpError,
check_test_quality, 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.editor_content import HttpError as EditorHttpError, get_editor_content
from ..services.test_attempt import ( from ..services.test_attempt import (
HttpError as AttemptHttpError, HttpError as AttemptHttpError,
check_question_for_attempt,
count_missing_hints,
generate_missing_hints_for_test,
get_attempt_review_for_user, get_attempt_review_for_user,
get_play_content, get_play_content,
list_test_attempts_for_author, list_test_attempts_for_author,
@ -63,10 +69,9 @@ log = logging.getLogger(__name__)
tests_bp = Blueprint('tests', __name__) tests_bp = Blueprint('tests', __name__)
# ─── helpers ───────────────────────────────────────────────────────── # ─── helpers ─────────────────────────────────────────────────────────────────
def _stringify_uuids(d: dict) -> dict: def _stringify_uuids(d: dict) -> dict:
"""Преобразует UUID-поля в строки для безопасной JSON-сериализации."""
out = {} out = {}
for k, v in d.items(): for k, v in d.items():
if hasattr(v, 'hex') and not isinstance(v, (str, bytes)): if hasattr(v, 'hex') and not isinstance(v, (str, bytes)):
@ -76,26 +81,29 @@ def _stringify_uuids(d: dict) -> dict:
return out return out
def _check_test_author_or_404(test_id: str, user_id: str) -> dict: def _to_uuid(val) -> _uuid.UUID | None:
"""Загружает {id, created_by}; 404 если нет, 403 если не автор.""" if isinstance(val, _uuid.UUID):
eng = get_engine() return val
with eng.connect() as conn: try:
row = conn.execute( return _uuid.UUID(str(val))
text('SELECT id, created_by FROM tests WHERE id = :id'), except (ValueError, AttributeError):
{'id': test_id}, return None
).mappings().first()
if not row:
from werkzeug.exceptions import NotFound
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']) 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 from werkzeug.exceptions import Forbidden
raise 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'])
@tests_bp.route('/api/tests', methods=['GET']) @tests_bp.route('/api/tests', methods=['GET'])
@ -127,58 +135,50 @@ def api_create_test():
@login_required @login_required
def api_test_summary(test_id): def api_test_summary(test_id):
user = current_user() user = current_user()
eng = get_engine() session = get_session()
with eng.connect() as conn: tid = _to_uuid(test_id)
row = conn.execute( if not tid:
text( return jsonify(error=RU['notFound']), 404
"""
SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active, test = (
t.created_by, t.created_at, t.updated_at, session.query(Test)
tv.id AS active_version_id, tv.version, .options(selectinload(Test.author), selectinload(Test.versions))
u.full_name AS author_full_name .filter(Test.id == tid)
FROM tests t .first()
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 if not test:
WHERE t.id = :id
"""
),
{'id': test_id},
).mappings().first()
if not row:
return jsonify(error=RU['notFound']), 404 return jsonify(error=RU['notFound']), 404
is_author = is_test_author(row['created_by'], user.id) is_author = is_test_author(test.created_by, user.id)
if not row['chain_active'] and not is_author: if not test.is_active and not is_author:
return jsonify(error=RU['notFound']), 404 return jsonify(error=RU['notFound']), 404
if not is_author: if not is_author:
from ..services.test_access import user_has_test_access from ..services.test_access import user_has_test_access
acc = user_has_test_access(user.id, test_id) acc = user_has_test_access(user.id, test_id)
if not acc.ok: if not acc.ok:
return jsonify(error=RU['notFound']), 404 return jsonify(error=RU['notFound']), 404
has_attempts = False active_version = next((v for v in test.versions if v.is_active), None)
with eng.connect() as conn: has_attempts = has_any_attempt_for_test(session, tid)
has_attempts = has_any_attempt_for_test(conn, test_id)
return jsonify( return jsonify(
test={ test={
'id': str(row['id']), 'id': str(test.id),
'title': row['title'], 'title': test.title,
'description': row['description'], 'description': test.description,
'passingThreshold': row['passing_threshold'], 'passingThreshold': test.passing_threshold,
'chainActive': row['chain_active'], 'chainActive': test.is_active,
'activeVersionId': str(row['active_version_id']) if row['active_version_id'] else None, 'activeVersionId': str(active_version.id) if active_version else None,
'version': row['version'], 'version': active_version.version if active_version else None,
'createdAt': row['created_at'].isoformat() if row['created_at'] else None, 'createdAt': test.created_at.isoformat() if test.created_at else None,
'updatedAt': row['updated_at'].isoformat() if row['updated_at'] else None, 'updatedAt': test.updated_at.isoformat() if test.updated_at else None,
'createdBy': str(row['created_by']) if row['created_by'] else None, 'createdBy': str(test.created_by) if test.created_by else None,
'authorFullName': row['author_full_name'], 'authorFullName': test.author.full_name if test.author else None,
'hasAttempts': bool(has_attempts), 'hasAttempts': bool(has_attempts),
}, },
isAuthor=is_author, 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 @login_required
def api_test_versions(test_id): def api_test_versions(test_id):
user = current_user() user = current_user()
eng = get_engine() session = get_session()
with eng.connect() as conn: tid = _to_uuid(test_id)
t = conn.execute( if not tid:
text( return jsonify(error=RU['notFound']), 404
"""
SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at, test = (
t.description, u.full_name AS author_full_name session.query(Test)
FROM tests t .options(selectinload(Test.author), selectinload(Test.versions))
INNER JOIN users u ON u.id = t.created_by .filter(Test.id == tid)
WHERE t.id = :id .first()
""" )
), if not test:
{'id': test_id},
).mappings().first()
if not t:
return jsonify(error=RU['notFound']), 404 return jsonify(error=RU['notFound']), 404
if not is_test_author(t['created_by'], user.id): if not is_test_author(test.created_by, user.id):
return jsonify(error='Доступ запрещён.'), 403 return jsonify(error='Доступ запрещён.'), 403
rows = conn.execute( has_attempts = has_any_attempt_for_test(session, tid)
text( sorted_versions = sorted(test.versions, key=lambda v: v.version)
'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)
return jsonify( return jsonify(
test={ test={
'id': str(t['id']), 'id': str(test.id),
'title': t['title'], 'title': test.title,
'description': t['description'], 'description': test.description,
'chainActive': t['is_active'], 'chainActive': test.is_active,
'createdAt': t['created_at'].isoformat() if t['created_at'] else None, 'createdAt': test.created_at.isoformat() if test.created_at else None,
'updatedAt': t['updated_at'].isoformat() if t['updated_at'] else None, 'updatedAt': test.updated_at.isoformat() if test.updated_at else None,
'createdBy': str(t['created_by']) if t['created_by'] else None, 'createdBy': str(test.created_by) if test.created_by else None,
'authorFullName': t['author_full_name'], 'authorFullName': test.author.full_name if test.author else None,
}, },
versions=[ versions=[
{ {
'id': str(r['id']), 'id': str(v.id),
'version': r['version'], 'version': v.version,
'is_active': r['is_active'], 'is_active': v.is_active,
'parent_id': str(r['parent_id']) if r['parent_id'] else None, 'parent_id': str(v.parent_id) if v.parent_id else None,
'created_at': r['created_at'].isoformat() if r['created_at'] 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, hasAttempts=has_attempts,
) )
@ -267,22 +258,22 @@ def api_save_draft(test_id):
def api_activate_version(test_id, version_id): def api_activate_version(test_id, version_id):
user = current_user() user = current_user()
_check_test_author_or_404(test_id, user.id) _check_test_author_or_404(test_id, user.id)
eng = get_engine()
with eng.begin() as conn: session = get_session()
v = conn.execute( tid = _to_uuid(test_id)
text('SELECT id FROM test_versions WHERE test_id = :t AND id = :v'), vid = _to_uuid(version_id)
{'t': test_id, 'v': version_id},
version = session.query(TestVersion).filter(
TestVersion.test_id == tid, TestVersion.id == vid
).first() ).first()
if not v: if not version:
return jsonify(error='Версия не найдена.'), 404 return jsonify(error='Версия не найдена.'), 404
conn.execute(
text('UPDATE test_versions SET is_active = false WHERE test_id = :t'), session.query(TestVersion).filter(TestVersion.test_id == tid).update(
{'t': test_id}, {TestVersion.is_active: False}, synchronize_session='fetch'
)
conn.execute(
text('UPDATE test_versions SET is_active = true WHERE id = :v'),
{'v': version_id},
) )
version.is_active = True
session.commit()
return jsonify(ok=True, activeVersionId=str(version_id)) 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')) chain = body.get('chainActive', body.get('isActive'))
if not isinstance(chain, bool): if not isinstance(chain, bool):
return jsonify(error='Передайте chainActive: true/false в теле запроса.'), 400 return jsonify(error='Передайте chainActive: true/false в теле запроса.'), 400
_check_test_author_or_404(test_id, user.id) test = _check_test_author_or_404(test_id, user.id)
eng = get_engine() session = get_session()
with eng.begin() as conn: test.is_active = chain
conn.execute( session.commit()
text(
'UPDATE tests SET is_active = :v, updated_at = CURRENT_TIMESTAMP WHERE id = :id'
),
{'v': chain, 'id': test_id},
)
return jsonify(id=test_id, chainActive=chain) return jsonify(id=test_id, chainActive=chain)
@ -310,9 +296,8 @@ def api_patch_test(test_id):
@login_required @login_required
def api_start_attempt(test_id): def api_start_attempt(test_id):
user = current_user() user = current_user()
eng = get_engine()
try: try:
out = start_attempt(eng, user.id, test_id) out = start_attempt(None, user.id, test_id)
except AttemptHttpError as e: except AttemptHttpError as e:
return jsonify(error=e.message), e.status return jsonify(error=e.message), e.status
return jsonify(out), 201 return jsonify(out), 201
@ -322,9 +307,8 @@ def api_start_attempt(test_id):
@login_required @login_required
def api_attempt_play(test_id, attempt_id): def api_attempt_play(test_id, attempt_id):
user = current_user() user = current_user()
eng = get_engine()
try: 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: except AttemptHttpError as e:
return jsonify(error=e.message), e.status return jsonify(error=e.message), e.status
return jsonify(out) return jsonify(out)
@ -334,10 +318,45 @@ def api_attempt_play(test_id, attempt_id):
@login_required @login_required
def api_attempt_submit(test_id, attempt_id): def api_attempt_submit(test_id, attempt_id):
user = current_user() user = current_user()
eng = get_engine()
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
try: 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: except AttemptHttpError as e:
return jsonify(error=e.message), e.status return jsonify(error=e.message), e.status
return jsonify(out) return jsonify(out)
@ -347,9 +366,8 @@ def api_attempt_submit(test_id, attempt_id):
@login_required @login_required
def api_attempt_review(test_id, attempt_id): def api_attempt_review(test_id, attempt_id):
user = current_user() user = current_user()
eng = get_engine()
try: 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: except AttemptHttpError as e:
return jsonify(error=e.message), e.status return jsonify(error=e.message), e.status
return jsonify(out) return jsonify(out)
@ -359,9 +377,8 @@ def api_attempt_review(test_id, attempt_id):
@login_required @login_required
def api_attempts_list(test_id): def api_attempts_list(test_id):
user = current_user() user = current_user()
eng = get_engine()
try: 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: except AttemptHttpError as e:
return jsonify(error=e.message), e.status return jsonify(error=e.message), e.status
return jsonify( 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']) @tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST'])
@login_required @login_required
@ -418,16 +435,15 @@ def api_ai_generate_question(test_id):
body.get('questionText') or '', body.get('questionText') or '',
body.get('optionsCount'), body.get('optionsCount'),
bool(body.get('hasMultipleAnswers')), bool(body.get('hasMultipleAnswers')),
mode=body.get('mode'),
existing_options=body.get('existingOptions'),
) )
except (AiHttpError, LlmError) as e: except (AiHttpError, LlmError) as e:
return _ai_error_response(e) return _ai_error_response(e)
return jsonify(ok=True, **out) return jsonify(ok=True, **out)
# ─── AI v2 (E1.8) ────────────────────────────────────────────────────
def _ai_error_response(e): def _ai_error_response(e):
"""Единый JSON-формат ошибки для AI-эндпоинтов."""
if isinstance(e, AiHttpError): if isinstance(e, AiHttpError):
return jsonify(error=e.message), e.status return jsonify(error=e.message), e.status
if isinstance(e, LlmError): if isinstance(e, LlmError):
@ -495,15 +511,12 @@ def api_ai_improve_test(test_id):
return jsonify(ok=True, **out) return jsonify(ok=True, **out)
# ─── Импорт документа (E1.3) ──────────────────────────────────────── # ─── Импорт документа (E1.3) ─────────────────────────────────────────────────
@tests_bp.route('/api/tests/import/document', methods=['POST']) @tests_bp.route('/api/tests/import/document', methods=['POST'])
@login_required @login_required
def api_import_document(): def api_import_document():
"""PDF/DOCX/TXT/MD → извлечённый текст + AI-черновик (если задан LLM-ключ). """Шаг 1: загрузить файл и извлечь текст. Генерация — отдельным запросом."""
Ограничения: размер файла `MAX_CONTENT_LENGTH = 16 МБ` (см. фабрику).
"""
f = request.files.get('file') f = request.files.get('file')
if f is None or not f.filename: if f is None or not f.filename:
return jsonify(error='Прикрепите файл к полю file.'), 400 return jsonify(error='Прикрепите файл к полю file.'), 400
@ -515,7 +528,6 @@ def api_import_document():
log.exception('extract_text_from_file failed') log.exception('extract_text_from_file failed')
return jsonify(error='Не удалось разобрать файл.'), 500 return jsonify(error='Не удалось разобрать файл.'), 500
generation = generation_for_import_document(extracted)
return jsonify( return jsonify(
received=True, received=True,
originalName=f.filename, originalName=f.filename,
@ -523,11 +535,23 @@ def api_import_document():
size=len(extracted.encode('utf-8')), size=len(extracted.encode('utf-8')),
extractedText=extracted, extractedText=extracted,
textLength=len(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']) @tests_bp.route('/tests', methods=['GET'])
@login_required @login_required
@ -567,9 +591,8 @@ def tests_attempt_page(test_id, attempt_id):
@login_required @login_required
def tests_attempt_review_page(test_id, attempt_id): def tests_attempt_review_page(test_id, attempt_id):
user = current_user() user = current_user()
eng = get_engine()
try: 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: except AttemptHttpError as e:
if e.status == 404: if e.status == 404:
return render_template('404.html'), 404 return render_template('404.html'), 404

1
flask_app/requirements.txt

@ -6,6 +6,7 @@ waitress>=3.0.0
# SQLAlchemy + psycopg2 драйвер для PostgreSQL. # SQLAlchemy + psycopg2 драйвер для PostgreSQL.
SQLAlchemy>=2.0.0,<3 SQLAlchemy>=2.0.0,<3
psycopg2-binary>=2.9.0,<3 psycopg2-binary>=2.9.0,<3
alembic>=1.13.0,<2
# Этап 1 (E1.1): авторизация. bcrypt — для локальных хешей в clinic_tests.users. # Этап 1 (E1.1): авторизация. bcrypt — для локальных хешей в clinic_tests.users.
# Werkzeug-хеши (scrypt/pbkdf2) проверяет встроенный werkzeug.security. # Werkzeug-хеши (scrypt/pbkdf2) проверяет встроенный werkzeug.security.

Loading…
Cancel
Save