diff --git a/.DS_Store b/.DS_Store index 5d52eee..c1ca77e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 14614ea..7b17369 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -32,6 +32,7 @@ services: HR_AUTH: ${HR_AUTH:-1} HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test} UI_VARIANT: ${UI_VARIANT_MODERN:-modern} + DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-} # LLM (E1.2/E1.3/E1.8): один общий ключ, читается из .env проекта. DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} @@ -59,6 +60,7 @@ services: HR_AUTH: ${HR_AUTH:-1} HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test} UI_VARIANT: ${UI_VARIANT_LEGACY:-legacy} + DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} LLM_BASE_URL: ${LLM_BASE_URL:-} diff --git a/flask_app/alembic.ini b/flask_app/alembic.ini new file mode 100644 index 0000000..ee832ac --- /dev/null +++ b/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 diff --git a/flask_app/alembic/env.py b/flask_app/alembic/env.py new file mode 100644 index 0000000..a4945d1 --- /dev/null +++ b/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() diff --git a/flask_app/alembic/script.py.mako b/flask_app/alembic/script.py.mako new file mode 100644 index 0000000..b1f8b89 --- /dev/null +++ b/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"} diff --git a/flask_app/alembic/versions/0001_baseline.py b/flask_app/alembic/versions/0001_baseline.py new file mode 100644 index 0000000..151ec48 --- /dev/null +++ b/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 diff --git a/flask_app/app/__init__.py b/flask_app/app/__init__.py index e7af15b..c26aaa6 100644 --- a/flask_app/app/__init__.py +++ b/flask_app/app/__init__.py @@ -69,6 +69,12 @@ def create_app() -> Flask: app.register_blueprint(tests_bp) app.register_blueprint(settings_bp) + from . import db as _db + + @app.teardown_appcontext + def _shutdown_session(exc): # noqa: ARG001 + _db.remove_session() + from .config import is_assignment_feature_enabled, is_dev_ui, is_hr_auth_enabled from .auth.decorators import current_user as _current_user diff --git a/flask_app/app/auth/routes.py b/flask_app/app/auth/routes.py index 591341e..03b4879 100644 --- a/flask_app/app/auth/routes.py +++ b/flask_app/app/auth/routes.py @@ -14,7 +14,7 @@ from flask import ( url_for, ) -from ..config import is_assignment_feature_enabled, is_dev_ui +from ..config import get_dev_fio_password, is_assignment_feature_enabled, is_dev_ui from ..messages import RU from .decorators import login_required, current_user from .services import AuthError, authenticate_credentials @@ -46,7 +46,11 @@ def _do_login(login: str, password: str): def login_page(): if current_user() is not None: return redirect(_safe_next('/')) - return render_template('auth/login.html', next=_safe_next('/')) + return render_template( + 'auth/login.html', + next=_safe_next('/'), + dev_fio_enabled=bool(get_dev_fio_password()), + ) @auth_bp.route('/login', methods=['POST']) @@ -57,11 +61,21 @@ def login_submit(): _do_login(login, password) except AuthError as e: flash(e.message, 'error') - return render_template('auth/login.html', next=_safe_next('/'), login=login), e.status + return render_template( + 'auth/login.html', + next=_safe_next('/'), + login=login, + dev_fio_enabled=bool(get_dev_fio_password()), + ), e.status except Exception: log.exception('login_submit failed') flash(RU['loginFailed'], 'error') - return render_template('auth/login.html', next=_safe_next('/'), login=login), 500 + return render_template( + 'auth/login.html', + next=_safe_next('/'), + login=login, + dev_fio_enabled=bool(get_dev_fio_password()), + ), 500 return redirect(_safe_next('/')) @@ -96,6 +110,56 @@ def api_logout(): return jsonify(message=RU['loggedOut']) +@auth_bp.route('/api/auth/dev/assignment-directory', methods=['GET']) +@login_required +def api_assignment_directory(): + """Список сотрудников для назначения теста с поиском по имени/логину и фильтром отдела.""" + from ..db import get_session + from ..models import Department, User as UserModel + + q_str = (request.args.get('q') or '').strip() + dept_filter = (request.args.get('department') or '').strip() + clinic_filter = (request.args.get('clinic') or 'all').strip() + + session = get_session() + + dept_rows = session.query(Department).order_by(Department.name).all() + dept_by_name = {d.name: d.id for d in dept_rows} + departments = [d.name for d in dept_rows] + + query = ( + session.query(UserModel) + .filter(UserModel.is_active.is_(True)) + ) + if q_str: + like = f'%{q_str}%' + query = query.filter( + UserModel.full_name.ilike(like) | UserModel.login.ilike(like) + ) + if dept_filter and dept_filter in dept_by_name: + query = query.filter(UserModel.department_id == dept_by_name[dept_filter]) + if clinic_filter == 'with': + query = query.filter(UserModel.staff_id.isnot(None)) + elif clinic_filter == 'without': + query = query.filter(UserModel.staff_id.is_(None)) + + users = query.order_by(UserModel.full_name).limit(200).all() + + dept_name_by_id = {d.id: d.name for d in dept_rows} + people = [ + { + 'staffId': str(u.id), + 'fio': u.full_name, + 'webLogin': u.login, + 'role': u.role, + 'department': dept_name_by_id.get(u.department_id) if u.department_id else None, + 'clinicUserId': u.staff_id, + } + for u in users + ] + return jsonify(people=people, departments=departments) + + @auth_bp.route('/api/auth/me', methods=['GET']) @login_required def api_me(): diff --git a/flask_app/app/auth/services.py b/flask_app/app/auth/services.py index 451e7bf..0981793 100644 --- a/flask_app/app/auth/services.py +++ b/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`. -- HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (Werkzeug scrypt/pbkdf2), - затем находим запись `staff_members` по `web_login` и UPSERT-им в - `clinic_tests.users` по `staff_id`. - -Werkzeug-хеши проверяются через `werkzeug.security.check_password_hash`, -bcrypt-хеши — через пакет `bcrypt`. +- Локальный (по умолчанию): bcrypt / Werkzeug в `clinic_tests.users.password_hash`. +- HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (raw SQL, внешняя схема), + затем UPSERT в `clinic_tests.users` по `staff_id`. """ from __future__ import annotations +import re from dataclasses import dataclass from typing import Optional @@ -20,16 +17,18 @@ from werkzeug.security import check_password_hash as _werkzeug_check from ..config import ( HR_MANAGED_PASSWORD_PLACEHOLDER, + get_dev_fio_password, is_hr_auth_enabled, ) -from ..db import get_engine, get_hr_engine +from ..db import get_engine, get_session from ..messages import RU +from ..models import User from .hr_role import map_hr_role_to_app @dataclass class AuthUser: - id: str # UUID в виде строки + id: str login: str full_name: str | None role: str @@ -37,20 +36,17 @@ class AuthUser: staff_id: Optional[int] def to_public_dict(self) -> dict: - out = { + return { 'id': str(self.id), 'login': self.login, 'fullName': self.full_name, 'role': self.role, - 'departmentId': self.department_id, + 'departmentId': str(self.department_id) if self.department_id else None, + 'staffId': self.staff_id, } - out['staffId'] = self.staff_id - return out class AuthError(Exception): - """Ошибка авторизации с HTTP-кодом и сообщением для пользователя.""" - def __init__(self, status: int, message: str) -> None: super().__init__(message) self.status = status @@ -58,7 +54,6 @@ class AuthError(Exception): def _verify_password(plain: str, hashed: str | None) -> bool: - """Универсальная проверка пароля: bcrypt + Werkzeug (scrypt/pbkdf2).""" if not hashed: return False if hashed == HR_MANAGED_PASSWORD_PLACEHOLDER: @@ -79,53 +74,136 @@ def _verify_password(plain: str, hashed: str | None) -> bool: return False +def _user_to_auth(user: User) -> AuthUser: + return AuthUser( + id=str(user.id), + login=user.login, + full_name=user.full_name, + role=user.role, + department_id=str(user.department_id) if user.department_id else None, + staff_id=user.staff_id, + ) + + +def _normalize_fio(s: str) -> str: + """Приводим ФИО к одному виду: trim, пробелы, неразрывный пробел, много переводов строк.""" + t = str(s or '') + t = t.replace('\u00a0', ' ') + return re.sub(r'\s+', ' ', t).strip() + + def authenticate_credentials(login: str, password: str) -> AuthUser: - """Главная точка входа. Возвращает AuthUser или поднимает AuthError.""" login = (login or '').strip() password = password or '' if not login or not password: raise AuthError(400, RU['loginAndPasswordRequired']) + # Dev-режим: вход по ФИО из HR с общим паролем + dev_pw = get_dev_fio_password() + if dev_pw and password.strip() == dev_pw.strip(): + try: + return _authenticate_dev_fio(login) + except AuthError: + pass # если ФИО не найдено — продолжаем обычную проверку + if is_hr_auth_enabled(): return _authenticate_via_hr(login, password) return _authenticate_local(login, password) -# ─── локальный режим ──────────────────────────────────────────────── +def _authenticate_dev_fio(fio_raw: str) -> AuthUser: + """Dev-режим: найти сотрудника по ФИО в HR-системе и создать/обновить локального пользователя.""" + from ..db import get_hr_engine + hr_eng = get_hr_engine() + if hr_eng is None: + raise AuthError(500, RU['hrDatabaseUrlMissing']) + + fio_needle = _normalize_fio(fio_raw) + if not fio_needle: + raise AuthError(401, RU['invalidCredentials']) + + needle = fio_needle.lower() -def _authenticate_local(login: str, password: str) -> AuthUser: - eng = get_engine() - with eng.connect() as conn: - row = conn.execute( + with hr_eng.connect() as hr_conn: + rows = hr_conn.execute( text( - 'SELECT id, login, password_hash, full_name, role, department_id, staff_id ' - 'FROM users WHERE login = :login AND is_active = true' + """ + 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 + """ ), - {'login': login}, - ).mappings().first() + {'needle': needle}, + ).mappings().all() + if len(rows) != 1: + raise AuthError(401, RU['invalidCredentials']) + s = rows[0] - if not row: - raise AuthError(401, RU['invalidCredentials']) + web_login = (s['web_login'] or '').strip() or f'fio_{s["id"]}' - if row['password_hash'] == HR_MANAGED_PASSWORD_PLACEHOLDER: - raise AuthError(401, RU['useHrLogin']) + staff_id = int(s['id']) + full_name = s['fio'] or fio_raw + + session = get_session() + + user = session.query(User).filter(User.staff_id == staff_id).first() + if not user: + # Не переиспользовать строку другого человека только по совпадению логина HR + occupied = session.query(User).filter(User.login == web_login).first() + login_val = web_login + if occupied and occupied.staff_id != staff_id: + login_val = f'hr_staff_{staff_id}' + user = User( + login=login_val, + password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER, + full_name=full_name, + role='employee', + is_active=True, + staff_id=staff_id, + ) + session.add(user) + else: + user.full_name = full_name + user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER + user.is_active = True + # Обновить логин с HR только если конфликта нет + if web_login != user.login: + taken = session.query(User).filter( + User.login == web_login, + User.id != user.id, + ).first() + if not taken: + user.login = web_login + + session.commit() + session.refresh(user) + return _user_to_auth(user) - if not _verify_password(password, row['password_hash']): - raise AuthError(401, RU['invalidCredentials']) - return AuthUser( - id=str(row['id']), - login=row['login'], - full_name=row['full_name'], - role=row['role'], - department_id=row['department_id'], - staff_id=row['staff_id'], +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: + from ..db import get_hr_engine hr_eng = get_hr_engine() if hr_eng is None: raise AuthError(500, RU['hrDatabaseUrlMissing']) @@ -157,61 +235,48 @@ def _authenticate_via_hr(login: str, password: str) -> AuthUser: fio = s['fio'] or login app_role = map_hr_role_to_app(u['role']) - eng = get_engine() - with eng.begin() as conn: - row = conn.execute( - text( - """ - INSERT INTO users (login, password_hash, full_name, role, - department_id, is_active, staff_id) - VALUES (:login, :ph, :fn, :role, NULL, true, :staff_id) - ON CONFLICT (staff_id) DO UPDATE SET - login = EXCLUDED.login, - full_name = EXCLUDED.full_name, - role = EXCLUDED.role, - password_hash = EXCLUDED.password_hash - RETURNING id, login, full_name, role, department_id, staff_id - """ - ), - { - 'login': login, - 'ph': HR_MANAGED_PASSWORD_PLACEHOLDER, - 'fn': fio, - 'role': app_role, - 'staff_id': staff_id, - }, - ).mappings().first() - - return AuthUser( - id=str(row['id']), - login=row['login'], - full_name=row['full_name'], - role=row['role'], - department_id=row['department_id'], - staff_id=row['staff_id'], - ) + # UPSERT через ORM: ищем по staff_id, затем по login, затем создаём + session = get_session() + user = session.query(User).filter(User.staff_id == staff_id).first() + if not user: + # при первом входе staff_id ещё не проставлен — ищем по login + user = session.query(User).filter(User.login == login).first() + if user: + user.login = login + user.full_name = fio + user.role = app_role + user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER + user.staff_id = staff_id + user.is_active = True + else: + user = User( + login=login, + password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER, + full_name=fio, + role=app_role, + is_active=True, + staff_id=staff_id, + ) + session.add(user) + session.commit() + session.refresh(user) + return _user_to_auth(user) def load_user_by_id(user_id: str) -> Optional[AuthUser]: - """Догружает пользователя из `clinic_tests.users` (используется при каждом запросе).""" if not user_id: return None - eng = get_engine() - with eng.connect() as conn: - row = conn.execute( - text( - 'SELECT id, login, full_name, role, department_id, staff_id ' - 'FROM users WHERE id = :id AND is_active = true' - ), - {'id': user_id}, - ).mappings().first() - if not row: + session = get_session() + try: + import uuid as _uuid + uid = _uuid.UUID(user_id) + except (ValueError, AttributeError): return None - return AuthUser( - id=str(row['id']), - login=row['login'], - full_name=row['full_name'], - role=row['role'], - department_id=row['department_id'], - staff_id=row['staff_id'], + user = ( + session.query(User) + .filter(User.id == uid, User.is_active.is_(True)) + .first() ) + if not user: + return None + return _user_to_auth(user) diff --git a/flask_app/app/blueprints/main.py b/flask_app/app/blueprints/main.py index dfb3534..1d642e4 100644 --- a/flask_app/app/blueprints/main.py +++ b/flask_app/app/blueprints/main.py @@ -1,18 +1,117 @@ -"""Главный blueprint — посадочная страница и health-чек. - -В E1.0 здесь нет бизнес-логики; страницы и эндпоинты добавляются в следующих -спринтах (E1.1 — auth, E1.2 — тесты, и т.д.). -""" +"""Главный blueprint — посадочная страница, статистика и health-чек.""" from __future__ import annotations from flask import Blueprint, jsonify, render_template from .. import db as app_db from ..auth.decorators import login_required +from ..auth.decorators import current_user as get_current_user main_bp = Blueprint('main', __name__) +def _get_stats() -> dict: + """Собирает статистику прохождений для дашборда.""" + from sqlalchemy import func, case + from ..db import get_session + from ..models import Department, User, Test, TestAttempt + + session = get_session() + try: + total_tests = session.query(func.count(Test.id)).scalar() or 0 + active_tests = session.query(func.count(Test.id)).filter(Test.is_active.is_(True)).scalar() or 0 + + total_completed = ( + session.query(func.count(TestAttempt.id)) + .filter(TestAttempt.status == 'completed') + .scalar() or 0 + ) + total_passed = ( + session.query(func.count(TestAttempt.id)) + .filter(TestAttempt.status == 'completed', TestAttempt.passed.is_(True)) + .scalar() or 0 + ) + total_users = session.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0 + + # Статистика по отделам + dept_rows = ( + session.query( + Department.name, + func.count(TestAttempt.id).label('total'), + func.sum( + case((TestAttempt.passed.is_(True), 1), else_=0) + ).label('passed_count'), + ) + .join(User, User.department_id == Department.id) + .join(TestAttempt, TestAttempt.user_id == User.id) + .filter(TestAttempt.status == 'completed') + .group_by(Department.id, Department.name) + .order_by(Department.name) + .all() + ) + + from ..models import TestVersion + recent_rows = ( + session.query( + TestAttempt.started_at, + TestAttempt.completed_at, + TestAttempt.passed, + TestAttempt.correct_count, + TestAttempt.total_questions, + User.full_name, + User.login, + Test.title.label('test_title'), + ) + .join(User, User.id == TestAttempt.user_id) + .join(TestVersion, TestVersion.id == TestAttempt.test_version_id) + .join(Test, Test.id == TestVersion.test_id) + .filter(TestAttempt.status == 'completed') + .order_by(TestAttempt.completed_at.desc()) + .limit(10) + .all() + ) + + dept_stats = [ + { + 'name': r.name, + 'total': r.total, + 'passed': int(r.passed_count or 0), + 'rate': round(100 * int(r.passed_count or 0) / r.total) if r.total else 0, + } + for r in dept_rows + ] + + recent_list = [ + { + 'user': r.full_name or r.login, + 'test': r.test_title, + 'passed': r.passed, + 'score': f'{r.correct_count}/{r.total_questions}' if r.correct_count is not None else '—', + 'at': r.completed_at.strftime('%d.%m %H:%M') if r.completed_at else '—', + } + for r in recent_rows + ] + + pass_rate = round(100 * total_passed / total_completed) if total_completed else 0 + + return { + 'total_tests': total_tests, + 'active_tests': active_tests, + 'total_completed': total_completed, + 'total_passed': total_passed, + 'total_users': total_users, + 'pass_rate': pass_rate, + 'dept_stats': dept_stats, + 'recent': recent_list, + } + except Exception: + return { + 'total_tests': 0, 'active_tests': 0, 'total_completed': 0, + 'total_passed': 0, 'total_users': 0, 'pass_rate': 0, + 'dept_stats': [], 'recent': [], + } + + @main_bp.route('/health') def health(): """Smoke-проверка приложения и подключений к БД (без авторизации).""" @@ -29,3 +128,29 @@ def health(): @login_required def index(): return render_template('index.html') + + +@main_bp.route('/stats') +@login_required +def stats_page(): + return render_template('stats.html', stats=_get_stats()) + + +@main_bp.route('/assignments') +@login_required +def assignments_page(): + from ..db import get_session + from ..models import Test, TestVersion + from sqlalchemy.orm import selectinload + session = get_session() + tests = ( + session.query(Test) + .join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True)) + .filter(Test.is_active.is_(True)) + .order_by(Test.title) + .all() + ) + return render_template('assignments.html', tests=[ + {'id': str(t.id), 'title': t.title or '(без названия)'} + for t in tests + ]) diff --git a/flask_app/app/blueprints/settings.py b/flask_app/app/blueprints/settings.py index 54fe3c8..19465b5 100644 --- a/flask_app/app/blueprints/settings.py +++ b/flask_app/app/blueprints/settings.py @@ -1,15 +1,11 @@ -"""Страница настроек: статус LLM-ключа и проверка подключения (E1.8). - -Ключ — общий, читается из ENV (`DEEPSEEK_API_KEY` / `OPENAI_API_KEY`). Здесь — -только просмотр статуса и smoke-проверка. Изменение ключа — через `.env` и -рестарт процесса. -""" +"""Настройки: LLM-статус, пинг и редактор промптов.""" from __future__ import annotations -from flask import Blueprint, jsonify, render_template +from flask import Blueprint, jsonify, render_template, request from ..auth.decorators import login_required from ..services.llm_client import get_llm_config, ping_llm +from ..services import prompt_store settings_bp = Blueprint('settings', __name__) @@ -27,7 +23,34 @@ def settings_page(): ) +@settings_bp.route('/settings/prompts', methods=['GET']) +@login_required +def prompts_page(): + return render_template('settings_prompts.html', prompts=prompt_store.get_all()) + + @settings_bp.route('/api/llm/ping', methods=['POST', 'GET']) @login_required def api_llm_ping(): return jsonify(ping_llm()) + + +@settings_bp.route('/api/ai/prompts', methods=['GET']) +@login_required +def api_get_prompts(): + return jsonify(prompts=prompt_store.get_all()) + + +@settings_bp.route('/api/ai/prompts/', 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) diff --git a/flask_app/app/config.py b/flask_app/app/config.py index 3f0a99d..cc80e29 100644 --- a/flask_app/app/config.py +++ b/flask_app/app/config.py @@ -37,3 +37,8 @@ def is_dev_ui() -> bool: if (os.environ.get('FLASK_ENV') or '').lower() == 'development': return True return (os.environ.get('FLASK_DEBUG') or '').strip() == '1' + + +def get_dev_fio_password() -> str | None: + """Общий пароль для dev-входа по ФИО. Задаётся в DEV_FIO_PASSWORD.""" + return os.environ.get('DEV_FIO_PASSWORD') or None diff --git a/flask_app/app/data/prompts.json b/flask_app/app/data/prompts.json new file mode 100644 index 0000000..d179b93 --- /dev/null +++ b/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": "Выбранные пользователем варианты" + } + } +} diff --git a/flask_app/app/db.py b/flask_app/app/db.py index 68dd37b..fad4804 100644 --- a/flask_app/app/db.py +++ b/flask_app/app/db.py @@ -1,7 +1,7 @@ -"""Подключение к PostgreSQL — тот же паттерн, что в HR_TG_Bot/tgFlaskForm/db/session.py. +"""Подключение к PostgreSQL и ORM-сессии. -В Этапе 1 работаем с БД `clinic_tests` (схема не меняется). Опционально доступна -вторая БД `hr_bot_test` для HR-аутентификации (см. .env.example, флаг HR_AUTH). +Основная БД — `clinic_tests`. +Опциональная вторая БД — `hr_bot_test` (когда HR_AUTH=1). """ from __future__ import annotations @@ -11,76 +11,85 @@ from typing import Optional from sqlalchemy import create_engine from sqlalchemy.engine import Engine -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session, scoped_session, sessionmaker from sqlalchemy.pool import QueuePool -_lock = threading.Lock() +_engine_lock = threading.Lock() +_session_lock = threading.Lock() +_hr_engine_lock = threading.Lock() + _engine: Optional[Engine] = None -_session_local: Optional[sessionmaker] = None +_session_factory: Optional[scoped_session] = None _hr_engine: Optional[Engine] = None -_hr_session_local: Optional[sessionmaker] = None -def get_database_url() -> str: - """URL основной БД (`clinic_tests`). +# ─── URL helpers ───────────────────────────────────────────────────────────── - Приоритет: DATABASE_URL → отдельные DB_*-переменные. - """ +def get_database_url() -> str: if db_url := os.environ.get('DATABASE_URL'): 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') - db_name = os.environ.get('DB_NAME', 'clinic_tests') - db_user = os.environ.get('DB_USER', 'hr_bot_user') - db_password = os.environ.get('DB_PASSWORD', 'hrbot123') - return f'postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}' + +def _hr_auth_enabled() -> bool: + return (os.environ.get('HR_AUTH') or '').strip().lower() in ('1', 'true', 'yes', 'on') def get_hr_database_url() -> Optional[str]: - """URL БД HR (`hr_bot_test`) — только если включён HR_AUTH.""" if not _hr_auth_enabled(): return None - if url := os.environ.get('HR_DATABASE_URL'): - return url.strip() - return None - + url = (os.environ.get('HR_DATABASE_URL') or '').strip() + return url or None -def _hr_auth_enabled() -> bool: - val = (os.environ.get('HR_AUTH') or '').strip().lower() - return val in ('1', 'true', 'yes', 'on') +# ─── Main engine ───────────────────────────────────────────────────────────── def get_engine() -> Engine: - """Возвращает общий engine основной БД (singleton на процесс).""" global _engine if _engine is not None: return _engine - with _lock: - if _engine is not None: - return _engine - _engine = create_engine( - get_database_url(), - poolclass=QueuePool, - pool_size=5, - max_overflow=10, - pool_pre_ping=True, - ) + with _engine_lock: + if _engine is None: + _engine = create_engine( + get_database_url(), + poolclass=QueuePool, + pool_size=5, + max_overflow=10, + pool_pre_ping=True, + ) return _engine -def get_session(): - """Создаёт новую ORM-сессию поверх общего engine.""" - global _session_local - if _session_local is None: - with _lock: - if _session_local is None: - _session_local = sessionmaker(bind=get_engine()) - return _session_local() +# ─── Scoped session ────────────────────────────────────────────────────────── +def get_session() -> Session: + """Возвращает ORM-сессию для текущего потока (scoped_session).""" + global _session_factory + if _session_factory is None: + with _session_lock: + if _session_factory is None: + # Инициализируем engine до захвата session_lock, чтобы не было вложенных блокировок + engine = get_engine() + _session_factory = scoped_session( + sessionmaker(bind=engine, autoflush=True, autocommit=False) + ) + return _session_factory # type: ignore[return-value] + + +def remove_session() -> None: + """Освобождает сессию для текущего потока. Вызывается в teardown_appcontext.""" + if _session_factory is not None: + _session_factory.remove() + + +# ─── HR engine (raw SQL only) ──────────────────────────────────────────────── def get_hr_engine() -> Optional[Engine]: - """Engine для HR-БД. Возвращает None, если HR_AUTH не включён.""" if not _hr_auth_enabled(): return None global _hr_engine @@ -89,34 +98,21 @@ def get_hr_engine() -> Optional[Engine]: url = get_hr_database_url() if not url: return None - with _lock: - if _hr_engine is not None: - return _hr_engine - _hr_engine = create_engine( - url, - poolclass=QueuePool, - pool_size=3, - max_overflow=5, - pool_pre_ping=True, - ) + with _hr_engine_lock: + if _hr_engine is None: + _hr_engine = create_engine( + url, + poolclass=QueuePool, + pool_size=3, + max_overflow=5, + pool_pre_ping=True, + ) return _hr_engine -def get_hr_session(): - """Сессия для HR-БД (или None при выключенном HR_AUTH).""" - eng = get_hr_engine() - if eng is None: - return None - global _hr_session_local - if _hr_session_local is None: - with _lock: - if _hr_session_local is None: - _hr_session_local = sessionmaker(bind=eng) - return _hr_session_local() - +# ─── Smoke check ───────────────────────────────────────────────────────────── def ping() -> dict: - """Smoke-проверка подключения к БД (используется в /health).""" out: dict = {'main': 'unknown'} try: with get_engine().connect() as conn: diff --git a/flask_app/app/models.py b/flask_app/app/models.py new file mode 100644 index 0000000..2bf48ab --- /dev/null +++ b/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") diff --git a/flask_app/app/services/ai_editor.py b/flask_app/app/services/ai_editor.py index bcac67b..1254109 100644 --- a/flask_app/app/services/ai_editor.py +++ b/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} +def generate_question_hint( + *, + question_text: str, + options: list[dict], +) -> str: + """Универсальная подсказка к вопросу: 2–4 предложения, объясняет правильный ответ.""" + cfg = get_llm_config() + if cfg is None: + return '' + correct_list = '; '.join(o['text'] for o in options if o.get('isCorrect')) + all_list = '; '.join(o['text'] for o in options) + system = ( + 'Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения), ' + 'без markdown и без вступлений. Объясни почему правильный вариант — правильный.' + ) + user = ( + f'Вопрос: {question_text}\n' + f'Варианты: {all_list}\n' + f'Правильный ответ: {correct_list or "—"}\n\n' + 'Дай краткое объяснение для подсказки во всплывающем окне.' + ) + try: + raw = chat_completion_text_content(cfg, system, user, 0.3, as_json=False) + return (raw or '').strip() + except Exception as e: + import logging + logging.getLogger(__name__).warning('generate_question_hint failed: %s', e) + return '' + + +def explain_answer( + *, + question_text: str, + options: list[dict], + selected_texts: list[str], + is_correct: bool, +) -> str: + """Генерирует короткое объяснение результата ответа на вопрос (для попапа подсказки).""" + cfg = get_llm_config() + if cfg is None: + return '' + correct_list = '; '.join(o['text'] for o in options if o.get('isCorrect')) + sel_list = '; '.join(selected_texts) if selected_texts else '(ничего не выбрано)' + verdict = 'верно' if is_correct else 'неверно' + system = ( + 'Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения). ' + 'Объясни почему правильный ответ именно такой, без лишней воды и без markdown.' + ) + user = ( + f'Вопрос: {question_text}\n' + f'Правильный ответ: {correct_list or "—"}\n' + f'Ответ ученика ({verdict}): {sel_list}\n\n' + 'Дай краткое объяснение для подсказки во всплывающем окне.' + ) + try: + raw = chat_completion_text_content(cfg, system, user, 0.3, as_json=False) + return (raw or '').strip() + except Exception as e: + import logging + logging.getLogger(__name__).warning('explain_answer failed: %s', e) + return '' + + def generate_or_rephrase_question( test_title: str, test_description: str, question_text: str, options_count: Any, has_multiple_answers: bool, + mode: str | None = None, + existing_options: list[dict] | None = None, ) -> dict: + import json as _json + cfg = _require_cfg() try: n = int(float(options_count)) @@ -308,6 +375,43 @@ def generate_or_rephrase_question( topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip() qt = (question_text or '').strip() + # ── Режим дистракторов: есть вопрос + часть вариантов пуста ───────────── + if qt and mode == 'distractors' and existing_options: + filled = [o for o in existing_options if (o.get('text') or '').strip()] + empty_count = len([o for o in existing_options if not (o.get('text') or '').strip()] ) + if empty_count > 0: + filled_lines = '\n'.join( + f'- {"✓" if o.get("isCorrect") else "✗"} {o["text"]}' + for o in filled + ) or '(нет)' + system = ( + 'Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: ' + f'{{"options": [{{"text": string, "isCorrect": false}}, ...]}} — ' + f'ровно {empty_count} объекта в массиве. ' + 'Все тексты на русском, без нумерации, без кавычек.' + ) + user = ( + f'Тема теста: {topic}\n\n' + f'Вопрос: {qt}\n\n' + f'Уже заполненные варианты:\n{filled_lines}\n\n' + f'Придумай ровно {empty_count} правдоподобных, но НЕВЕРНЫХ дистракторов ' + f'(isCorrect: false), которые не повторяют уже существующие варианты ' + f'и выглядят похоже на реальные ответы.' + ) + raw = chat_completion_text_content(cfg, system, user, 0.45) + parsed = parse_json_from_llm_text(raw) + opts = [] + if isinstance(parsed, dict): + opts = parsed.get('options') or [] + elif isinstance(parsed, list): + opts = parsed + opts = [ + {'text': str(o.get('text') or '').strip(), 'isCorrect': False} + for o in opts if (o.get('text') or '').strip() + ][:empty_count] + return {'mode': 'distractors', 'text': qt, 'options': opts} + + # ── Режим улучшения: вопрос есть → только переформулировать текст ──────── if qt: system = ( 'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — ' @@ -325,6 +429,7 @@ def generate_or_rephrase_question( raise LlmError('Пустой text в ответе модели.', code='llm_shape') return {'mode': 'rephrase', 'text': text} + # ── Полная генерация: вопрос пуст ──────────────────────────────────────── system = ( 'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", ' '"options": [{ "text", "isCorrect" }]}. Все на русском.' diff --git a/flask_app/app/services/document_gen.py b/flask_app/app/services/document_gen.py index d4256b8..192a375 100644 --- a/flask_app/app/services/document_gen.py +++ b/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 -def generation_for_import_document(extracted_text: str) -> dict: +def generation_for_import_document(extracted_text: str, user_hint: str = '') -> dict: text = (extracted_text or '').strip() if not text: return { @@ -41,9 +41,10 @@ def generation_for_import_document(extracted_text: str) -> dict: 'Для одиночного выбора ровно один isCorrect: true. ' 'Текст и формулировки — на русском, по содержанию входного материала.' ) + hint_block = f'\n\nДополнительные инструкции от автора теста:\n{user_hint.strip()}' if user_hint and user_hint.strip() else '' user = ( 'Составь тест с вопросами с одним или несколькими правильными ответами ' - 'на основе текста:\n\n' + slice_ + 'на основе текста:\n\n' + slice_ + hint_block ) raw = chat_completion_text_content(cfg, system, user, 0.25) parsed = parse_json_from_llm_text(raw) diff --git a/flask_app/app/services/editor_content.py b/flask_app/app/services/editor_content.py index c1cef83..e3d18c7 100644 --- a/flask_app/app/services/editor_content.py +++ b/flask_app/app/services/editor_content.py @@ -1,14 +1,14 @@ -"""Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами. - -Порт `getEditorContent` + `loadQuestionsForVersion` (только includeCorrect=true вариант) -из `services/testAttemptService.js`. -""" +"""Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами.""" 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 ..models import AnswerOption, Question, Test, TestVersion from .test_access import is_test_author from .test_chain import has_any_attempt_for_test @@ -20,86 +20,85 @@ class HttpError(Exception): self.message = message -def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]: - qrows = conn.execute( - text( - 'SELECT id, text, question_order, has_multiple_answers ' - 'FROM questions WHERE test_version_id = :v ORDER BY question_order' - ), - {'v': test_version_id}, - ).mappings().all() +def load_questions_for_version(session, test_version_id, *, include_correct: bool) -> list[dict]: + if not isinstance(test_version_id, _uuid.UUID): + try: + test_version_id = _uuid.UUID(str(test_version_id)) + except (ValueError, AttributeError): + return [] + + questions = ( + session.query(Question) + .options(selectinload(Question.options)) + .filter(Question.test_version_id == test_version_id) + .order_by(Question.question_order) + .all() + ) out = [] - for r in qrows: - orows = conn.execute( - text( - 'SELECT id, text, is_correct, option_order ' - 'FROM answer_options WHERE question_id = :q ORDER BY option_order' - ), - {'q': r['id']}, - ).mappings().all() + for q in questions: options = [] - for o in orows: + for o in sorted(q.options, key=lambda x: x.option_order): base = { - 'id': str(o['id']), - 'text': o['text'], - 'optionOrder': o['option_order'], + 'id': str(o.id), + 'text': o.text, + 'optionOrder': o.option_order, } if include_correct: - base['isCorrect'] = bool(o['is_correct']) + base['isCorrect'] = bool(o.is_correct) options.append(base) - out.append( - { - 'id': str(r['id']), - 'text': r['text'], - 'questionOrder': r['question_order'], - 'hasMultipleAnswers': bool(r['has_multiple_answers']), - 'options': options, - } - ) + out.append({ + 'id': str(q.id), + 'text': q.text, + 'questionOrder': q.question_order, + 'hasMultipleAnswers': bool(q.has_multiple_answers), + 'options': options, + }) return out def get_editor_content(user_id: str, test_id: str) -> dict: - eng = get_engine() - with eng.connect() as conn: - tr = conn.execute( - text( - 'SELECT id, title, description, passing_threshold, created_by ' - 'FROM tests WHERE id = :id' - ), - {'id': test_id}, - ).mappings().first() - if not tr: - raise HttpError(404, 'Тест не найден.') - if not is_test_author(tr['created_by'], user_id): - raise HttpError(403, 'Доступ запрещён.') - tv = conn.execute( - text( - 'SELECT id FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1' - ), - {'id': test_id}, - ).mappings().first() - if not tv: - raise HttpError(400, 'Нет активной версии теста.') - version_id = tv['id'] - version_count_row = conn.execute( - text('SELECT COUNT(*) AS n FROM test_versions WHERE test_id = :id'), - {'id': test_id}, - ).mappings().first() - version_count = int(version_count_row['n'] or 0) - questions = load_questions_for_version(conn, version_id, include_correct=True) - has_attempts = has_any_attempt_for_test(conn, test_id) + session = get_session() + try: + tid = _uuid.UUID(test_id) + except (ValueError, AttributeError): + raise HttpError(404, 'Тест не найден.') + + test = session.get(Test, tid) + if not test: + raise HttpError(404, 'Тест не найден.') + if not is_test_author(test.created_by, user_id): + raise HttpError(403, 'Доступ запрещён.') + + active_version = ( + session.query(TestVersion) + .filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True)) + .first() + ) + if not active_version: + raise HttpError(400, 'Нет активной версии теста.') + + version_count = ( + session.query(func.count(TestVersion.id)) + .filter(TestVersion.test_id == tid) + .scalar() or 0 + ) + + questions = load_questions_for_version(session, active_version.id, include_correct=True) + has_attempts = has_any_attempt_for_test(session, tid) return { 'test': { - 'id': str(tr['id']), - 'title': tr['title'], - 'description': tr['description'], - 'passingThreshold': tr['passing_threshold'], + 'id': str(test.id), + 'title': test.title, + 'description': test.description, + 'passingThreshold': test.passing_threshold, + 'timeLimit': test.time_limit, + 'hintsEnabled': bool(test.hints_enabled), + 'resultMode': test.result_mode or 'end', 'hasAttempts': bool(has_attempts), 'versionCount': version_count, 'hasForkRisk': bool(has_attempts) or version_count > 1, }, - 'activeVersionId': str(version_id), + 'activeVersionId': str(active_version.id), 'questions': questions, } diff --git a/flask_app/app/services/llm_client.py b/flask_app/app/services/llm_client.py index 39473df..c046242 100644 --- a/flask_app/app/services/llm_client.py +++ b/flask_app/app/services/llm_client.py @@ -51,8 +51,13 @@ def chat_completion_text_content( user: str, temperature: float = 0.25, timeout: int = 120, + as_json: bool = True, ) -> str: - """Возвращает `assistant.message.content` (строку).""" + """Возвращает `assistant.message.content` (строку). + + `as_json=True` (по умолчанию) включает `response_format: json_object`. Для свободного + текста (например, объяснения к вопросу) передайте `as_json=False`. + """ body: dict = { 'model': cfg.model, 'messages': [ @@ -61,7 +66,7 @@ def chat_completion_text_content( ], 'temperature': temperature, } - if (os.environ.get('LLM_NO_JSON') or '').strip() != '1': + if as_json and (os.environ.get('LLM_NO_JSON') or '').strip() != '1': body['response_format'] = {'type': 'json_object'} req = urllib.request.Request( diff --git a/flask_app/app/services/prompt_store.py b/flask_app/app/services/prompt_store.py new file mode 100644 index 0000000..6aa9a88 --- /dev/null +++ b/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) diff --git a/flask_app/app/services/test_access.py b/flask_app/app/services/test_access.py index d1c0e20..241b5c8 100644 --- a/flask_app/app/services/test_access.py +++ b/flask_app/app/services/test_access.py @@ -1,15 +1,15 @@ -"""Кто видит тест: автор + назначенные пользователи (порт `services/testAccessService.js`).""" +"""Кто видит тест: автор + назначенные пользователи.""" from __future__ import annotations from dataclasses import dataclass -from sqlalchemy import text +from sqlalchemy import exists, select -from ..db import get_engine +from ..db import get_session +from ..models import Test, TestAssignment, TestAssignmentTarget, TestVersion, User def is_test_author(created_by, user_id) -> bool: - """`tests.created_by` — UUID. Сравниваем по строковому представлению.""" if created_by is None or user_id is None: return False return str(created_by) == str(user_id) @@ -23,86 +23,93 @@ class AccessResult: def user_has_test_access(user_id: str, test_id: str) -> AccessResult: - eng = get_engine() - with eng.connect() as conn: - row = conn.execute( - text('SELECT created_by FROM tests WHERE id = :id'), - {'id': test_id}, - ).mappings().first() - if not row: - return AccessResult(ok=False, is_author=False, not_found=True) - if is_test_author(row['created_by'], user_id): - return AccessResult(ok=True, is_author=True, not_found=False) - ar = conn.execute( - text( - """ - SELECT 1 - FROM test_assignments ta - INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id - INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id - WHERE tv_a.test_id = :test_id - AND tat.target_type = 'user' - AND tat.target_id = :user_id - LIMIT 1 - """ - ), - {'test_id': test_id, 'user_id': user_id}, - ).first() - return AccessResult(ok=ar is not None, is_author=False, not_found=False) + import uuid as _uuid + session = get_session() + try: + tid = _uuid.UUID(test_id) + uid = _uuid.UUID(user_id) + except (ValueError, AttributeError): + return AccessResult(ok=False, is_author=False, not_found=True) + + test = session.get(Test, tid) + if not test: + return AccessResult(ok=False, is_author=False, not_found=True) + + if is_test_author(test.created_by, uid): + return AccessResult(ok=True, is_author=True, not_found=False) + + assigned = session.query( + exists().where( + TestAssignmentTarget.target_type == 'user', + TestAssignmentTarget.target_id == uid, + TestAssignmentTarget.assignment_id == TestAssignment.id, + TestAssignment.test_version_id == TestVersion.id, + TestVersion.test_id == tid, + ) + ).scalar() + + return AccessResult(ok=bool(assigned), is_author=False, not_found=False) def list_visible_tests(user_id: str) -> list[dict]: - """Каталог: только активная цепочка + (автор OR назначен).""" - eng = get_engine() - with eng.connect() as conn: - rows = conn.execute( - text( - """ - SELECT DISTINCT t.id, t.title, t.description, t.is_active AS chain_active, - t.created_at, t.updated_at, - tv.id AS active_version_id, tv.version, - t.created_by, u.full_name AS author_full_name - FROM tests t - INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true - INNER JOIN users u ON u.id = t.created_by - WHERE t.is_active = true - AND ( - t.created_by = :uid - OR EXISTS ( - SELECT 1 - FROM test_assignments ta - INNER JOIN test_versions tv2 ON tv2.id = ta.test_version_id - INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id - WHERE tv2.test_id = t.id - AND tat.target_type = 'user' - AND tat.target_id = :uid - ) - ) - ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC - """ - ), - {'uid': user_id}, - ).mappings().all() - return [dict(r) for r in rows] + """В dev-режиме возвращает все активные тесты независимо от назначения.""" + session = get_session() + + rows = ( + session.query(Test, TestVersion, User) + .join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True)) + .outerjoin(User, User.id == Test.created_by) + .filter(Test.is_active.is_(True)) + .order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc()) + .all() + ) + + return [ + { + 'id': str(t.id), + 'title': t.title, + 'description': t.description, + 'chain_active': t.is_active, + 'created_at': t.created_at, + 'updated_at': t.updated_at, + 'active_version_id': str(tv.id), + 'version': tv.version, + 'created_by': str(t.created_by) if t.created_by else None, + 'author_full_name': u.full_name if u else '—', + } + for t, tv, u in rows + ] def list_hidden_by_author(user_id: str) -> list[dict]: - """Скрытые автором цепочки (`is_active = false`) — видны только автору.""" - eng = get_engine() - with eng.connect() as conn: - rows = conn.execute( - text( - """ - SELECT t.id, t.title, t.description, t.is_active AS chain_active, - t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, - t.created_by, u.full_name AS author_full_name - FROM tests t - INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true - INNER JOIN users u ON u.id = t.created_by - WHERE t.is_active = false AND t.created_by = :uid - ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC - """ - ), - {'uid': user_id}, - ).mappings().all() - return [dict(r) for r in rows] + import uuid as _uuid + session = get_session() + try: + uid = _uuid.UUID(user_id) + except (ValueError, AttributeError): + return [] + + rows = ( + session.query(Test, TestVersion, User) + .join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True)) + .join(User, User.id == Test.created_by) + .filter(Test.is_active.is_(False), Test.created_by == uid) + .order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc()) + .all() + ) + + return [ + { + 'id': str(t.id), + 'title': t.title, + 'description': t.description, + 'chain_active': t.is_active, + 'created_at': t.created_at, + 'updated_at': t.updated_at, + 'active_version_id': str(tv.id), + 'version': tv.version, + 'created_by': str(t.created_by), + 'author_full_name': u.full_name, + } + for t, tv, u in rows + ] diff --git a/flask_app/app/services/test_attempt.py b/flask_app/app/services/test_attempt.py index ac85ffe..782a432 100644 --- a/flask_app/app/services/test_attempt.py +++ b/flask_app/app/services/test_attempt.py @@ -1,8 +1,23 @@ +"""Сервис прохождения теста.""" from __future__ import annotations -from sqlalchemy import text +import uuid as _uuid +from datetime import datetime, timezone -from ..services.test_access import is_test_author, user_has_test_access +from sqlalchemy import func +from sqlalchemy.orm import Session, selectinload + +from ..db import get_session +from ..models import ( + AnswerOption, + Question, + Test, + TestAttempt, + TestVersion, + User, + UserAnswer, +) +from .test_access import is_test_author, user_has_test_access class HttpError(Exception): @@ -17,212 +32,218 @@ def _sort_uuid_strings(items) -> list[str]: def _same_selection(selected, correct_ids) -> bool: - a = _sort_uuid_strings(selected) - b = _sort_uuid_strings(correct_ids) - return a == b - - -def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]: - qrows = conn.execute( - text( - 'SELECT id, text, question_order, has_multiple_answers ' - 'FROM questions WHERE test_version_id = :v ORDER BY question_order' - ), - {'v': test_version_id}, - ).mappings().all() + return _sort_uuid_strings(selected) == _sort_uuid_strings(correct_ids) + + +def _to_uuid(val) -> _uuid.UUID | None: + if isinstance(val, _uuid.UUID): + return val + try: + return _uuid.UUID(str(val)) + except (ValueError, AttributeError): + return None + + +# ─── load questions (shared) ───────────────────────────────────────────────── + +def load_questions_for_version(session: Session, test_version_id, *, include_correct: bool) -> list[dict]: + vid = _to_uuid(test_version_id) + if vid is None: + return [] + questions = ( + session.query(Question) + .options(selectinload(Question.options)) + .filter(Question.test_version_id == vid) + .order_by(Question.question_order) + .all() + ) out = [] - for q in qrows: - orows = conn.execute( - text( - 'SELECT id, text, is_correct, option_order ' - 'FROM answer_options WHERE question_id = :q ORDER BY option_order' - ), - {'q': q['id']}, - ).mappings().all() - opts = [] - for o in orows: - base = { - 'id': str(o['id']), - 'text': o['text'], - 'optionOrder': o['option_order'], - } + for q in questions: + options = [] + for o in sorted(q.options, key=lambda x: x.option_order): + base = {'id': str(o.id), 'text': o.text, 'optionOrder': o.option_order} if include_correct: - base['isCorrect'] = bool(o['is_correct']) - opts.append(base) - out.append( - { - 'id': str(q['id']), - 'text': q['text'], - 'questionOrder': q['question_order'], - 'hasMultipleAnswers': bool(q['has_multiple_answers']), - 'options': opts, - } - ) + base['isCorrect'] = bool(o.is_correct) + options.append(base) + out.append({ + 'id': str(q.id), + 'text': q.text, + 'questionOrder': q.question_order, + 'hasMultipleAnswers': bool(q.has_multiple_answers), + 'options': options, + }) return out -def start_attempt(eng, user_id: str, test_id: str) -> dict: +# ─── start ─────────────────────────────────────────────────────────────────── + +def start_attempt(session_or_eng, user_id: str, test_id: str) -> dict: + """Принимает engine (legacy) или session — для обратной совместимости.""" acc = user_has_test_access(user_id, test_id) if not acc.ok: raise HttpError(404, 'Тест не найден.') - with eng.begin() as conn: - tv = conn.execute( - text( - 'SELECT id AS test_version_id FROM test_versions ' - 'WHERE test_id = :id AND is_active = true LIMIT 1' - ), - {'id': test_id}, - ).mappings().first() - if not tv: - raise HttpError(404, 'Нет активной версии теста.') - version_id = tv['test_version_id'] - mx = conn.execute( - text( - 'SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts ' - 'WHERE test_version_id = :v AND user_id = :u' - ), - {'v': version_id, 'u': user_id}, - ).mappings().first() - next_n = int(mx['n'] or 0) + 1 - a = conn.execute( - text( - "INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status) " - "VALUES (:v, :u, :n, 'in_progress') " - 'RETURNING id, test_version_id, user_id, attempt_number, status, started_at' - ), - {'v': version_id, 'u': user_id, 'n': next_n}, - ).mappings().first() - return {'attempt': dict(a)} - - -def get_play_content(eng, user_id: str, test_id: str, attempt_id: str) -> dict: - with eng.connect() as conn: - a = conn.execute( - text( - 'SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, ' - 't.title, t.passing_threshold ' - 'FROM test_attempts ta ' - 'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' - 'INNER JOIN tests t ON t.id = tv.test_id ' - 'WHERE ta.id = :a' - ), - {'a': attempt_id}, - ).mappings().first() - if not a: - raise HttpError(404, 'Попытка не найдена.') - if str(a['test_id']) != str(test_id): - raise HttpError(404, 'Попытка не найдена.') - if str(a['user_id']) != str(user_id): - raise HttpError(403, 'Доступ запрещён.') - if a['status'] != 'in_progress': - raise HttpError(400, 'Попытка уже завершена.') - qs = load_questions_for_version(conn, a['test_version_id'], include_correct=False) + + session = get_session() + tid = _to_uuid(test_id) + uid = _to_uuid(user_id) + + active_version = ( + session.query(TestVersion) + .filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True)) + .first() + ) + if not active_version: + raise HttpError(404, 'Нет активной версии теста.') + + max_n = ( + session.query(func.coalesce(func.max(TestAttempt.attempt_number), 0)) + .filter( + TestAttempt.test_version_id == active_version.id, + TestAttempt.user_id == uid, + ) + .scalar() or 0 + ) + attempt = TestAttempt( + test_version_id=active_version.id, + user_id=uid, + attempt_number=int(max_n) + 1, + status='in_progress', + ) + session.add(attempt) + session.commit() + session.refresh(attempt) return { - 'testTitle': a['title'], - 'passingThreshold': a['passing_threshold'], - 'attemptId': str(a['id']), + 'attempt': { + 'id': str(attempt.id), + 'test_version_id': str(attempt.test_version_id), + 'user_id': str(attempt.user_id), + 'attempt_number': attempt.attempt_number, + 'status': attempt.status, + 'started_at': attempt.started_at.isoformat() if attempt.started_at else None, + } + } + + +# ─── play ──────────────────────────────────────────────────────────────────── + +def get_play_content(session_or_eng, user_id: str, test_id: str, attempt_id: str) -> dict: + session = get_session() + aid = _to_uuid(attempt_id) + uid = _to_uuid(user_id) + tid = _to_uuid(test_id) + + attempt = ( + session.query(TestAttempt) + .options( + selectinload(TestAttempt.test_version).selectinload(TestVersion.test) + ) + .filter(TestAttempt.id == aid) + .first() + ) + if not attempt: + raise HttpError(404, 'Попытка не найдена.') + if attempt.test_version.test_id != tid: + raise HttpError(404, 'Попытка не найдена.') + if attempt.user_id != uid: + raise HttpError(403, 'Доступ запрещён.') + if attempt.status != 'in_progress': + raise HttpError(400, 'Попытка уже завершена.') + + test = attempt.test_version.test + qs = load_questions_for_version(session, attempt.test_version_id, include_correct=False) + return { + 'testTitle': test.title, + 'passingThreshold': test.passing_threshold, + 'timeLimit': test.time_limit, + 'hintsEnabled': bool(test.hints_enabled), + 'resultMode': test.result_mode or 'end', + 'attemptId': str(attempt.id), 'questions': qs, } -def submit_attempt(eng, user_id: str, test_id: str, attempt_id: str, raw_answers: dict | None) -> dict: +# ─── submit ────────────────────────────────────────────────────────────────── + +def submit_attempt(session_or_eng, user_id: str, test_id: str, attempt_id: str, + raw_answers: dict | None) -> dict: answers = raw_answers if isinstance(raw_answers, dict) else {} - with eng.begin() as conn: - a = conn.execute( - text( - 'SELECT id, user_id, status, test_version_id ' - 'FROM test_attempts WHERE id = :a FOR UPDATE' - ), - {'a': attempt_id}, - ).mappings().first() - if not a: - raise HttpError(404, 'Попытка не найдена.') - link = conn.execute( - text( - 'SELECT t.passing_threshold, tv.test_id ' - 'FROM test_versions tv ' - 'INNER JOIN tests t ON t.id = tv.test_id ' - 'WHERE tv.id = :v' - ), - {'v': a['test_version_id']}, - ).mappings().first() - if not link: - raise HttpError(404, 'Тест не найден.') - if str(link['test_id']) != str(test_id): - raise HttpError(404, 'Попытка не найдена.') - if str(a['user_id']) != str(user_id): - raise HttpError(403, 'Доступ запрещён.') - if a['status'] != 'in_progress': - raise HttpError(400, 'Попытка уже завершена.') - - qrows = conn.execute( - text('SELECT id FROM questions WHERE test_version_id = :v'), - {'v': a['test_version_id']}, - ).mappings().all() - if not qrows: - raise HttpError(400, 'В тесте нет вопросов.') - - opts = conn.execute( - text( - 'SELECT a.id, a.question_id, a.is_correct ' - 'FROM answer_options a ' - 'INNER JOIN questions q ON q.id = a.question_id ' - 'WHERE q.test_version_id = :v' - ), - {'v': a['test_version_id']}, - ).mappings().all() - - 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 - for q in qrows: - qid = str(q['id']) - selected = answers.get(qid, []) - if not isinstance(selected, list): - selected = [str(selected)] - selected = [str(x) for x in selected] - g = by_q.get(qid, {'all': set(), 'correct': []}) - for sid in selected: - if sid not in g['all']: - raise HttpError(400, 'Некорректный вариант ответа.') - if _same_selection(selected, g['correct']): - correct_count += 1 - - total = len(qrows) - percent = (correct_count / total) * 100 if total else 0 - threshold = int(link['passing_threshold'] or 0) - passed = percent + 1e-9 >= threshold - - conn.execute(text('DELETE FROM user_answers WHERE attempt_id = :a'), {'a': attempt_id}) - for q in qrows: - qid = str(q['id']) - selected = answers.get(qid, []) - if not isinstance(selected, list): - selected = [str(selected)] - selected = [str(x) for x in selected] - conn.execute( - text( - 'INSERT INTO user_answers (attempt_id, question_id, selected_options) ' - 'VALUES (:a, :q, :s::uuid[])' - ), - {'a': attempt_id, 'q': q['id'], 's': selected}, - ) - conn.execute( - text( - "UPDATE test_attempts SET status = 'completed', completed_at = CURRENT_TIMESTAMP, " - 'correct_count = :c, total_questions = :t, passed = :p WHERE id = :a' - ), - {'a': attempt_id, 'c': correct_count, 't': total, 'p': passed}, - ) + session = 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) + .with_for_update() + .first() + ) + if not attempt: + raise HttpError(404, 'Попытка не найдена.') + if attempt.test_version.test_id != tid: + raise HttpError(404, 'Попытка не найдена.') + if attempt.user_id != uid: + raise HttpError(403, 'Доступ запрещён.') + if attempt.status != 'in_progress': + raise HttpError(400, 'Попытка уже завершена.') + + test = attempt.test_version.test + questions = ( + session.query(Question) + .options(selectinload(Question.options)) + .filter(Question.test_version_id == attempt.test_version_id) + .all() + ) + if not questions: + raise HttpError(400, 'В тесте нет вопросов.') + + by_q: dict[str, dict] = {} + for q in questions: + qid = str(q.id) + by_q[qid] = {'all': {str(o.id) for o in q.options}, 'correct': [str(o.id) for o in q.options if o.is_correct]} - review = build_review_from_db(eng, attempt_id) + correct_count = 0 + for q in questions: + qid = str(q.id) + selected = answers.get(qid, []) + if not isinstance(selected, list): + selected = [str(selected)] + selected = [str(x) for x in selected] + g = by_q[qid] + for sid in selected: + if sid not in g['all']: + raise HttpError(400, 'Некорректный вариант ответа.') + if _same_selection(selected, g['correct']): + correct_count += 1 + + total = len(questions) + percent = (correct_count / total) * 100 if total else 0 + threshold = int(test.passing_threshold or 0) + passed = percent + 1e-9 >= threshold + + # удаляем старые ответы и записываем новые + session.query(UserAnswer).filter(UserAnswer.attempt_id == aid).delete(synchronize_session='fetch') + for q in questions: + qid = str(q.id) + selected = answers.get(qid, []) + if not isinstance(selected, list): + selected = [str(selected)] + selected_uuids = [_to_uuid(x) for x in selected if _to_uuid(x) is not None] + session.add(UserAnswer( + attempt_id=aid, + question_id=q.id, + selected_options=selected_uuids, + )) + + attempt.status = 'completed' + attempt.completed_at = datetime.now(timezone.utc) + attempt.correct_count = correct_count + attempt.total_questions = total + attempt.passed = passed + session.commit() + + review = build_review_from_db(session, attempt_id) return { 'attemptId': attempt_id, 'correctCount': correct_count, @@ -234,121 +255,268 @@ def submit_attempt(eng, user_id: str, test_id: str, attempt_id: str, raw_answers } -def build_review_from_db(eng, attempt_id: str) -> dict: - with eng.connect() as conn: - a = conn.execute( - text( - 'SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions, ' - 'ta.passed, ta.started_at, ta.completed_at, ' - 't.id AS test_id, t.title, t.passing_threshold, ' - 'u.full_name AS attempter_name, u.login AS attempter_login ' - 'FROM test_attempts ta ' - 'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' - 'INNER JOIN tests t ON t.id = tv.test_id ' - 'INNER JOIN users u ON u.id = ta.user_id ' - 'WHERE ta.id = :a' - ), - {'a': attempt_id}, - ).mappings().first() - if not a: - raise HttpError(404, 'Попытка не найдена.') - if a['status'] != 'completed': - raise HttpError(400, 'Попытка не завершена.') - questions = load_questions_for_version(conn, a['test_version_id'], include_correct=True) - uans = conn.execute( - text('SELECT question_id, selected_options FROM user_answers WHERE attempt_id = :a'), - {'a': attempt_id}, - ).mappings().all() - - sel_by_q = {str(r['question_id']): [str(x) for x in (r['selected_options'] or [])] for r in uans} - total = int(a['total_questions'] or len(questions)) - percent = round(((a['correct_count'] or 0) / total) * 100, 1) if total else 0 +# ─── review ────────────────────────────────────────────────────────────────── + +def build_review_from_db(session: Session, attempt_id: str) -> dict: + aid = _to_uuid(attempt_id) + attempt = ( + session.query(TestAttempt) + .options( + selectinload(TestAttempt.test_version).selectinload(TestVersion.test), + selectinload(TestAttempt.user), + selectinload(TestAttempt.user_answers), + ) + .filter(TestAttempt.id == aid) + .first() + ) + if not attempt: + raise HttpError(404, 'Попытка не найдена.') + if attempt.status != 'completed': + raise HttpError(400, 'Попытка не завершена.') + + test = attempt.test_version.test + questions = load_questions_for_version(session, attempt.test_version_id, include_correct=True) + + sel_by_q: dict[str, list[str]] = { + str(ua.question_id): [str(x) for x in (ua.selected_options or [])] + for ua in attempt.user_answers + } + + total = int(attempt.total_questions or len(questions)) + percent = round(((attempt.correct_count or 0) / total) * 100, 1) if total else 0 q_out = [] for q in questions: - selected = _sort_uuid_strings(sel_by_q.get(str(q['id']), [])) + selected = _sort_uuid_strings(sel_by_q.get(q['id'], [])) correct = _sort_uuid_strings([o['id'] for o in q['options'] if o.get('isCorrect')]) selected_set = set(selected) - q_out.append( - { - 'id': q['id'], - 'text': q['text'], - 'hasMultipleAnswers': q['hasMultipleAnswers'], - 'isUserCorrect': _same_selection(selected, correct), - 'options': [ - { - 'id': o['id'], - 'text': o['text'], - 'isCorrect': o.get('isCorrect', False), - 'selected': o['id'] in selected_set, - } - for o in q['options'] - ], - } - ) + q_out.append({ + 'id': q['id'], + 'text': q['text'], + 'hasMultipleAnswers': q['hasMultipleAnswers'], + 'isUserCorrect': _same_selection(selected, correct), + 'options': [ + { + 'id': o['id'], + 'text': o['text'], + 'isCorrect': o.get('isCorrect', False), + 'selected': o['id'] in selected_set, + } + for o in q['options'] + ], + }) return { - 'attemptId': str(a['id']), - 'testId': str(a['test_id']), - 'testTitle': a['title'], - 'passingThreshold': int(a['passing_threshold'] or 0), - 'correctCount': int(a['correct_count'] or 0), + 'attemptId': str(attempt.id), + 'testId': str(test.id), + 'testTitle': test.title, + 'passingThreshold': int(test.passing_threshold or 0), + 'correctCount': int(attempt.correct_count or 0), 'totalQuestions': total, 'percent': percent, - 'passed': bool(a['passed']), - 'startedAt': a['started_at'].isoformat() if a['started_at'] else None, - 'completedAt': a['completed_at'].isoformat() if a['completed_at'] else None, - 'attempterUserId': str(a['user_id']), - 'attempterName': a['attempter_name'], - 'attempterLogin': a['attempter_login'], + 'passed': bool(attempt.passed), + 'startedAt': attempt.started_at.isoformat() if attempt.started_at else None, + 'completedAt': attempt.completed_at.isoformat() if attempt.completed_at else None, + 'attempterUserId': str(attempt.user_id), + 'attempterName': attempt.user.full_name, + 'attempterLogin': attempt.user.login, 'questions': q_out, } -def get_attempt_review_for_user(eng, current_user_id: str, test_id: str, attempt_id: str) -> dict: - with eng.connect() as conn: - row = conn.execute( - text( - 'SELECT ta.user_id, t.created_by, tv.test_id ' - 'FROM test_attempts ta ' - 'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' - 'INNER JOIN tests t ON t.id = tv.test_id ' - 'WHERE ta.id = :a' - ), - {'a': attempt_id}, - ).mappings().first() - if not row: +def get_attempt_review_for_user(session_or_eng, current_user_id: str, test_id: str, + attempt_id: str) -> dict: + session = get_session() + aid = _to_uuid(attempt_id) + tid = _to_uuid(test_id) + + attempt = ( + session.query(TestAttempt) + .options(selectinload(TestAttempt.test_version).selectinload(TestVersion.test)) + .filter(TestAttempt.id == aid) + .first() + ) + if not attempt: raise HttpError(404, 'Попытка не найдена.') - if str(row['test_id']) != str(test_id): + if attempt.test_version.test_id != tid: raise HttpError(404, 'Попытка не найдена.') - is_owner = str(row['user_id']) == str(current_user_id) - is_author = is_test_author(row['created_by'], current_user_id) + + is_owner = str(attempt.user_id) == str(current_user_id) + is_author = is_test_author(attempt.test_version.test.created_by, current_user_id) if not is_owner and not is_author: raise HttpError(403, 'Доступ запрещён.') - return build_review_from_db(eng, attempt_id) - - -def list_test_attempts_for_author(eng, author_id: str, test_id: str) -> list[dict]: - with eng.connect() as conn: - t = conn.execute( - text('SELECT id, created_by FROM tests WHERE id = :id'), - {'id': test_id}, - ).mappings().first() - if not t: - raise HttpError(404, 'Тест не найден.') - if not is_test_author(t['created_by'], author_id): - raise HttpError(403, 'Доступ запрещён.') - rows = conn.execute( - text( - 'SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at, ' - 'ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version, ' - 'u.full_name AS attempter_name, u.login AS attempter_login ' - 'FROM test_attempts ta ' - 'INNER JOIN test_versions tv ON tv.id = ta.test_version_id ' - 'INNER JOIN users u ON u.id = ta.user_id ' - 'WHERE tv.test_id = :id ' - 'ORDER BY ta.started_at DESC NULLS LAST LIMIT 200' - ), - {'id': test_id}, - ).mappings().all() - return [dict(r) for r in rows] + 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 = '' + + return { + 'questionId': str(question.id), + 'isCorrect': is_correct, + 'correctOptionIds': correct_ids, + 'correctOptionTexts': correct_texts, + 'explanation': explanation, + } + + +def list_test_attempts_for_author(session_or_eng, author_id: str, test_id: str) -> list[dict]: + session = get_session() + tid = _to_uuid(test_id) + + test = session.get(Test, tid) + if not test: + raise HttpError(404, 'Тест не найден.') + if not is_test_author(test.created_by, author_id): + raise HttpError(403, 'Доступ запрещён.') + + rows = ( + session.query(TestAttempt, TestVersion, User) + .join(TestVersion, TestAttempt.test_version_id == TestVersion.id) + .join(User, TestAttempt.user_id == User.id) + .filter(TestVersion.test_id == tid) + .order_by(TestAttempt.started_at.desc().nullslast()) + .limit(200) + .all() + ) + + return [ + { + 'id': str(a.id), + 'user_id': str(a.user_id), + 'status': a.status, + 'attempt_number': a.attempt_number, + 'started_at': a.started_at, + 'completed_at': a.completed_at, + 'correct_count': a.correct_count, + 'total_questions': a.total_questions, + 'passed': a.passed, + 'test_version': tv.version, + 'attempter_name': u.full_name, + 'attempter_login': u.login, + } + for a, tv, u in rows + ] diff --git a/flask_app/app/services/test_chain.py b/flask_app/app/services/test_chain.py index 1a601de..2af0b02 100644 --- a/flask_app/app/services/test_chain.py +++ b/flask_app/app/services/test_chain.py @@ -1,22 +1,23 @@ """Утилиты по цепочке теста (попытки/версии).""" from __future__ import annotations -from sqlalchemy import text +from sqlalchemy.orm import Session +from ..models import TestAttempt, TestVersion -def has_any_attempt_for_test(conn, test_id: str) -> bool: - """`conn` может быть Connection или Engine — обе поддерживают .execute().""" - row = conn.execute( - text( - """ - SELECT EXISTS ( - SELECT 1 - FROM test_attempts ta - INNER JOIN test_versions tv ON ta.test_version_id = tv.id - WHERE tv.test_id = :test_id - ) AS has_any - """ - ), - {'test_id': test_id}, - ).first() - return bool(row[0]) + +def has_any_attempt_for_test(session: Session, test_id) -> bool: + """Возвращает True, если для теста есть хотя бы одна попытка.""" + import uuid as _uuid + if not isinstance(test_id, _uuid.UUID): + try: + test_id = _uuid.UUID(str(test_id)) + except (ValueError, AttributeError): + return False + + return session.query( + session.query(TestAttempt) + .join(TestVersion, TestAttempt.test_version_id == TestVersion.id) + .filter(TestVersion.test_id == test_id) + .exists() + ).scalar() diff --git a/flask_app/app/services/test_draft.py b/flask_app/app/services/test_draft.py index cbdcf23..ab90e13 100644 --- a/flask_app/app/services/test_draft.py +++ b/flask_app/app/services/test_draft.py @@ -1,12 +1,15 @@ -"""Создание/правка теста, fork версии при наличии попыток (порт `testDraftService.js`).""" +"""Создание/правка теста, fork версии при наличии попыток.""" from __future__ import annotations +import uuid as _uuid from typing import Any -from sqlalchemy import text +from sqlalchemy import func +from sqlalchemy.orm import Session -from ..db import get_engine +from ..db import get_session from ..messages import RU +from ..models import AnswerOption, Question, Test, TestVersion from .test_access import is_test_author from .test_chain import has_any_attempt_for_test @@ -19,216 +22,177 @@ class HttpError(Exception): def create_test_with_version(author_id: str, *, title: str, description: str | None) -> dict: - eng = get_engine() - with eng.begin() as conn: - t = conn.execute( - text( - """ - INSERT INTO tests (title, description, created_by, is_active, is_versioned) - VALUES (:title, :desc, :uid, true, true) RETURNING id - """ - ), - {'title': title, 'desc': description or None, 'uid': author_id}, - ).mappings().first() - test_id = t['id'] - v = conn.execute( - text( - """ - INSERT INTO test_versions (test_id, version, is_active, parent_id) - VALUES (:tid, 1, true, NULL) RETURNING id - """ - ), - {'tid': test_id}, - ).mappings().first() - return {'testId': str(test_id), 'versionId': str(v['id'])} - - -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']}, - ) - - -def _replace_version_content(conn, test_version_id, payload: dict) -> None: - conn.execute( - text( - """ - DELETE FROM answer_options WHERE question_id IN ( - SELECT id FROM questions WHERE test_version_id = :v - ) - """ - ), - {'v': test_version_id}, + session = get_session() + try: + uid = _uuid.UUID(author_id) + except (ValueError, AttributeError): + raise HttpError(400, 'Некорректный user_id.') + + test = Test( + title=title, + description=description or None, + created_by=uid, + is_active=True, + is_versioned=True, ) - conn.execute( - text('DELETE FROM questions WHERE test_version_id = :v'), - {'v': test_version_id}, + session.add(test) + session.flush() # получаем test.id + + version = TestVersion(test_id=test.id, version=1, is_active=True, parent_id=None) + session.add(version) + session.commit() + return {'testId': str(test.id), 'versionId': str(version.id)} + + +def _get_active_version(session: Session, test_id: _uuid.UUID) -> TestVersion | None: + return ( + session.query(TestVersion) + .filter(TestVersion.test_id == test_id, TestVersion.is_active.is_(True)) + .first() + ) + + +def _copy_question_tree(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() ) - questions = payload.get('questions') or [] - for i, q in enumerate(questions): - ins_q = conn.execute( - text( - """ - INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers) - VALUES (:v, :text, :ord, :multi) RETURNING id - """ - ), - { - 'v': test_version_id, - 'text': q.get('text'), - 'ord': q.get('question_order') or (i + 1), - 'multi': bool(q.get('hasMultipleAnswers')), - }, - ).mappings().first() - qid = ins_q['id'] - opts = q.get('options') or [] - for j, o in enumerate(opts): - conn.execute( - text( - """ - INSERT INTO answer_options (question_id, text, is_correct, option_order) - VALUES (:q, :text, :ic, :ord) - """ - ), - { - 'q': qid, - 'text': o.get('text'), - 'ic': bool(o.get('isCorrect')), - 'ord': o.get('option_order') or (j + 1), - }, - ) - - -def _fork_new_version(conn, test_id: str) -> dict: - av = _get_active_version_row(conn, test_id) + for q in questions: + new_q = Question( + test_version_id=to_version_id, + text=q.text, + question_order=q.question_order, + has_multiple_answers=q.has_multiple_answers, + ai_hint=q.ai_hint, + ) + session.add(new_q) + session.flush() + for o in sorted(q.options, key=lambda x: x.option_order): + session.add(AnswerOption( + question_id=new_q.id, + text=o.text, + is_correct=o.is_correct, + option_order=o.option_order, + )) + + +def _fork_new_version(session: Session, test_id: _uuid.UUID) -> TestVersion: + av = _get_active_version(session, test_id) if not av: - raise HttpError(500, RU['internal']) # invariant: должна быть активная версия - mx = conn.execute( - text( - 'SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = :t' - ), - {'t': test_id}, - ).mappings().first() - next_v = (mx['v'] or 0) + 1 - conn.execute( - text('UPDATE test_versions SET is_active = false WHERE test_id = :t'), - {'t': test_id}, + raise HttpError(500, RU['internal'] if 'internal' in RU else 'Внутренняя ошибка.') + + max_ver = ( + session.query(func.coalesce(func.max(TestVersion.version), 0)) + .filter(TestVersion.test_id == test_id) + .scalar() or 0 + ) + next_v = int(max_ver) + 1 + + # деактивируем все версии + session.query(TestVersion).filter(TestVersion.test_id == test_id).update( + {TestVersion.is_active: False}, synchronize_session='fetch' + ) + + new_version = TestVersion( + test_id=test_id, + version=next_v, + is_active=True, + parent_id=av.id, ) - nv = conn.execute( - text( - """ - INSERT INTO test_versions (test_id, version, is_active, parent_id) - VALUES (:t, :ver, true, :parent) RETURNING * - """ - ), - {'t': test_id, 'ver': next_v, 'parent': av['id']}, - ).mappings().first() - _copy_question_tree(conn, av['id'], nv['id']) - return dict(nv) + session.add(new_version) + session.flush() + _copy_question_tree(session, av.id, new_version.id) + return new_version + + +def _replace_version_content(session: Session, version: TestVersion, payload: dict) -> None: + # Снимок ai_hint по тексту вопроса перед удалением + old_hints: dict[str, str] = {} + for q in version.questions: + if q.ai_hint and q.text not in old_hints: + old_hints[q.text] = q.ai_hint + + # удаляем через cascade (answer_options удалятся каскадно через ORM) + for q in list(version.questions): + session.delete(q) + session.flush() + + questions_payload = payload.get('questions') or [] + for i, qp in enumerate(questions_payload): + q_text = (qp.get('text') or '').strip() + new_q = Question( + test_version_id=version.id, + text=q_text, + question_order=qp.get('question_order') or (i + 1), + has_multiple_answers=bool(qp.get('hasMultipleAnswers')), + ai_hint=old_hints.get(q_text), + ) + session.add(new_q) + session.flush() + for j, op in enumerate(qp.get('options') or []): + session.add(AnswerOption( + question_id=new_q.id, + text=(op.get('text') or '').strip(), + is_correct=bool(op.get('isCorrect')), + option_order=op.get('option_order') or (j + 1), + )) def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict: if not isinstance(payload, dict): payload = {} - eng = get_engine() - with eng.begin() as conn: - t = conn.execute( - text('SELECT id, created_by FROM tests WHERE id = :id'), - {'id': test_id}, - ).mappings().first() - if not t: - raise HttpError(404, RU['testNotFound'] if 'testNotFound' in RU else 'Тест не найден.') - if not is_test_author(t['created_by'], author_id): - raise HttpError(403, 'Доступ запрещён.') - - if payload.get('title') is not None or payload.get('description') is not None: - conn.execute( - text( - """ - UPDATE tests - SET title = COALESCE(:title, title), - description = COALESCE(:desc, description), - updated_at = CURRENT_TIMESTAMP - WHERE id = :id - """ - ), - { - 'title': payload.get('title'), - 'desc': payload.get('description'), - 'id': test_id, - }, - ) - if payload.get('passingThreshold') is not None: - try: - raw = float(payload['passingThreshold']) - pt = max(0, min(100, round(raw))) - conn.execute( - text( - 'UPDATE tests SET passing_threshold = :pt, updated_at = CURRENT_TIMESTAMP WHERE id = :id' - ), - {'pt': pt, 'id': test_id}, - ) - except (TypeError, ValueError): - pass - - has_attempts = has_any_attempt_for_test(conn, test_id) - version_row = _get_active_version_row(conn, test_id) - if not version_row: - raise HttpError(500, 'Нет активной версии теста.') - - forked = False - if has_attempts and 'questions' in payload and payload.get('questions') is not None: - version_row = _fork_new_version(conn, test_id) - forked = True - - if payload.get('questions') is not None: - _replace_version_content(conn, version_row['id'], payload) - - return {'testId': test_id, 'versionId': str(version_row['id']), 'forked': forked} + session = get_session() + try: + tid = _uuid.UUID(test_id) + except (ValueError, AttributeError): + raise HttpError(404, 'Тест не найден.') + + test = session.get(Test, tid) + if not test: + raise HttpError(404, 'Тест не найден.') + if not is_test_author(test.created_by, author_id): + raise HttpError(403, 'Доступ запрещён.') + + if payload.get('title') is not None: + test.title = payload['title'] + if payload.get('description') is not None: + test.description = payload['description'] or None + + if payload.get('passingThreshold') is not None: + try: + test.passing_threshold = max(0, min(100, round(float(payload['passingThreshold'])))) + except (TypeError, ValueError): + pass + + if 'timeLimit' in payload: + tl = payload.get('timeLimit') + try: + test.time_limit = None if tl in (None, '', 0) else max(0, int(tl)) + except (TypeError, ValueError): + pass + + if 'hintsEnabled' in payload: + test.hints_enabled = bool(payload['hintsEnabled']) + + if 'resultMode' in payload: + rm = (payload.get('resultMode') or '').strip().lower() + if rm in ('immediate', 'end'): + test.result_mode = rm + + has_attempts = has_any_attempt_for_test(session, tid) + active_version = _get_active_version(session, tid) + if not active_version: + raise HttpError(500, 'Нет активной версии теста.') + + forked = False + if has_attempts and 'questions' in payload and payload.get('questions') is not None: + active_version = _fork_new_version(session, tid) + forked = True + + if payload.get('questions') is not None: + _replace_version_content(session, active_version, payload) + + session.commit() + return {'testId': test_id, 'versionId': str(active_version.id), 'forked': forked} diff --git a/flask_app/app/static/css/app.css b/flask_app/app/static/css/app.css index 058891c..06df7f1 100644 --- a/flask_app/app/static/css/app.css +++ b/flask_app/app/static/css/app.css @@ -264,6 +264,20 @@ body.ui-legacy .btn-ghost:hover { text-decoration: none; } +body.ui-legacy .btn-primary { + background: var(--primary); + color: #fff; + border-color: var(--primary); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} +body.ui-legacy .btn-primary:hover { + background: var(--primary-hover); + border-color: var(--primary-hover); +} +body.ui-legacy .btn-primary:active { + transform: translateY(0.5px); +} + body.ui-legacy .text-muted { color: var(--on-surface-variant); font-size: 0.875rem; @@ -365,6 +379,52 @@ body.ui-legacy .callout--warning { color: #92400e; } +body.ui-legacy .callout--error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; +} + +/* Страница входа (legacy) */ +body.ui-legacy .login-page { + display: flex; + justify-content: center; + width: 100%; + padding: 1rem 0; +} +body.ui-legacy .login-shell { + width: 100%; + max-width: 22rem; +} +body.ui-legacy .login-logo { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-bottom: 1.25rem; +} +body.ui-legacy .login-logo .font-headline { + margin: 0.5rem 0 0; + font-size: 1.35rem; + font-weight: 700; +} +body.ui-legacy .login-card { + background: var(--surface); + border: 1px solid color-mix(in srgb, var(--outline-variant) 38%, transparent); + border-radius: 1rem; + padding: 1.35rem 1.25rem 1.5rem; + box-shadow: var(--shadow-card); +} +body.ui-legacy .form-field + .form-field { + margin-top: 1rem; +} +body.ui-legacy .login-card .btn-primary { + width: 100%; + margin-top: 1rem; + min-height: 2.65rem; + justify-content: center; +} + body.ui-legacy .muted, body.ui-legacy .text-muted, body.ui-legacy .text-secondary { @@ -402,6 +462,16 @@ body.ui-legacy .form-input:focus { box-shadow: 0 0 0 3px rgba(0, 113, 104, 0.12); background: #fff; } +body.ui-legacy select.form-input { + appearance: none; + -webkit-appearance: none; + padding-right: 2.5rem; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 1rem; + cursor: pointer; +} body.ui-legacy .surface-card { background: var(--surface); @@ -421,23 +491,53 @@ body.ui-legacy .cabinet-brick--hero { margin-bottom: 1.25rem; } -.hero-brick__nav { +.hero-brick__meta-row { display: flex; - justify-content: space-between; - align-items: baseline; flex-wrap: wrap; - gap: 0.5rem; - font-size: 0.85rem; + align-items: baseline; + gap: 0.35rem; + margin-top: 0.5rem; + font-size: 0.82rem; color: var(--ink-500, #6b7280); } -.hero-brick__meta { - display: inline-flex; +.hero-brick__sep { opacity: 0.45; } + +.hero-brick__divider { + margin-top: 0.75rem; + border-top: 1px solid color-mix(in srgb, var(--outline-variant, #e5e7eb) 60%, transparent); +} + +.hero-brick__tags { + margin-top: 0.6rem; + display: flex; flex-wrap: wrap; - gap: 0.4rem; - align-items: baseline; - color: var(--ink-500, #6b7280); + gap: 0.45rem; + align-items: center; +} +.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 { display: block; @@ -510,6 +610,41 @@ body.ui-legacy .cabinet-brick--hero { } .hero-brick__chip input[type="checkbox"] { accent-color: var(--primary, #0d9488); } +.q-item { transition: box-shadow .15s ease, transform .12s ease; } +.q-item.q-dragging { opacity: 0.55; box-shadow: 0 6px 20px rgba(0,0,0,0.12); } +.q-item.q-drop-before { box-shadow: 0 -2px 0 0 var(--primary, #0d9488) inset; } +.q-item.q-drop-after { box-shadow: 0 2px 0 0 var(--primary, #0d9488) inset; } +.q-drag { cursor: grab; color: var(--ink-500, #6b7280); } +.q-drag:active { cursor: grabbing; } + +.q-item.q-removed > *:not(.q-removed-banner) { opacity: 0.45; } +.q-item.q-removed .q-text, +.q-item.q-removed .q-multi, +.q-item.q-removed .q-options, +.q-item.q-removed .q-add-option, +.q-item.q-removed .q-ai, +.q-item.q-removed .q-up, +.q-item.q-removed .q-down, +.q-item.q-removed .q-delete, +.q-item.q-removed .q-drag { + pointer-events: none; +} +.q-item.q-removed .q-text { text-decoration: line-through; } +.q-removed-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + margin-bottom: .5rem; + padding: .35rem .6rem; + background: color-mix(in srgb, #fff7ed 65%, transparent); + border: 1px solid color-mix(in srgb, #fbbf24 50%, transparent); + border-radius: .5rem; + color: #92400e; + font-size: .85rem; +} +.q-removed-banner .q-restore { pointer-events: auto; } + body.ui-legacy .cabinet-disclosure { border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); border-radius: 1rem; @@ -567,10 +702,167 @@ body.ui-legacy .cabinet-disclosure__summary-sub { } body.ui-legacy .cabinet-disclosure__body { - padding: 0.7rem 1rem 1.05rem; + padding: 1rem 1rem 1.25rem; border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); } +/* ─── Question textarea + char counter ──────────────────────────── */ +.q-text { + padding-bottom: 1.6rem; /* space for counter */ + resize: none; + overflow: hidden; + line-height: 1.55; +} +.q-text-counter { + font-size: 0.68rem; + line-height: 1; + bottom: 6px !important; + right: 10px !important; +} + +/* ─── Question editor blocks (AI panel sections) ─────────────────── */ +body.ui-legacy .question-editor-block { + padding-top: 1rem; + margin-top: 1rem; + border-top: 1px solid color-mix(in srgb, var(--outline-variant) 28%, transparent); +} +body.ui-legacy .question-editor-block--first { + padding-top: 0; + margin-top: 0; + border-top: none; +} +body.ui-legacy .test-detail-subsection__title { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.6rem; + color: var(--ink-900, #111827); +} +body.ui-legacy .test-detail-ai-panel { + padding: 1rem 1.1rem 1.1rem; + margin-bottom: 1.25rem; +} + +/* ─── Option row alignment ───────────────────────────────────────── */ +.question-option-row { + align-items: flex-start; +} +.question-option-row__mark-wrap { + padding-top: 0.45rem; /* align checkbox with first line of textarea */ +} +.opt-text { + line-height: 1.55; +} +.opt-delete { + margin-top: 0.2rem; +} + +/* ─── Question AI overlay ────────────────────────────────────────── */ +.q-ai-overlay { + transition: opacity 0.15s; +} +.q-item[style*="pointer-events: none"] .q-text, +.q-item[style*="pointer-events: none"] .opt-text { + opacity: 0.45; +} + +/* ─── Drag-and-drop import dropzone ─────────────────────────────── */ +.import-dropzone { + transition: border-color 0.15s, background-color 0.15s; +} +.import-dropzone--over { + border-color: var(--brand-500, #6366f1) !important; + background-color: color-mix(in srgb, var(--brand-100, #e0e7ff) 40%, transparent) !important; +} +.import-dropzone--loading { + opacity: 0.6; + pointer-events: none; +} +.import-dropzone--loading .material-symbols-outlined { + animation: spin 1s linear infinite; +} +.import-dropzone--done { + border-style: solid; + border-color: var(--primary, #007168) !important; + background-color: color-mix(in srgb, var(--primary, #007168) 6%, transparent) !important; + pointer-events: none; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ─── Import modal (compact, like save-modal) ───────────────────── */ +.save-modal { + padding: 0; + margin: 3rem auto auto; + border: none; + border-radius: 1rem; + background: #fff; + box-shadow: 0 8px 32px rgba(0,0,0,.18); + max-width: 26rem; + width: calc(100% - 2rem); +} +.save-modal::backdrop { + background: rgba(0,0,0,.4); +} +.save-modal__inner { + padding: 1.25rem 1.25rem 1rem; +} + +.settings-grid { + display: flex; + flex-direction: column; + gap: 0.65rem; +} +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.4rem 0.25rem; + border-bottom: 1px dashed color-mix(in srgb, var(--outline-variant) 30%, transparent); +} +.settings-row:last-child { border-bottom: none; } +.settings-row--block { + flex-direction: column; + align-items: stretch; + gap: 0.4rem; + border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); + border-radius: 0.75rem; + padding: 0.6rem 0.75rem; +} +.settings-row__label { + display: flex; + flex-direction: column; + font-size: 0.92rem; + color: var(--ink-700, #2c3a37); + gap: 0.1rem; +} +.settings-row__hint { + font-size: 0.78rem; + color: #6b7d79; + font-weight: 400; +} +.settings-row__input { + width: 6.5rem; + text-align: right; + border-radius: 0.6rem; + border: 1px solid var(--ink-300, #c8d2cf); + padding: 0.5rem 0.75rem; + background: white; +} +.settings-row__input:focus { + outline: none; + border-color: var(--brand-500, #2bb39a); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand-500, #2bb39a) 18%, transparent); +} +.settings-radio { + display: flex; + align-items: flex-start; + gap: 0.55rem; + cursor: pointer; + font-size: 0.92rem; + padding: 0.25rem 0; +} +.settings-radio input { margin-top: 0.2rem; } + body.ui-legacy .test-detail-subsection { margin-top: 1.25rem; padding-top: 1.15rem; @@ -584,8 +876,8 @@ body.ui-legacy .test-detail-subsection--tight { } body.ui-legacy .test-detail-subsection__title { - margin: 0 0 0.35rem; - font-size: 0.95rem; + margin: 0 0 0.5rem; + font-size: 0.875rem; font-weight: 600; } @@ -597,8 +889,8 @@ body.ui-legacy .test-detail-hint { } body.ui-legacy .test-detail-ai-panel { - padding: 0.9rem 1rem; - margin-bottom: 1.15rem; + padding: 1rem 1.1rem 1.1rem; + margin-bottom: 1.25rem; background: var(--surface-container-low); border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent); border-radius: 0.85rem; @@ -666,3 +958,69 @@ body.ui-legacy .attempts-card-list { flex-direction: column; gap: 0.5rem; } + +/* ─── Version items (compact row in top section) ─────────────────── */ +body.ui-legacy .version-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: 0.6rem; + background: var(--surface-container-low, #f5f5f5); + border: 1px solid var(--outline-variant, #e0e0e0); +} +body.ui-legacy .version-item[data-active="1"] { + background: color-mix(in srgb, var(--primary, #007168) 8%, white); + border-color: color-mix(in srgb, var(--primary, #007168) 25%, transparent); +} +body.ui-legacy .version-item__label { + font-weight: 600; + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.4rem; +} +body.ui-legacy .version-item__badge { + font-size: 0.65rem; + font-weight: 500; + padding: 0.1rem 0.4rem; + border-radius: 999px; + background: var(--primary, #007168); + color: #fff; + text-transform: uppercase; + letter-spacing: 0.03em; +} +body.ui-legacy .version-item__date { + font-size: 0.78rem; + flex: 1; +} +body.ui-legacy .version-item__spacer { + width: 1px; +} +body.ui-legacy #versions-section { + padding: 0.75rem 1rem; +} +body.ui-legacy .attempts-card-list__item { + padding: 0.75rem 1rem; +} +body.ui-legacy .attempts-card-list__row { + display: flex; + align-items: center; + gap: 0.75rem; +} +body.ui-legacy .attempts-card-list__main { + flex: 1 1 0; + min-width: 0; +} +body.ui-legacy .attempts-card-list__main p { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +body.ui-legacy .attempts-card-list__main p + p { + margin-top: 0.2rem; +} +body.ui-legacy .attempts-card-list__action { + flex-shrink: 0; +} diff --git a/flask_app/app/static/js/editor.js b/flask_app/app/static/js/editor.js index fd51a65..9017d5a 100644 --- a/flask_app/app/static/js/editor.js +++ b/flask_app/app/static/js/editor.js @@ -17,6 +17,8 @@ const $ = (sel, parent = document) => parent.querySelector(sel); const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel)); + const MAX_OPTIONS = 8; + const titleEl = $('#test-title'); const descEl = $('#test-description'); const thresholdEl = $('#test-threshold'); @@ -24,7 +26,8 @@ const qCountEl = $('#q-count'); const saveStatusEl = $('#save-status'); const aiStatusEl = $('#ai-status'); - const chainActiveEl = $('#chain-active'); + const chainActiveEl = { checked: true, _val: true }; // display-only — реальный toggle в блоке «Показ в каталоге» + const chainActiveDisplay = $('#chain-active-display'); const aiTopicEl = $('#ai-topic'); const aiQCountEl = $('#ai-q-count'); const aiOCountEl = $('#ai-o-count'); @@ -100,30 +103,78 @@ $('.q-multi', node).checked = !!q.hasMultipleAnswers; const optsEl = $('.q-options', node); - (q.options || []).forEach((o) => optsEl.appendChild(renderOption(o))); + (q.options || []).forEach((o) => optsEl.appendChild(renderOption(o, node))); bindQuestionEvents(node); syncOptionInputTypes(node); + updateOptionsCounter(node); + updateAiButtonLabel(node); return node; } - function renderOption(o) { + function renderOption(o, qNode) { const node = tplO.content.firstElementChild.cloneNode(true); - $('.opt-text', node).value = o.text || ''; + const textEl = $('.opt-text', node); + textEl.value = o.text || ''; $('.opt-correct', node).checked = !!o.isCorrect; + if (textEl && textEl.tagName === 'TEXTAREA') { + const resize = () => autoResize(textEl); + textEl.addEventListener('input', resize); + requestAnimationFrame(resize); + } $('.opt-delete', node).addEventListener('click', () => { node.remove(); + if (qNode) updateOptionsCounter(qNode); + scheduleDirtyCheck(); }); return node; } - function bindQuestionEvents(node) { - $('.q-delete', node).addEventListener('click', () => { - if (!confirm('Удалить вопрос?')) return; - node.remove(); + let dragSrc = null; + function bindDragEvents(node) { + const handle = $('.q-drag', node); + if (handle) { + handle.addEventListener('mousedown', () => { node.draggable = true; }); + handle.addEventListener('mouseup', () => { node.draggable = true; }); + } + node.addEventListener('dragstart', (e) => { + dragSrc = node; + node.classList.add('q-dragging'); + try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); } catch {} + }); + node.addEventListener('dragend', () => { + node.classList.remove('q-dragging'); + $$('#questions .q-item').forEach((li) => li.classList.remove('q-drop-before', 'q-drop-after')); + dragSrc = null; renumber(); scheduleDirtyCheck(); }); + node.addEventListener('dragover', (e) => { + if (!dragSrc || dragSrc === node) return; + e.preventDefault(); + const rect = node.getBoundingClientRect(); + const before = (e.clientY - rect.top) < rect.height / 2; + node.classList.toggle('q-drop-before', before); + node.classList.toggle('q-drop-after', !before); + }); + node.addEventListener('dragleave', () => { + node.classList.remove('q-drop-before', 'q-drop-after'); + }); + node.addEventListener('drop', (e) => { + if (!dragSrc || dragSrc === node) return; + e.preventDefault(); + const rect = node.getBoundingClientRect(); + const before = (e.clientY - rect.top) < rect.height / 2; + node.classList.remove('q-drop-before', 'q-drop-after'); + node.parentNode.insertBefore(dragSrc, before ? node : node.nextSibling); + }); + } + + function bindQuestionEvents(node) { + bindDragEvents(node); + $('.q-delete', node).addEventListener('click', () => { + markQuestionRemoved(node); + }); $('.q-up', node).addEventListener('click', () => { if (node.previousElementSibling) { node.parentNode.insertBefore(node, node.previousElementSibling); @@ -138,26 +189,122 @@ scheduleDirtyCheck(); } }); - $('.q-add-option', node).addEventListener('click', () => { - $('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false })); + const addOptBtn = $('.q-add-option', node); + addOptBtn.addEventListener('click', () => { + const optsEl = $('.q-options', node); + const count = $$('.opt-item', node).length; + if (count >= MAX_OPTIONS) return; + optsEl.appendChild(renderOption({ text: '', isCorrect: false }, node)); syncOptionInputTypes(node); + updateOptionsCounter(node); scheduleDirtyCheck(); }); + + // Кнопка очистки вопроса + const clearBtn = $('.q-clear', node); + if (clearBtn) { + clearBtn.addEventListener('click', () => { + const qTextEl = $('.q-text', node); + qTextEl.value = ''; + autoResize(qTextEl); + $$('.opt-text', node).forEach((t) => { t.value = ''; autoResize(t); }); + $$('.opt-correct', node).forEach((c) => { c.checked = false; }); + updateAiButtonLabel(node); + scheduleDirtyCheck(); + }); + } + + // Умная кнопка AI — label зависит от наличия текста + const qTextEl2 = $('.q-text', node); + if (qTextEl2) { + qTextEl2.addEventListener('input', () => updateAiButtonLabel(node)); + } + $('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node)); $('.q-multi', node).addEventListener('change', () => { syncOptionInputTypes(node); scheduleDirtyCheck(); }); + + // Счётчик символов у textarea вопроса + const qTextEl = $('.q-text', node); + const qCounter = $('.q-text-counter', node); + if (qTextEl && qCounter) { + const updateCounter = () => { + const len = qTextEl.value.length; + const max = parseInt(qTextEl.getAttribute('maxlength') || '500', 10); + qCounter.textContent = len > 200 ? `${len}/${max}` : ''; + qCounter.style.color = len > 450 ? '#ef4444' : len > 350 ? '#f59e0b' : ''; + autoResize(qTextEl); + }; + qTextEl.addEventListener('input', () => { updateCounter(); scheduleDirtyCheck(); }); + requestAnimationFrame(updateCounter); + } + } + + function updateAiButtonLabel(node) { + const qText = $('.q-text', node); + const label = $('.q-ai-label', node); + if (!qText || !label) return; + const hasText = qText.value.trim().length > 0; + label.textContent = hasText ? 'Улучшить' : 'Сгенерировать'; + } + + function updateOptionsCounter(node) { + const count = $$('.opt-item', node).length; + const countEl = $('.q-options-count', node); + const addBtn = $('.q-add-option', node); + const labelEl = $('.q-add-option-label', node); + if (countEl) countEl.textContent = count > 0 ? `${count}/${MAX_OPTIONS}` : ''; + if (addBtn) { + const atMax = count >= MAX_OPTIONS; + addBtn.disabled = atMax; + addBtn.style.opacity = atMax ? '0.4' : ''; + if (labelEl) labelEl.textContent = atMax ? 'Лимит вариантов' : 'Добавить вариант'; + } } function renumber() { - $$('#questions .q-item').forEach((li, i) => { - $('.q-num', li).textContent = `Вопрос #${i + 1}`; + let i = 0; + $$('#questions .q-item').forEach((li) => { + const removed = li.classList.contains('q-removed'); + if (removed) { + $$('.q-num', li).forEach((el) => { el.textContent = 'Удалён'; }); + return; + } + i += 1; + $$('.q-num', li).forEach((el) => { el.textContent = `Вопрос #${i}`; }); }); - const n = $$('#questions .q-item').length; - if (qCountEl) qCountEl.textContent = n; + if (qCountEl) qCountEl.textContent = i; const mirror = document.getElementById('q-count-mirror'); - if (mirror) mirror.textContent = n; + if (mirror) mirror.textContent = i; + } + + function markQuestionRemoved(node) { + if (node.classList.contains('q-removed')) return; + node.classList.add('q-removed'); + node.draggable = false; + let banner = $('.q-removed-banner', node); + if (!banner) { + banner = document.createElement('div'); + banner.className = 'q-removed-banner'; + banner.innerHTML = + 'Вопрос будет удалён при сохранении' + + ''; + node.prepend(banner); + $('.q-restore', banner).addEventListener('click', () => restoreQuestion(node)); + } + renumber(); + scheduleDirtyCheck(); + } + + function restoreQuestion(node) { + node.classList.remove('q-removed'); + node.draggable = true; + const banner = $('.q-removed-banner', node); + if (banner) banner.remove(); + renumber(); + scheduleDirtyCheck(); } function autoResize(el) { @@ -166,20 +313,71 @@ el.style.height = el.scrollHeight + 'px'; } + function syncThresholdMirror() { + const m = document.getElementById('threshold-mirror'); + if (!m) return; + const v = (thresholdEl && thresholdEl.value !== '') ? thresholdEl.value : '—'; + m.textContent = v; + } + function loadInitial() { titleEl.value = initial.test.title || ''; descEl.value = initial.test.description || ''; autoResize(titleEl); autoResize(descEl); + if (thresholdEl) { + thresholdEl.addEventListener('input', syncThresholdMirror); + thresholdEl.addEventListener('change', syncThresholdMirror); + } if (titleEl && titleEl.tagName === 'TEXTAREA') { - titleEl.addEventListener('input', () => autoResize(titleEl)); + titleEl.addEventListener('input', () => { + autoResize(titleEl); + // Синхронизируем поле темы, только если оно не было изменено вручную + if (aiTopicEl && aiTopicEl.dataset.userEdited !== '1') { + aiTopicEl.value = titleEl.value; + } + }); titleEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') e.preventDefault(); }); } + if (aiTopicEl) { + aiTopicEl.addEventListener('input', () => { + aiTopicEl.dataset.userEdited = '1'; + autoResize(aiTopicEl); + }); + } if (descEl) descEl.addEventListener('input', () => autoResize(descEl)); + if (docUserHint) docUserHint.addEventListener('input', () => autoResize(docUserHint)); thresholdEl.value = initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold); + syncThresholdMirror(); + + const timeLimitEl = document.getElementById('test-time-limit'); + const hintsEl = document.getElementById('test-hints-enabled'); + const hintsRow = document.getElementById('test-hints-row'); + const resultModeRadios = document.querySelectorAll('input[name="result-mode"]'); + + if (timeLimitEl) { + timeLimitEl.value = initial.test.timeLimit == null ? '' : Number(initial.test.timeLimit); + timeLimitEl.addEventListener('input', scheduleDirtyCheck); + } + const initMode = (initial.test.resultMode === 'immediate') ? 'immediate' : 'end'; + resultModeRadios.forEach((r) => { + r.checked = (r.value === initMode); + r.addEventListener('change', () => { + const mode = document.querySelector('input[name="result-mode"]:checked'); + const isImmediate = mode && mode.value === 'immediate'; + if (hintsRow) hintsRow.style.display = isImmediate ? '' : 'none'; + if (hintsEl && !isImmediate) hintsEl.checked = false; + scheduleDirtyCheck(); + }); + }); + if (hintsEl) { + hintsEl.checked = !!initial.test.hintsEnabled; + hintsEl.addEventListener('change', scheduleDirtyCheck); + } + if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : 'none'; questionsEl.innerHTML = ''; (initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); @@ -187,6 +385,7 @@ if (aiTopicEl && !aiTopicEl.value.trim()) { aiTopicEl.value = initial.test.title || ''; } + if (aiTopicEl) requestAnimationFrame(() => autoResize(aiTopicEl)); } function fmtDt(iso) { @@ -207,7 +406,7 @@ // ─── collect ─────────────────────────────────────────────────────── function collectPayload() { - const questions = $$('#questions .q-item').map((li, i) => ({ + const questions = $$('#questions .q-item:not(.q-removed)').map((li, i) => ({ text: $('.q-text', li).value.trim(), question_order: i + 1, hasMultipleAnswers: $('.q-multi', li).checked, @@ -224,6 +423,17 @@ }; const t = thresholdEl.value; if (t !== '' && Number.isFinite(Number(t))) payload.passingThreshold = Number(t); + + const timeLimitEl = document.getElementById('test-time-limit'); + if (timeLimitEl) { + const tl = timeLimitEl.value; + payload.timeLimit = (tl === '' ? null : Math.max(0, Number(tl) || 0)); + } + const modeEl = document.querySelector('input[name="result-mode"]:checked'); + payload.resultMode = (modeEl && modeEl.value === 'immediate') ? 'immediate' : 'end'; + const hintsEl = document.getElementById('test-hints-enabled'); + payload.hintsEnabled = !!(hintsEl && hintsEl.checked && payload.resultMode === 'immediate'); + return payload; } @@ -271,11 +481,48 @@ }); if (r2.ok) chainActive = chainActiveEl.checked; } - saveStatusEl.textContent = data.forked - ? 'Сохранено (создана новая версия — есть попытки прохождения).' - : 'Сохранено.'; resetBaselineDraft(); - setTimeout(() => (saveStatusEl.textContent = ''), 4000); + const msg = data.forked + ? 'Сохранено. Создана новая версия — у теста есть попытки прохождения.' + : 'Изменения сохранены.'; + const saveModal = document.getElementById('save-modal'); + const saveMsg = document.getElementById('save-modal-msg'); + + const hintsEl = document.getElementById('test-hints-enabled'); + const modeEl = document.querySelector('input[name="result-mode"]:checked'); + const wantsHints = !!(hintsEl && hintsEl.checked) && modeEl && modeEl.value === 'immediate'; + if (wantsHints) { + try { + const sr = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`); + const st = await sr.json().catch(() => ({})); + if (sr.ok && Number(st.missing) > 0) { + saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`; + const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' }); + const gd = await gr.json().catch(() => ({})); + if (!gr.ok) { + saveStatusEl.textContent = ''; + alert(gd.error || 'Не удалось сгенерировать подсказки.'); + if (saveMsg) saveMsg.textContent = msg + ' (часть подсказок не создана)'; + if (saveModal) saveModal.showModal(); + return; + } + const tail = gd.failed + ? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось.` + : ` Подсказки созданы (${gd.generated}).`; + if (saveMsg) saveMsg.textContent = msg + tail; + } else { + if (saveMsg) saveMsg.textContent = msg; + } + } catch (err) { + if (saveMsg) saveMsg.textContent = msg + ' (ИИ-подсказки не созданы)'; + } + } else { + if (saveMsg) saveMsg.textContent = msg; + } + saveStatusEl.textContent = ''; + if (saveModal) { + saveModal.showModal(); + } } catch (e) { saveStatusEl.textContent = ''; alert(e.message || 'Не удалось сохранить.'); @@ -288,6 +535,16 @@ alert('Укажите тему.'); return; } + // Предупреждение, если в тесте уже есть вопросы или заполненное название/описание + const hasContent = questionsEl.children.length > 0 + || titleEl.value.trim() + || descEl.value.trim(); + if (hasContent) { + const ok = confirm( + 'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?' + ); + if (!ok) return; + } const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7)); const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3)); const shape = Array.from({ length: nQ }, () => ({ @@ -325,50 +582,227 @@ } }); - // ─── импорт документа (E1.3) ─────────────────────────────────── + // ─── импорт документа с drag-and-drop (E1.3) ────────────────── - $('#ai-import-file').addEventListener('change', async (ev) => { - const file = ev.target.files && ev.target.files[0]; - ev.target.value = ''; + const importDropzone = $('#ai-import-dropzone'); + const importDropzoneLabel = $('#ai-import-dropzone-label'); + const docUserHint = $('#doc-user-hint'); + const docGenerateBtn = $('#doc-generate-btn'); + const importModal = $('#import-modal'); + const importModalTitle = $('#import-modal-title'); + const importModalBody = $('#import-modal-body'); + const importModalActions = $('#import-modal-actions'); + + let _extractedText = ''; + let _extractedFileName = ''; + + function openImportModal(title, bodyHtml, actions) { + importModalTitle.textContent = title; + importModalBody.innerHTML = bodyHtml; + importModalActions.innerHTML = ''; + actions.forEach(({ label, onClick, primary }) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = label; + btn.className = primary + ? 'px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium' + : 'px-4 py-2 rounded-lg bg-white border border-ink-300 hover:bg-ink-50 text-ink-700 text-sm'; + btn.addEventListener('click', onClick); + importModalActions.appendChild(btn); + }); + importModal.showModal(); + } + + // Фаза 1: выбрать файл → извлечь текст, обновить метку дропзоны + async function handleImportFile(file) { if (!file) return; aiStatusEl.textContent = `Загружаем «${file.name}»…`; + importDropzone.classList.add('import-dropzone--loading'); try { const fd = new FormData(); fd.append('file', file); const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd }); const data = await r.json(); - if (!r.ok) throw new Error(data.error || 'Не удалось импортировать.'); + if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.'); + _extractedText = data.extractedText || ''; + _extractedFileName = file.name; + aiStatusEl.textContent = `Файл загружен: «${file.name}» · ${data.textLength ?? 0} символов`; + if (importDropzoneLabel) importDropzoneLabel.textContent = `✓ ${file.name}`; + importDropzone.classList.add('import-dropzone--done'); + } catch (e) { + aiStatusEl.textContent = ''; + openImportModal( + 'Ошибка загрузки', + `

${escHtml(e.message || 'Не удалось загрузить файл.')}

`, + [{ label: 'Закрыть', onClick: () => importModal.close() }], + ); + } finally { + importDropzone.classList.remove('import-dropzone--loading'); + } + } + + // Фаза 2: сгенерировать тест из извлечённого текста + подсказки + async function handleGenerateFromDoc() { + if (!_extractedText) return; + const userHint = docUserHint ? docUserHint.value.trim() : ''; + docGenerateBtn.disabled = true; + docGenerateBtn.textContent = 'Генерируем…'; + aiStatusEl.textContent = 'Генерируем тест из документа…'; + try { + const r = await fetch('/api/tests/generate-from-extracted', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ extractedText: _extractedText, userHint }), + }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'Ошибка генерации.'); const g = data.generation || {}; + aiStatusEl.textContent = ''; + if (!g.available) { - aiStatusEl.textContent = ''; - const msg = g.message || 'AI недоступен.'; - const preview = (g.textPreview || data.extractedText || '').slice(0, 600); - alert(msg + (preview ? '\n\nПервые 600 символов:\n' + preview : '')); + openImportModal( + 'AI недоступен', + `

+ ${escHtml(g.message || 'AI недоступен — ключ не настроен.')} +

`, + [{ label: 'Закрыть', onClick: () => importModal.close() }], + ); return; } - const ok = confirm( - `${g.message}\n\nПрименить как новый черновик?\n` + - `Текущие вопросы будут заменены.`, + + const draft = g.draft || {}; + const qs = draft.questions || []; + const qPreview = qs.slice(0, 4).map((q, i) => + `
  • ${i + 1}. ${escHtml((q.text || '').slice(0, 80))}${(q.text || '').length > 80 ? '…' : ''}
  • ` + ).join(''); + const moreCount = qs.length > 4 ? qs.length - 4 : 0; + const bodyHtml = ` + ${draft.title ? `

    ${escHtml(draft.title)}

    ` : ''} + ${draft.description ? `

    ${escHtml(draft.description)}

    ` : ''} +

    Вопросов: ${qs.length}

    + ${qs.length ? ` + ${moreCount ? `

    …и ещё ${moreCount}

    ` : ''}` : ''} +

    + Текущие вопросы теста будут заменены. +

    `; + + openImportModal( + `Черновик из «${escHtml(_extractedFileName)}»`, + bodyHtml, + [ + { + label: 'Применить', + primary: true, + onClick: () => { + importModal.close(); + if (draft.title) { titleEl.value = draft.title; autoResize(titleEl); } + if (draft.description) { descEl.value = draft.description; autoResize(descEl); } + questionsEl.innerHTML = ''; + qs.forEach((q) => questionsEl.appendChild(renderQuestion(q))); + renumber(); + scheduleDirtyCheck(); + aiStatusEl.textContent = `Применено: ${qs.length} вопросов.`; + setTimeout(() => (aiStatusEl.textContent = ''), 4000); + // Сброс зоны загрузки + _extractedText = ''; + _extractedFileName = ''; + if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файл сюда или нажмите'; + importDropzone.classList.remove('import-dropzone--done'); + if (docUserHint) docUserHint.value = ''; + aiStatusEl.textContent = ''; + }, + }, + { label: 'Отмена', onClick: () => importModal.close() }, + ], ); - if (!ok) { - aiStatusEl.textContent = ''; - return; - } - const draft = g.draft; - if (draft.title) titleEl.value = draft.title; - if (draft.description) descEl.value = draft.description; - questionsEl.innerHTML = ''; - (draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); - renumber(); - scheduleDirtyCheck(); - aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`; - setTimeout(() => (aiStatusEl.textContent = ''), 4000); } catch (e) { aiStatusEl.textContent = ''; - alert(e.message || 'Не удалось импортировать.'); + openImportModal( + 'Ошибка генерации', + `

    ${escHtml(e.message || 'Не удалось сгенерировать тест.')}

    `, + [{ label: 'Закрыть', onClick: () => importModal.close() }], + ); + } finally { + if (docGenerateBtn) { + docGenerateBtn.disabled = false; + docGenerateBtn.innerHTML = 'auto_awesome Сгенерировать из документа'; + } + } + } + + 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 ───────── function aiAlert(data, fallback) { @@ -388,6 +822,34 @@ const modalActions = $('#ai-modal-actions'); $('#ai-modal-close').addEventListener('click', () => modal.close()); + const saveModalEl = document.getElementById('save-modal'); + const saveStayBtn = document.getElementById('save-modal-stay'); + const saveGoBtn = document.getElementById('save-modal-go'); + if (saveStayBtn) saveStayBtn.addEventListener('click', () => saveModalEl.close()); + if (saveGoBtn) saveGoBtn.addEventListener('click', () => { window.location.href = '/tests'; }); + + function doCancel() { + if (isDirty()) { + if (!confirm('Есть несохранённые изменения. Уйти без сохранения?')) return; + } + window.location.href = '/tests'; + } + + const cancelBtn = document.getElementById('btn-cancel'); + if (cancelBtn) cancelBtn.addEventListener('click', doCancel); + + const cancelBtnInline = document.getElementById('btn-cancel-inline'); + if (cancelBtnInline) cancelBtnInline.addEventListener('click', doCancel); + + // Кнопка «Сохранить» под вопросами — дублирует основную + const saveDraftInlineBtn = document.getElementById('save-draft-inline'); + const saveStatusInlineEl = document.getElementById('save-status-inline'); + if (saveDraftInlineBtn) { + saveDraftInlineBtn.addEventListener('click', () => { + document.getElementById('save-draft')?.click(); + }); + } + function openModal(title, bodyHtml, actions) { modalTitle.textContent = title; modalBody.innerHTML = bodyHtml; @@ -610,11 +1072,39 @@ }); async function aiGenerateQuestion(node) { - const qText = $('.q-text', node).value.trim(); - const optsCount = Math.max(2, $$('.opt-item', node).length || 4); + const qTextEl = $('.q-text', node); + const qText = qTextEl.value.trim(); + const existingOpts = $$('.opt-item', node); + const optsCount = Math.max(2, existingOpts.length || 4); const multi = $('.q-multi', node).checked; - aiStatusEl.textContent = 'AI: один вопрос…'; + const overlay = $('.q-ai-overlay', node); + + // Показываем оверлей + overlay?.classList.remove('hidden'); + node.style.pointerEvents = 'none'; + try { + // Собираем варианты с их состоянием + const existingOptions = existingOpts.map((op) => ({ + text: $('.opt-text', op).value.trim(), + isCorrect: $('.opt-correct', op).checked, + })); + const emptySlots = existingOptions.filter((o) => !o.text).length; + const filledSlots = existingOptions.filter((o) => o.text).length; + + // Выбираем режим: + // - нет текста вопроса → full + // - есть вопрос + есть пустые варианты (и хоть один заполнен) → distractors + // - есть вопрос, все варианты заполнены или вариантов нет → rephrase + let requestMode; + if (!qText) { + requestMode = 'full'; + } else if (emptySlots > 0 && filledSlots > 0) { + requestMode = 'distractors'; + } else { + requestMode = 'rephrase'; + } + const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -624,86 +1114,154 @@ questionText: qText, optionsCount: optsCount, hasMultipleAnswers: multi, + mode: requestMode, + existingOptions: qText ? existingOptions : undefined, }), }); const data = await r.json(); if (!r.ok) throw new Error(data.error || 'AI: ошибка.'); - $('.q-text', node).value = data.text || ''; - if (data.mode === 'full' && Array.isArray(data.options)) { - const optsEl = $('.q-options', node); + + // Обновляем текст вопроса (кроме режима дистракторов — текст не меняем) + if (data.mode !== 'distractors') { + qTextEl.value = data.text || qText; + autoResize(qTextEl); + } + + const optsEl = $('.q-options', node); + if (data.mode === 'full' && Array.isArray(data.options) && data.options.length) { + // Полная замена вариантов optsEl.innerHTML = ''; - data.options.forEach((o) => optsEl.appendChild(renderOption(o))); + data.options.forEach((o) => optsEl.appendChild(renderOption(o, node))); $('.q-multi', node).checked = !!data.hasMultipleAnswers; + } else if (data.mode === 'distractors' && Array.isArray(data.options) && data.options.length) { + // Заполняем только пустые слоты + let dIdx = 0; + existingOpts.forEach((op) => { + const t = $('.opt-text', op); + if (!t.value.trim() && dIdx < data.options.length) { + t.value = data.options[dIdx].text || ''; + autoResize(t); + dIdx++; + } + }); } + + syncOptionInputTypes(node); + updateOptionsCounter(node); + updateAiButtonLabel(node); scheduleDirtyCheck(); - aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.'; - setTimeout(() => (aiStatusEl.textContent = ''), 4000); } catch (e) { aiStatusEl.textContent = ''; alert(e.message || 'AI: ошибка.'); + } finally { + overlay?.classList.add('hidden'); + node.style.pointerEvents = ''; } } // ─── chain active state (грузим summary, чтобы знать стартовое значение) ─── + function updateChainActiveDisplay(active) { + chainActive = !!active; + chainActiveEl.checked = chainActive; + if (chainActiveDisplay) { + chainActiveDisplay.textContent = chainActive ? '✓ Активна в каталоге' : 'Скрыта из каталога'; + chainActiveDisplay.style.color = chainActive ? '' : 'var(--ink-500, #6b7280)'; + } + } + fetch(`/api/tests/${TEST_ID}/summary`) .then((r) => r.json()) .then((data) => { if (data && data.test && typeof data.test.chainActive === 'boolean') { - chainActive = data.test.chainActive; - chainActiveEl.checked = chainActive; + updateChainActiveDisplay(data.test.chainActive); } else { - chainActiveEl.checked = true; - chainActive = true; + updateChainActiveDisplay(true); } }) - .catch(() => { - chainActiveEl.checked = true; - }); + .catch(() => { updateChainActiveDisplay(true); }); function renderVersions(rows) { if (!versionsListEl) return; versionsListEl.innerHTML = ''; + if (!(rows || []).length) { + versionsListEl.innerHTML = '
  • Нет версий.
  • '; + return; + } (rows || []).forEach((r) => { const li = document.createElement('li'); - li.className = 'surface-card version-card-list__item'; + li.className = 'version-item'; + li.dataset.versionId = r.id; + li.dataset.active = r.is_active ? '1' : '0'; li.innerHTML = ` -
    -
    -
    - v${r.version} - ${r.is_active ? 'текущая' : ''} -
    -

    ${fmtDt(r.created_at)}

    -

    Активна: ${r.is_active ? 'да' : 'нет'}

    -
    -
    `; + + Версия ${r.version} + ${r.is_active ? 'активная' : ''} + + ${fmtDt(r.created_at)} + ${!r.is_active + ? `` + : ''}`; versionsListEl.appendChild(li); }); + versionsListEl.querySelectorAll('.version-item__activate').forEach((btn) => { + btn.addEventListener('click', async () => { + const vid = btn.dataset.versionId; + btn.disabled = true; + btn.textContent = '…'; + try { + const r = await fetch(`/api/tests/${TEST_ID}/versions/${vid}/activate`, { method: 'POST' }); + if (!r.ok) throw new Error('Не удалось активировать'); + // обновить список + const v = await fetch(`/api/tests/${TEST_ID}/versions`).then((x) => x.json()).catch(() => null); + if (v && Array.isArray(v.versions)) renderVersions(v.versions); + } catch (e) { + btn.disabled = false; + btn.textContent = 'Сделать активной'; + alert(e.message); + } + }); + }); } function renderAttempts(rows) { if (!attemptsListEl) return; attemptsListEl.innerHTML = ''; + if (!(rows || []).length) { + attemptsListEl.innerHTML = '
  • Прохождений ещё нет.
  • '; + return; + } + const statusLabel = { + completed: null, // handled by score + in_progress: 'Идёт', + expired: 'Истекло', + }; (rows || []).forEach((a) => { const when = a.completedAt ? fmtDt(a.completedAt) : (a.startedAt ? fmtDt(a.startedAt) : '—'); - const result = a.status === 'completed' && a.totalQuestions != null - ? `${a.correctCount}/${a.totalQuestions}${a.passed ? ' · зачёт' : ' · незачёт'}` - : a.status; + let result; + if (a.status === 'completed' && a.totalQuestions != null) { + const verdict = a.passed ? '✓ Сдано' : '✗ Не сдано'; + const score = `${a.correctCount} из ${a.totalQuestions}`; + result = `${verdict} · ${score}`; + } else { + result = statusLabel[a.status] || a.status; + } + const passedCls = a.status === 'completed' + ? (a.passed ? 'color:#166534;' : 'color:#991b1b;') + : 'color:#6b7280;'; const li = document.createElement('li'); li.className = 'surface-card attempts-card-list__item'; li.innerHTML = `
    -

    ${when}

    -

    ${escHtml(a.attempterName || '—')} - ${a.attempterLogin ? `${escHtml(a.attempterLogin)}` : ''} -

    -

    v${a.testVersion} · ${escHtml(result)}

    +

    ${when}

    +

    ${escHtml(a.attempterName || a.attempterLogin || '—')}

    +

    ${escHtml(result)}

    ${a.status === 'completed' ? `Разбор` - : ''} + : `${statusLabel[a.status] || ''}`}
    `; attemptsListEl.appendChild(li); }); @@ -720,7 +1278,7 @@ ${escHtml(p.fio || '—')} ${p.webLogin ? `` : ''} - ${escHtml(p.department || '—')}${p.clinicUserId ? ' · есть учётка' : ' · нет учётки (создадим при назначении)'} + ${escHtml(p.department || '—')} `; const cb = row.querySelector('input'); cb.addEventListener('change', () => { @@ -798,7 +1356,7 @@ if (visibilityBtn) { visibilityBtn.addEventListener('click', async () => { - const next = !chainActiveEl.checked; + const next = !chainActive; try { const r = await fetch(`/api/tests/${TEST_ID}`, { method: 'PATCH', @@ -807,8 +1365,7 @@ }); const data = await r.json(); if (!r.ok) throw new Error(data.error || 'Ошибка изменения видимости'); - chainActiveEl.checked = !!next; - chainActive = !!next; + updateChainActiveDisplay(next); visibilityBtn.textContent = chainActive ? 'Скрыть из списка' : 'Снова показать в списке'; } catch (e) { alert(e.message || 'Ошибка изменения видимости'); @@ -816,6 +1373,35 @@ }); } + // ─── Создать шаблон ──────────────────────────────────────────── + const createTemplateBtn = $('#create-template'); + if (createTemplateBtn) { + createTemplateBtn.addEventListener('click', () => { + const qCount = Math.min(30, Math.max(1, parseInt($('#ai-q-count').value || '7', 10))); + const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt($('#ai-o-count').value || '3', 10))); + const existing = $$('#questions .q-item').length; + if (existing > 0) { + const ok = confirm( + `Создать шаблон: ${qCount} вопросов × ${oCount} вариантов?\n` + + 'Текущие вопросы будут заменены.' + ); + if (!ok) return; + } + questionsEl.innerHTML = ''; + for (let qi = 0; qi < qCount; qi++) { + const opts = []; + for (let oi = 0; oi < oCount; oi++) { + opts.push({ text: '', isCorrect: oi === 0 }); + } + questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: false, options: opts })); + } + renumber(); + scheduleDirtyCheck(); + // Прокручиваем к первому вопросу + questionsEl.firstElementChild?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + } + Promise.all([ fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null), fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null), diff --git a/flask_app/app/templates/assignments.html b/flask_app/app/templates/assignments.html new file mode 100644 index 0000000..67755b4 --- /dev/null +++ b/flask_app/app/templates/assignments.html @@ -0,0 +1,169 @@ +{% extends "base.html" %} +{% block title %}Назначения — Тестирование персонала{% endblock %} + +{% block content %} +
    + +
    +

    Назначения

    + ← Главная +
    + + {# Выбор теста #} +
    +

    Тест

    + +
    + + {# Панель назначения (скрыта пока не выбран тест) #} + + +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/flask_app/app/templates/auth/login.html b/flask_app/app/templates/auth/login.html index a74c465..b54beee 100644 --- a/flask_app/app/templates/auth/login.html +++ b/flask_app/app/templates/auth/login.html @@ -22,13 +22,21 @@ {% endwith %}
    + {% if dev_fio_enabled %} +

    + Введите ФИО из кадровой системы и общий dev-пароль — или обычный логин/пароль. +

    + {% endif %}
    - + + value="{{ login or '' }}" required autofocus autocomplete="username" + placeholder="{% if dev_fio_enabled %}Иванов Иван Иванович{% endif %}" />
    @@ -50,9 +58,12 @@

    Вход в систему

    - Используйте логин и пароль. - {% if hr_auth_enabled %} + {% if dev_fio_enabled %} + Введите ФИО из кадровой системы и общий dev-пароль — или обычный логин/пароль. + {% elif hr_auth_enabled %} Учётка кадровой системы (HR). + {% else %} + Используйте логин и пароль. {% endif %}

    @@ -74,8 +85,11 @@ diff --git a/flask_app/app/templates/base.html b/flask_app/app/templates/base.html index c6837aa..c87c007 100644 --- a/flask_app/app/templates/base.html +++ b/flask_app/app/templates/base.html @@ -107,7 +107,6 @@ text-ink-700 hover:bg-ink-100" title="Настройки" aria-label="Настройки"> settings -
    {% endblock %} diff --git a/flask_app/app/templates/settings_prompts.html b/flask_app/app/templates/settings_prompts.html new file mode 100644 index 0000000..85b350c --- /dev/null +++ b/flask_app/app/templates/settings_prompts.html @@ -0,0 +1,433 @@ +{% extends "base.html" %} +{% block title %}Настройки ИИ — промпты{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +{# Inject prompt data safely as JSON into JS scope #} + + +
    +
    + psychology +

    Настройки ИИ

    +
    + + arrow_back + Главная + +
    + +

    + Переменные отображаются как + Название теста + — перетащите их в нужное место или нажмите «+ переменная» внизу редактора для вставки. +

    + +
    +{% for pid, p in prompts.items() %} +
    +
    + chevron_right +
    +
    {{ p.label }}
    + {% if p.description %} +
    {{ p.description }}
    + {% endif %} +
    + +
    + +
    + {# System #} +
    + System +
    +
    +
    +
    +
    + {# User #} +
    + User +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +{% endfor %} +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/flask_app/app/templates/stats.html b/flask_app/app/templates/stats.html new file mode 100644 index 0000000..a71d759 --- /dev/null +++ b/flask_app/app/templates/stats.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% block title %}Статистика прохождений{% endblock %} + +{% block content %} +
    +
    + bar_chart +

    Статистика

    +
    + + arrow_back + Главная + +
    + +{# Сводные метрики #} +
    + {% 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%'), + ] %} +
    + {{ icon }} +
    {{ value }}
    +
    {{ label }}
    +
    + {% endfor %} +
    + +{# По отделам + последние прохождения #} +
    + +
    +
    + corporate_fare +

    По отделам

    +
    + {% if stats.dept_stats %} +
    + + + + + + + + + + + {% for d in stats.dept_stats %} + + + + + + + {% endfor %} + +
    ОтделПрошлиСдали%
    {{ d.name }}{{ d.total }}{{ d.passed }} + + {{ d.rate }}% + +
    +
    + {% else %} +

    Прохождений ещё нет.

    + {% endif %} +
    + +
    +
    + history +

    Последние прохождения

    +
    + {% if stats.recent %} +
      + {% for r in stats.recent %} +
    • + + {% if r.passed == true %}check_circle{% elif r.passed is none %}radio_button_unchecked{% else %}cancel{% endif %} + +
      +
      {{ r.test }}
      +
      {{ r.user }} · {{ r.score }} · {{ r.at }}
      +
      +
    • + {% endfor %} +
    + {% else %} +

    Прохождений ещё нет.

    + {% endif %} +
    + +
    +{% endblock %} diff --git a/flask_app/app/templates/tests/attempt.html b/flask_app/app/templates/tests/attempt.html index 34c8f1f..be94496 100644 --- a/flask_app/app/templates/tests/attempt.html +++ b/flask_app/app/templates/tests/attempt.html @@ -6,6 +6,7 @@

    Загрузка…

    +
      @@ -15,6 +16,18 @@ + + +
      +

      Подсказка

      +

      +

      +

      +
      + +
      +
      +
      diff --git a/flask_app/app/templates/tests/editor.html b/flask_app/app/templates/tests/editor.html index 264d57c..ea2d450 100644 --- a/flask_app/app/templates/tests/editor.html +++ b/flask_app/app/templates/tests/editor.html @@ -8,16 +8,9 @@ data-initial='{{ content | tojson | safe }}'>
      -
      - ← к списку - - Автор: Вы - · - Обновлён: - · - Версия - -
      + ← К тестам + @@ -25,26 +18,92 @@ -
      - - - Вопросов: 0 - - +
      + Автор: Вы + · + Обновлён: + · + Версия
      -
      + {# ── Версии ───────────────────────────────────────────────────── #} +
      + + + Версии + История изменений; можно переключить активную версию + + +
      +
        +
        +
        + +
        + + + Параметры теста + Порог, таймер, режим показа результата и подсказок + + +
        +
        + + + + +
        + Когда показывать результат + + +
        + + + +
        + Видимость в каталоге +

        Скрытые тесты не показываются в общем списке; ссылку по-прежнему можно открыть.

        + +
        +
        +
        +
        +
        @@ -54,13 +113,14 @@
        + + {# ── Создать шаблон ──────────────────────────────────────── #}
        -

        Генерация сетки вопросов (ИИ)

        - -
        +

        Структура теста

        +

        + Укажите количество вопросов и вариантов — создайте шаблон и заполните его вручную или через ИИ. +

        +
        + +
        +
        + + {# ── Заполнить через ИИ по теме ──────────────────────────── #} +
        +

        Заполнить через ИИ

        + +
        +
        +
        + + {# ── Проверить и улучшить ─────────────────────────────────── #} +
        +

        Проверить и улучшить

        +
        + +
        + {# ── Документ в вопросы ──────────────────────────────────── #}

        Документ в вопросы

        -

        - PDF, Word или текст — вставьте в черновик вопросов. -

        -
        + {# ── Модалка результата импорта документа ─────────────────── #} + +
        +

        +
        +
        +
        +
        +

        {# ── 3. Вопросы ─────────────────────────────────────────────── #} -
        -
        -

        Вопросы (0)

        +
        +
        +

        Вопросы (0)

        +
        +
          +
          -
            -
            -
            -
            -
            - - - История - Версии теста и кто проходил - - -
            -
            -

            Версии

            -
              -
              -
              -

              Прохождения

              -
                -
                + {# Кнопка «Сохранить» под вопросами #} +
                + + +

                -
                - -
                - - - Показ в каталоге - Видимость в списке и выдача сотрудникам - - -
                -
                -

                Видимость

                -

                Скрытые тесты в общем списке не показываются; ссылку на тест по-прежнему можно открыть.

                -
                - -
                -
                -
                -

                Кому выдать

                -

                Список с учётом поиска и фильтров; можно отметить всех на экране.

                -
                - - - - -
                -
                -
                - - -
                -
                +
                + +{# Прохождения перенесены на /stats. Назначения перенесены на /assignments #} {# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #} @@ -178,12 +253,16 @@ pb-[env(safe-area-inset-bottom)]">
                - Активность цепочки и поля теста — в шапке. -
                - + +
                + + +
                +
                + + {# ── Модалка результата AI-проверки/улучшения (fullscreen на мобиле) ── #} {{ t.title }} {{ t.author_full_name or '—' }} - · v{{ t.version }} + · Версия {{ t.version }}
                @@ -44,7 +44,7 @@ {{ t.title }} {{ t.author_full_name or '—' }} - · v{{ t.version }} · скрыт + · Версия {{ t.version }} · скрыт @@ -61,7 +61,7 @@

                Каталог тестов

                -

                Активные тесты, к которым у вас есть доступ.

                +

                Все активные тесты.