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.
37 KiB
Дизайн системы
Дата: 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 refs→sum refs.PointsRandomFromTopics rules→sum (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), преподаватель видит число
рядом с баллом и сам решает, похоже это на списывание или нет — автоматических санкций система
не применяет.
Жёсткий лимит времени. Два уровня принуждения:
- Активная сессия.
submitAnswerиfinishAttemptперед выполнением проверяютAttempt.isExpired quiz now attempt(функция уже есть вAttempts.fs, просто не вызывается). Если время вышло — сервер сам переводит попытку вGraded(те же шаги, что и ручнойfinishAttempt:Attempt.submit TimedOut→Grading.gradeAttempt,FinishReason = Some TimedOut) и возвращаетError "Время вышло, тест завершён автоматически", а не проваливает исходное действие молча. Клиент по такому ответу показывает экран результата. - Заброшенная сессия. Если студент закрыл вкладку и больше не прислал ни одного запроса,
пункт 1 не сработает — некому вызвать
submitAnswer. Поэтому на сервере нужен фоновыйIHostedService("expiry sweeper"), который каждые ~30 сек находитInProgress-попытки сstarted_at + time_limit < nowв Postgres и точно так же принудительно завершает и оценивает их (Attempt.submit TimedOut→Grading.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. Фазы реализации
- Домен:
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. - PostgreSQL: схема (§5), Dapper-репозиторий взамен
Store.fs, миграции, конфиг строки подключения, фоновыйIHostedService-sweeper для заброшенных просроченных попыток (§3.6). - Admin API + мини-UI: CRUD пользователей (с учётом
IsActive). - Teacher API + UI: темы → вопросы (с архивированием вместо жёсткого удаления, §3.2) → тесты
(fixed/random, включая настройку
TimeLimit,publishQuiz/unpublishQuiz) → назначение студентов. - Результаты и аналитика: список попыток по тесту (
getQuizAttempts, сFocusLossCount,FinishReasonи длительностью), сводка по студентам с учётомGradingMethod(getQuizResults), детальный просмотр попытки,QuestionStat. - Student UI: уже работает end-to-end, донастройка — reflect only assigned+open quizzes,
обратный отсчёт времени в UI, слушатели
visibilitychange/blur→reportFocusLoss, вынос кнопки «Завершить тест» в отдельный зафиксированный блок (§6). - Деплой на 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) вопросы/тесты в общих списках приглушённым цветом с фильтром, или полностью прятать и доставать только через карточку конкретной попытки?