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

502 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Дизайн системы
Дата: 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 — когда создана тема/вопрос/тест).
```fsharp
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` и переподвешивается на преподавателя вместо курса:
```fsharp
[<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
Главное новое понятие — тест может либо содержать явный список вопросов, либо описывать правило
случайного набора по темам:
```fsharp
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.Points`
- `RandomFromTopics rules``sum (rule.Count * rule.PointsPerQuestion)`
"Тест по теме" из требования пользователя — это `RandomFromTopics` с одним правилом
(например, все/N вопросов из одной темы), а "итоговый" тест — `RandomFromTopics` с несколькими
правилами (по одному на каждую выбранную тему). Отдельный домена не нужен — это два случая одного
и того же режима.
### 3.4 Резолв случайного набора и попытка
Ключевая проблема: раз набор вопросов случаен, конкретная попытка должна **зафиксировать**, какие
именно вопросы были показаны студенту — иначе нечего будет ни оценивать, ни показывать в ревью.
Резолв случается **один раз при `startAttempt`** и сохраняется на самой попытке:
```fsharp
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`, рандом и доступ к вопросам передаются снаружи:
```fsharp
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`. По ней считается:
```fsharp
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, который **инкрементит**
счётчик на сервере (не принимает готовое число от клиента — так его нельзя подделать в свою пользу,
разве что заспамить в меньшую сторону невозможно, а накрутить больше нет смысла жулику):
```fsharp
// добавляется в 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 TimedOut``Grading.gradeAttempt`, `FinishReason = Some TimedOut`)
и возвращает `Error "Время вышло, тест завершён автоматически"`,
а не проваливает исходное действие молча. Клиент по такому ответу показывает экран результата.
2. **Заброшенная сессия.** Если студент закрыл вкладку и больше не прислал ни одного запроса,
пункт 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-методе результат её работы не отдавался ни
преподавателю, ни самому студенту. Закрываем оба пробела одним набором типов:
```fsharp
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)
```fsharp
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)
```fsharp
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 на сервере) сериализует их напрямую.
```sql
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`/`blur``reportFocusLoss`,
вынос кнопки «Завершить тест» в отдельный зафиксированный блок (§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`) вопросы/тесты в общих списках приглушённым
цветом с фильтром, или полностью прятать и доставать только через карточку конкретной попытки?