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.
This commit is contained in:
501
docs/DESIGN.md
Normal file
501
docs/DESIGN.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# Дизайн системы
|
||||
|
||||
Дата: 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`) вопросы/тесты в общих списках приглушённым
|
||||
цветом с фильтром, или полностью прятать и доставать только через карточку конкретной попытки?
|
||||
Reference in New Issue
Block a user