Files
RuvdsTest/docs/DESIGN.md
danamir 942dfc9c1a Initial commit: standalone quiz-testing system
Full-stack F# (Domain/Server/Client via Fable+Elmish+Feliz), PostgreSQL
persistence via Dapper, Docker Compose deployment. Student quiz-taking flow
with time-limit enforcement and focus-loss tracking, Teacher question bank
and quiz builder with results analytics, Admin user management.
2026-08-06 12:36:16 +03:00

37 KiB
Raw Blame History

Дизайн системы

Дата: 2026-08-03. Статус: проектирование, реализация не начата (кроме уже существующего студенческого MVP).

1. Идея и границы системы

Standalone-система проверки знаний — аналог модуля «Тест» в Moodle, без остальной части LMS (без курсов как оргединицы, без контента/форумов). Три роли: Admin, Teacher, Student.

Ключевые решения, зафиксированные в разговоре с пользователем:

Вопрос Решение
Курсы Не нужны. Убираем Course/Enrollment из домена.
Банк вопросов Приватный у каждого преподавателя (темы = топики, без общего пространства).
«Итоговый» тест со случайными вопросами Случайный набор вопросов формируется заново при каждой попытке (как в Moodle).
Кому виден тест Преподаватель явно назначает тест конкретным студентам (нет курсов/групп).
Регистрация Открытой регистрации нет — все аккаунты создаёт Admin вручную.
Роль Admin Admin = Teacher + управление пользователями (создание/удаление учителей и студентов).
Персистентность PostgreSQL (уже предусмотрено комментарием в Store.fs), доступ через Dapper.
Аналитика Нужна сразу: % правильных ответов и сложность по каждому вопросу, не только список попыток.
Анти-списывание В аналитике попытки нужно видеть число потерь фокуса окна теста.
Лимит времени Настраивается преподавателем (уже есть TimeLimit), но должен реально принуждаться, а не только отображаться.
Что происходит по истечении времени Авто-сдача текущих ответов на сервере, как в Moodle.
Публикация теста Отдельный флаг IsPublished — преподаватель готовит тест и назначения заранее, студенты не видят его, пока он явно не опубликован.
Ручная коррекция автооценки Не нужна для v1 — полагаемся только на автопроверку (Grading.gradeResponse).

2. Роли и права

  • Student — видит только тесты, на которые его явно назначили; проходит попытки; видит свои результаты.
  • Teacher — управляет своими темами, вопросами и тестами; назначает на тест студентов из общего справочника пользователей (справочник read-only для Teacher — юзеров создаёт только Admin); видит попытки и аналитику только по своим тестам.
  • Admin — всё, что может Teacher (свой банк вопросов и свои тесты — то есть Admin тоже может быть автором тестов), плюс CRUD пользователей (создание/деактивация/сброс пароля, назначение ролей).

Авторизация как сейчас: JWT с claim роли, выдаётся при login. Каждый серверный хендлер проверяет роль/владельца сам (единообразный Result-based шаблон ошибок, без ASP.NET [Authorize(Roles=...)], чтобы не расходиться со стилем существующего QuizApi.fs).

3. Доменная модель (изменения к текущей)

Убираем: Course, Enrollment, CourseId, EnrollmentRole (полностью, не используются без курсов).

3.1 Пользователь

Текущий User (Domain/Users.fs) не несёт признака активности, хотя возможность деактивации уже решена (§2, Admin) и уже присутствует в схеме БД (§5) — это пробел, закрываем явно. CreatedAt — практическая необходимость для сортировки списков в UI (Admin увидит, когда заведён аккаунт; Teacher — когда создана тема/вопрос/тест).

type User =
    { Id: UserId
      Name: string
      Email: string
      PasswordHash: string
      Role: Role
      IsActive: bool        // NEW — деактивированный пользователь не может login'иться
      CreatedAt: DateTimeOffset }   // NEW

login дополнительно проверяет IsActive; при false — тот же Error "Неверный email или пароль", что и при неверном пароле (не раскрываем факт существования/деактивации аккаунта в тексте ошибки).

3.2 Темы и вопросы

QuestionCategory переименовывается в Topic и переподвешивается на преподавателя вместо курса:

[<Struct>] type TopicId = TopicId of Guid

type Topic =
    { Id: TopicId
      OwnerId: UserId   // преподаватель-владелец
      Name: string
      CreatedAt: DateTimeOffset }   // NEW

type Question =
    { Id: QuestionId
      TopicId: TopicId
      Text: string
      Points: float      // дефолтные баллы вопроса в банке
      Type: QuestionType   // без изменений: SingleChoice/MultipleChoice/TrueFalse/ShortAnswer/Numeric
      IsArchived: bool      // NEW — см. врезку про удаление ниже
      CreatedAt: DateTimeOffset }   // NEW

Удаление при наличии истории. Вопрос может быть частью Quiz.Composition (FixedQuestions) и/или уже фигурировать в чьих-то Attempt.ResolvedQuestions/attempt_grades — жёсткое удаление сломало бы ссылочную целостность и обнулило бы прошлые результаты. Поэтому deleteQuestion: если вопрос нигде не используется — удаляет по-настоящему; если используется хоть где-то — выставляет IsArchived = true вместо удаления. Архивные вопросы не показываются в списке для добавления в новый тест и не участвуют в пуле RandomFromTopics (§3.4), но остаются видны в уже прошедших попытках и в getQuestionStats (§3.5) — история не должна исчезать. Тема (deleteTopic) удаляется только если в ней нет вопросов (архивных в том числе — сначала перенести/удалить вопросы); отдельного IsArchived для темы не вводим, это не то, на что что-либо ссылается напрямую после удаления вопросов.

3.3 Состав теста: fixed vs random-from-topics

Главное новое понятие — тест может либо содержать явный список вопросов, либо описывать правило случайного набора по темам:

type QuizQuestionRef =
    { QuestionId: QuestionId
      Points: float   // баллы именно в этом тесте, может отличаться от дефолта вопроса
      Order: int }

/// Правило "N случайных вопросов из темы X, каждый на Y баллов"
type RandomTopicRule =
    { TopicId: TopicId
      Count: int
      PointsPerQuestion: float }

type QuizComposition =
    | FixedQuestions of QuizQuestionRef list
    | RandomFromTopics of RandomTopicRule list

type Quiz =
    { Id: QuizId
      OwnerId: UserId
      Title: string
      Description: string
      Composition: QuizComposition
      TimeLimit: TimeSpan option
      MaxAttempts: int option
      GradingMethod: GradingMethod
      ShuffleQuestions: bool
      ShuffleAnswers: bool
      OpenFrom: DateTimeOffset option
      OpenTo: DateTimeOffset option
      PassingScore: float option
      AssignedStudentIds: Set<UserId>
      IsPublished: bool        // NEW — по умолчанию false, см. таблицу решений в §1
      IsArchived: bool         // NEW — аналогично Question.IsArchived: мягкое удаление, если есть попытки
      CreatedAt: DateTimeOffset }   // NEW

IsPublished отделяет подготовку теста от его показа студентам: пока флаг не выставлен явно (publishQuiz, см. §4.2), тест не появляется в getAvailableQuizzes даже у уже назначенных студентов — независимо от OpenFrom/OpenTo. deleteQuiz — то же правило мягкого удаления, что и для Question (§3.2): если по тесту уже есть хоть одна попытка, deleteQuiz архивирует (IsArchived = true) вместо удаления, чтобы не потерять историю в getQuizResults/getQuestionStats.

Quiz.totalPoints считается без резолва вопросов (важно, чтобы студент видел максимум баллов ещё до начала попытки):

  • FixedQuestions refssum refs.Points
  • RandomFromTopics rulessum (rule.Count * rule.PointsPerQuestion)

"Тест по теме" из требования пользователя — это RandomFromTopics с одним правилом (например, все/N вопросов из одной темы), а "итоговый" тест — RandomFromTopics с несколькими правилами (по одному на каждую выбранную тему). Отдельный домена не нужен — это два случая одного и того же режима.

3.4 Резолв случайного набора и попытка

Ключевая проблема: раз набор вопросов случаен, конкретная попытка должна зафиксировать, какие именно вопросы были показаны студенту — иначе нечего будет ни оценивать, ни показывать в ревью. Резолв случается один раз при startAttempt и сохраняется на самой попытке:

type AttemptFinishReason =
    | ManualSubmit
    | TimedOut

type Attempt =
    { Id: AttemptId
      QuizId: QuizId
      UserId: UserId
      AttemptNumber: int         // NEW — 1, 2, 3... в рамках (QuizId, UserId), для UI и MaxAttempts
      StartedAt: DateTimeOffset
      SubmittedAt: DateTimeOffset option
      State: AttemptState
      FinishReason: AttemptFinishReason option   // NEW — None пока InProgress; см. §3.6
      ResolvedQuestions: QuizQuestionRef list   // NEW — конкретные вопросы именно этой попытки
      Responses: Map<QuestionId, StudentResponse>
      Grades: Map<QuestionId, QuestionGrade>
      Score: float option
      FocusLossCount: int }   // NEW — см. §3.6

AttemptNumber считается при startAttempt как (существующие попытки этого студента по этому тесту).Length + 1 — избавляет UI/аналитику от пересчёта каждый раз. Attempt.submit принимает AttemptFinishReason параметром (ManualSubmit из ручного finishAttempt, TimedOut из принудительного завершения по лимиту времени, см. §3.6) — сигнатура меняется с submit (now: DateTimeOffset) (attempt: Attempt) на submit (reason: AttemptFinishReason) (now: DateTimeOffset) (attempt: Attempt).

Чистая (без I/O) функция резолва в Domain, рандом и доступ к вопросам передаются снаружи:

module Quiz =
    /// shuffle — инжектируемая функция перемешивания (использует ShuffleQuestions квиза сам вызывающий код).
    /// topicQuestions — вопросы каждой темы, уже загруженные вызывающим кодом из Store.
    let resolveComposition
        (shuffle: 'a list -> 'a list)
        (topicQuestions: Map<TopicId, Question list>)
        (quiz: Quiz)
        : Result<QuizQuestionRef list, string> =
        match quiz.Composition with
        | FixedQuestions refs -> Ok(if quiz.ShuffleQuestions then shuffle refs else refs)
        | RandomFromTopics rules ->
            rules
            |> List.fold (fun acc rule ->
                acc |> Result.bind (fun picked ->
                    match Map.tryFind rule.TopicId topicQuestions with
                    | Some pool when pool.Length >= rule.Count ->
                        let chosen = pool |> shuffle |> List.truncate rule.Count
                        let refs = chosen |> List.map (fun q -> { QuestionId = q.Id; Points = rule.PointsPerQuestion; Order = 0 })
                        Ok(picked @ refs)
                    | _ -> Error "В одной из тем недостаточно вопросов для случайного набора"))
                (Ok [])
            |> Result.map (fun refs ->
                let ordered = if quiz.ShuffleQuestions then shuffle refs else refs
                ordered |> List.mapi (fun i r -> { r with Order = i }))

Grading.gradeAttempt переключается на attempt.ResolvedQuestions вместо quiz.Questions как единый источник правды для обоих режимов — это же упрощает startAttempt/finishAttempt, им больше не нужно различать fixed/random после резолва.

3.5 Аналитика по вопросам

Отдельная нормализованная таблица (см. §5) attempt_grades (attempt_id, question_id, points_awarded, max_points, is_correct) пишется при finishAttempt вместе с Grades. По ней считается:

type QuestionStat =
    { QuestionId: QuestionId
      QuestionText: string
      TimesAsked: int
      TimesCorrect: int
      PercentCorrect: float
      AvgPointsAwarded: float }

Агрегация — обычный SQL GROUP BY question_id, без DU-хитростей.

3.6 Анти-списывание: потери фокуса окна и жёсткий лимит времени

Потери фокуса. Клиент слушает document.visibilitychange (переключение вкладки, сворачивание) и window.blur (переключение на другое окно/приложение поверх той же вкладки) с момента получения ответа от startAttempt — то есть с самого начала попытки, а не с первого отвеченного вопроса (студент может уйти листать шпаргалку ещё до того, как ответит хоть на один вопрос, и это тоже должно засчитаться). При переходе в состояние "не в фокусе" клиент дёргает новый метод API, который инкрементит счётчик на сервере (не принимает готовое число от клиента — так его нельзя подделать в свою пользу, разве что заспамить в меньшую сторону невозможно, а накрутить больше нет смысла жулику):

// добавляется в IQuizApi (§4.1)
reportFocusLoss: AttemptId -> Async<Result<unit, string>>

Дребезг (несколько событий подряд при одном уходе) гасится на клиенте — считаем один "уход", пока пользователь не вернулся (visibilitychange обратно в visible сбрасывает флаг "уже считали"). FocusLossCount попадает в AttemptSummary/AttemptDetail (§3.7), преподаватель видит число рядом с баллом и сам решает, похоже это на списывание или нет — автоматических санкций система не применяет.

Жёсткий лимит времени. Два уровня принуждения:

  1. Активная сессия. submitAnswer и finishAttempt перед выполнением проверяют Attempt.isExpired quiz now attempt (функция уже есть в Attempts.fs, просто не вызывается). Если время вышло — сервер сам переводит попытку в Graded (те же шаги, что и ручной finishAttempt: Attempt.submit TimedOutGrading.gradeAttempt, FinishReason = Some TimedOut) и возвращает Error "Время вышло, тест завершён автоматически", а не проваливает исходное действие молча. Клиент по такому ответу показывает экран результата.
  2. Заброшенная сессия. Если студент закрыл вкладку и больше не прислал ни одного запроса, пункт 1 не сработает — некому вызвать submitAnswer. Поэтому на сервере нужен фоновый IHostedService ("expiry sweeper"), который каждые ~30 сек находит InProgress-попытки с started_at + time_limit < now в Postgres и точно так же принудительно завершает и оценивает их (Attempt.submit TimedOutGrading.gradeAttempt). Это и есть источник истины по лимиту — клиентский таймер в UI (обратный отсчёт) только для удобства студента, не для принуждения.

Обычное ручное завершение (finishAttempt без истечения лимита) ставит FinishReason = Some ManualSubmit. Вопросы, на которые студент не успел ответить к моменту авто-сдачи, никак специально не обрабатываются — они и так остаются без записи в attempt.Responses, а Grading.gradeAttempt уже сегодня трактует отсутствующий ответ через emptyResponseFor как нулевой/неверный (см. Grading.fs:59-64). Отдельной логики "пропущенный вопрос" вводить не нужно.

3.7 Финальная оценка по нескольким попыткам и история

Пробел в более ранней версии этого документа: типы AttemptSummary/AttemptDetail/StudentQuizResult упоминались в §4 по имени, но нигде не были определены. Плюс — в домене уже есть Grading.applyGradingMethod, который сводит несколько попыток студента в одну итоговую оценку по Quiz.GradingMethod (HighestAttempt/AverageAttempt/FirstAttempt/LastAttempt), но раньше эта функция никуда не была подключена: ни в одном API-методе результат её работы не отдавался ни преподавателю, ни самому студенту. Закрываем оба пробела одним набором типов:

type AttemptSummary =
    { AttemptId: AttemptId
      StudentId: UserId
      StudentName: string
      AttemptNumber: int
      State: AttemptState
      StartedAt: DateTimeOffset
      SubmittedAt: DateTimeOffset option
      FinishReason: AttemptFinishReason option
      Score: float option
      MaxScore: float
      FocusLossCount: int }

type AttemptDetail =
    { Summary: AttemptSummary
      Questions: QuestionView list         // как показывались студенту, из ResolvedQuestions
      Responses: Map<QuestionId, StudentResponse>
      Grades: Map<QuestionId, QuestionGrade> }

/// Итог по тесту для одного студента, с учётом Quiz.GradingMethod.
type StudentQuizResult =
    { StudentId: UserId
      StudentName: string
      Attempts: AttemptSummary list
      FinalScore: float option    // Grading.applyGradingMethod по всем Attempts
      MaxScore: float
      Passed: bool option }

Два инварианта, которые эти типы предполагают:

  • Оценка — снимок на момент finishAttempt/sweeper'а. Если преподаватель потом отредактирует Question (текст, правильный ответ) или состав теста, уже выставленные Grades/Score задним числом не пересчитываются — иначе история результатов "плыла" бы вместе с правками банка вопросов. Это и есть причина, почему Question/Quiz архивируются, а не пересчитываются на лету (§3.2, §3.3).
  • ShuffleAnswers (порядок вариантов ответа внутри вопроса) не требует отдельного состояния — в отличие от ShuffleQuestions/ResolvedQuestions, порядок вариантов не влияет на то, что именно засчитывается (ответ кодируется через стабильный OptionId), поэтому просто перемешивается на сервере при формировании QuestionView в startAttempt, без сохранения куда-либо.

4. API (Fable.Remoting-style, вручную через fetch — см. комментарий в Api.fs)

Три интерфейса вместо одного, каждый — отдельный роут-неймспейс (/api/<TypeName>/<Method>), роль проверяется на сервере в каждом хендлере.

4.1 IQuizApi (Student) — правки существующего

  • login — дополнительно проверяет User.IsActive (§3.1).
  • getAvailableQuizzes — теперь фильтрует по IsPublished = true, AssignedStudentIds (только тесты, куда назначен текущий студент), not IsArchived и по окну OpenFrom/OpenTo, как сейчас.
  • startAttempt, submitAnswer, finishAttempt — логика резолва встраивается в startAttempt (см. §3.4), наружу для клиента ничего не меняется; submitAnswer/finishAttempt дополнительно проверяют истечение времени (см. §3.6).
  • reportFocusLoss: AttemptId -> Async<Result<unit, string>> — новый метод, см. §3.6.
  • getMyResults: QuizId -> Async<Result<StudentQuizResult, string>> — новый метод: студент видит свою историю попыток по тесту и итоговую оценку по Quiz.GradingMethod (§3.7) — без него у студента с MaxAttempts > 1 нет способа посмотреть, как считался финальный балл.

4.2 ITeacherApi (Teacher и Admin)

type ITeacherApi =
    { listTopics: unit -> Async<Topic list>
      createTopic: string -> Async<Result<Topic, string>>
      renameTopic: TopicId * string -> Async<Result<unit, string>>
      deleteTopic: TopicId -> Async<Result<unit, string>>

      listQuestions: TopicId -> Async<Question list>
      createQuestion: CreateQuestionRequest -> Async<Result<Question, string>>
      updateQuestion: UpdateQuestionRequest -> Async<Result<unit, string>>
      deleteQuestion: QuestionId -> Async<Result<unit, string>>

      listMyQuizzes: unit -> Async<QuizAdminSummary list>
      getQuiz: QuizId -> Async<Result<QuizDetail, string>>
      createQuiz: CreateQuizRequest -> Async<Result<QuizId, string>>
      updateQuiz: UpdateQuizRequest -> Async<Result<unit, string>>
      publishQuiz: QuizId -> Async<Result<unit, string>>     // NEW — см. §3.3
      unpublishQuiz: QuizId -> Async<Result<unit, string>>   // NEW — снять с публикации (уже стартовавших попыток не отменяет)
      deleteQuiz: QuizId -> Async<Result<unit, string>>       // архивирует, если есть попытки — см. §3.3

      listStudents: unit -> Async<StudentSummary list>          // справочник для назначения, read-only
      assignStudents: QuizId * UserId list -> Async<Result<unit, string>>
      unassignStudent: QuizId * UserId -> Async<Result<unit, string>>

      getQuizAttempts: QuizId -> Async<AttemptSummary list>            // сырой список попыток, см. §3.7
      getAttemptDetail: AttemptId -> Async<Result<AttemptDetail, string>>
      getQuizResults: QuizId -> Async<StudentQuizResult list>          // NEW — сводка по студентам, см. §3.7
      getQuestionStats: QuizId -> Async<QuestionStat list> }

getQuizAttempts и getQuizResults отвечают на разные вопросы: первый — "кто, когда и как проходил тест" (нужен для анти-читерского ревью каждой отдельной попытки — FocusLossCount, FinishReason, длительность), второй — "какая у студента итоговая оценка по тесту с учётом GradingMethod" (журнал-ведомость). Оба используют типы из §3.7.

Владение проверяется всюду: Teacher видит/меняет только темы/вопросы/тесты со своим OwnerId (Admin — тоже, но плюс видит вообще всех через IAdminApi, не через ITeacherApi).

4.3 IAdminApi (только Admin)

type IAdminApi =
    { listUsers: unit -> Async<UserSummary list>
      createUser: CreateUserRequest -> Async<Result<UserSummary, string>>   // задаёт Role: Teacher | Student | Admin
      updateUser: UpdateUserRequest -> Async<Result<unit, string>>
      deactivateUser: UserId -> Async<Result<unit, string>>
      resetPassword: UserId * string -> Async<Result<unit, string>> }

5. Персистентность (PostgreSQL + Dapper)

DU-тяжёлые части (QuestionType, QuizComposition, Responses) храним как jsonb — реляционных join-таблиц под каждый вариант DU не оправдано на этом масштабе, а Dapper + System.Text.Json (с тем же подходом к конвертерам, что уже применён для Fable.Remoting.Json на сервере) сериализует их напрямую.

users (
  id uuid pk, name text, email text unique, password_hash text, role text,
  is_active bool not null default true, created_at timestamptz not null default now()
)

topics (id uuid pk, owner_id uuid references users, name text, created_at timestamptz not null default now())

questions (
  id uuid pk, topic_id uuid references topics, text text, points double precision, type_json jsonb,
  is_archived bool not null default false, created_at timestamptz not null default now()
)

quizzes (
  id uuid pk, owner_id uuid references users, title text, description text,
  composition_json jsonb, time_limit_minutes int null, max_attempts int null,
  grading_method text, shuffle_questions bool, shuffle_answers bool,
  open_from timestamptz null, open_to timestamptz null, passing_score double precision null,
  is_published bool not null default false, is_archived bool not null default false,
  created_at timestamptz not null default now()
)

quiz_assignments (quiz_id uuid references quizzes, student_id uuid references users, primary key (quiz_id, student_id))

attempts (
  id uuid pk, quiz_id uuid references quizzes, user_id uuid references users,
  attempt_number int not null, started_at timestamptz, submitted_at timestamptz null, state text,
  finish_reason text null, resolved_questions_json jsonb, responses_json jsonb, score double precision null,
  focus_loss_count int not null default 0
)

attempt_grades (
  attempt_id uuid references attempts, question_id uuid references questions,
  points_awarded double precision, max_points double precision, is_correct bool,
  primary key (attempt_id, question_id)
)

Store.fs заменяется на модуль с Dapper-запросами за тем же member-интерфейсом (там уже есть комментарий это предвосхищающий) — сигнатуры методов остаются похожими, чтобы QuizApi.fs и новые TeacherApi.fs/AdminApi.fs менялись минимально.

Миграции — лёгкий инструмент поверх Dapper (например DbUp: пронумерованные .sql-файлы, применяются при старте сервера), без EF Core, чтобы не тащить лишний ORM-слой.

6. UX прохождения теста

Список вопросов остаётся одним непрерывно прокручиваемым блоком, как сейчас (View.fs:226) — без пагинации/пошагового визарда «один вопрос за раз». Студент должен иметь возможность свободно скроллить вверх-вниз по всем вопросам в любом порядке и с любой скоростью, без ограничений.

Кнопка «Завершить тест» физически выносится из области прокрутки. Сейчас (View.fs:227-231) она рендерится прямо под последним вопросом внутри того же taking-quiz-page-контейнера — при быстрой прокрутке длинного списка случайный клик в момент остановки скролла может преждевременно завершить попытку. Нужен отдельный зафиксированный блок (sticky-хедер сверху или боковая панель), где живут общие элементы управления попыткой — обратный отсчёт времени (§3.6) и кнопка «Завершить тест», — и который не участвует в скролле списка вопросов, чтобы моторика "долистать до конца" и "нажать завершить" были физически разными жестами.

7. Фазы реализации

  1. Домен: User.IsActive/CreatedAt, переименование Category→Topic + Topic.CreatedAt, Question.IsArchived/CreatedAt, QuizComposition, Quiz.IsPublished/IsArchived/CreatedAt, Attempt.ResolvedQuestions/AttemptNumber/FinishReason/FocusLossCount, включение проверки Attempt.isExpired в поток завершения попытки, удаление Course/Enrollment, обновление Grading/Attempt/Quiz модулей + юнит-тесты (tests/Domain.Tests) на резолв случайного набора, на totalPoints для обоих режимов и на сведение попыток через applyGradingMethod.
  2. PostgreSQL: схема (§5), Dapper-репозиторий взамен Store.fs, миграции, конфиг строки подключения, фоновый IHostedService-sweeper для заброшенных просроченных попыток (§3.6).
  3. Admin API + мини-UI: CRUD пользователей (с учётом IsActive).
  4. Teacher API + UI: темы → вопросы (с архивированием вместо жёсткого удаления, §3.2) → тесты (fixed/random, включая настройку TimeLimit, publishQuiz/unpublishQuiz) → назначение студентов.
  5. Результаты и аналитика: список попыток по тесту (getQuizAttempts, с FocusLossCount, FinishReason и длительностью), сводка по студентам с учётом GradingMethod (getQuizResults), детальный просмотр попытки, QuestionStat.
  6. Student UI: уже работает end-to-end, донастройка — reflect only assigned+open quizzes, обратный отсчёт времени в UI, слушатели visibilitychange/blurreportFocusLoss, вынос кнопки «Завершить тест» в отдельный зафиксированный блок (§6).
  7. Деплой на RuVDS: systemd-юнит для Kestrel, nginx как reverse proxy + TLS, прод-конфиг Jwt:Secret/Client:Origin/строка подключения к Postgres (сейчас в appsettings.Development.json захардкожен dev-секрет и dev-порт клиента — см. память проекта про CORS-баг 2026-08-03).

8. Открытые вопросы (не решены, всплывут по ходу реализации)

  • Нужен ли предпросмотр/тестовый прогон теста преподавателем без сохранения попытки в статистику?
  • Что показывать студенту при просмотре своего результата — только баллы, или также его ответы с правильными (риск слива вопросов в банк для будущих попыток при MaxAttempts > 1)?
  • Лимит на минимальное число вопросов в теме, чтобы RandomFromTopics не падал в самый ответственный момент («недостаточно вопросов») — валидировать при создании теста или только при старте попытки?
  • Нужен ли визуальный порог/бейдж «подозрительно» при большом FocusLossCount, или преподаватель просто смотрит на число сам без автоматической оценки?
  • С какого момента считать потери фокуса — решено: с ответа startAttempt, см. §3.6.
  • Показывать ли преподавателю архивные (IsArchived) вопросы/тесты в общих списках приглушённым цветом с фильтром, или полностью прятать и доставать только через карточку конкретной попытки?