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:
danamir
2026-08-06 12:36:16 +03:00
commit 942dfc9c1a
134 changed files with 10712 additions and 0 deletions

View File

@@ -0,0 +1,368 @@
module Client.Features.Teacher.Tests.State
open Elmish
open Domain.Contracts
open Client.Features.Teacher.Tests.Types
let init () : Model * Cmd<Msg> =
empty, Cmd.batch [ Cmd.ofMsg LoadQuizzes; Cmd.ofMsg LoadStudents; Cmd.ofMsg LoadTopics ]
/// Empty string means "not set" for these optional numeric fields; a
/// non-empty string that fails to parse is a validation error.
let private parseOptionalInt (text: string) : Result<int option, string> =
if System.String.IsNullOrWhiteSpace text then
Ok None
else
match System.Int32.TryParse text with
| true, v -> Ok(Some v)
| false, _ -> Error "Ожидалось целое число"
let private parseOptionalFloat (text: string) : Result<float option, string> =
if System.String.IsNullOrWhiteSpace text then
Ok None
else
match System.Double.TryParse text with
| true, v -> Ok(Some v)
| false, _ -> Error "Ожидалось число"
let private buildQuizFields (form: QuizForm) =
match parseOptionalInt form.TimeLimitText, parseOptionalInt form.MaxAttemptsText, parseOptionalFloat form.PassingScoreText with
| Error err, _, _
| _, Error err, _
| _, _, Error err -> Error err
| Ok timeLimit, Ok maxAttempts, Ok passingScore ->
if System.String.IsNullOrWhiteSpace form.Title then
Error "Название теста не может быть пустым"
elif form.Sources.IsEmpty then
Error "Выберите хотя бы один вопрос"
else
Ok(timeLimit, maxAttempts, passingScore)
let private toDraft (source: QuizQuestionSourceInput) : QuizSourceDraft =
match source with
| FixedQuestionInput qid -> FixedDraft qid
| RandomPoolInput rule -> PoolDraft(rule.TopicId, rule.Count)
let private toSourceInput (draft: QuizSourceDraft) : QuizQuestionSourceInput =
match draft with
| FixedDraft qid -> FixedQuestionInput qid
| PoolDraft(topicId, count) -> RandomPoolInput { TopicId = topicId; Count = count }
let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Msg> =
match msg with
| LoadQuizzes ->
let cmd =
Cmd.OfAsync.either
Api.listMyQuizzes
token
(function
| Ok quizzes -> QuizzesLoaded quizzes
| Error err -> QuizzesLoadFailed err)
(fun ex -> QuizzesLoadFailed ex.Message)
{ model with QuizzesLoading = true; QuizzesError = None }, cmd
| QuizzesLoaded quizzes -> { model with Quizzes = quizzes; QuizzesLoading = false }, Cmd.none
| QuizzesLoadFailed err -> { model with QuizzesLoading = false; QuizzesError = Some err }, Cmd.none
| LoadStudents ->
let cmd =
Cmd.OfAsync.either
Api.listStudents
token
(function
| Ok students -> StudentsLoaded students
| Error err -> StudentsLoadFailed err)
(fun ex -> StudentsLoadFailed ex.Message)
{ model with StudentsLoading = true; StudentsError = None }, cmd
| StudentsLoaded students -> { model with Students = students; StudentsLoading = false }, Cmd.none
| StudentsLoadFailed err -> { model with StudentsLoading = false; StudentsError = Some err }, Cmd.none
| SelectQuiz quizId ->
let assigned =
model.Quizzes
|> List.tryFind (fun q -> q.Id = quizId)
|> Option.map (fun q -> Set.ofList q.AssignedStudentIds)
|> Option.defaultValue Set.empty
{ model with
SelectedQuizId = Some quizId
SelectedStudentIds = assigned
SaveError = None
ShowQuizForm = false
ResultsQuizId = None },
Cmd.none
| ToggleStudent studentId ->
let next =
if model.SelectedStudentIds.Contains studentId then
model.SelectedStudentIds.Remove studentId
else
model.SelectedStudentIds.Add studentId
{ model with SelectedStudentIds = next }, Cmd.none
| SaveAssignments ->
match model.SelectedQuizId with
| None -> model, Cmd.none
| Some quizId ->
let request: AssignStudentsRequest =
{ QuizId = quizId
StudentIds = Set.toList model.SelectedStudentIds }
let cmd =
Cmd.OfAsync.either
(Api.assignStudents token)
request
(function
| Ok summary -> AssignmentsSaved summary
| Error err -> SaveFailed err)
(fun ex -> SaveFailed ex.Message)
{ model with IsSaving = true; SaveError = None }, cmd
| AssignmentsSaved summary ->
{ model with
Quizzes = model.Quizzes |> List.map (fun q -> if q.Id = summary.Id then summary else q)
IsSaving = false },
Cmd.none
| SaveFailed err -> { model with IsSaving = false; SaveError = Some err }, Cmd.none
| LoadTopics ->
let cmd =
Cmd.OfAsync.either
Api.listTopics
token
(function
| Ok topics -> TopicsLoaded topics
| Error err -> TopicsLoadFailed err)
(fun ex -> TopicsLoadFailed ex.Message)
{ model with TopicsLoading = true; TopicsError = None }, cmd
| TopicsLoaded topics -> { model with Topics = topics; TopicsLoading = false }, Cmd.none
| TopicsLoadFailed err -> { model with TopicsLoading = false; TopicsError = Some err }, Cmd.none
| StartNewQuiz ->
{ model with
ShowQuizForm = true
EditingQuizId = None
QuizForm = emptyQuizForm
SelectedQuizId = None
ResultsQuizId = None },
Cmd.none
| StartEditQuiz quizId ->
match model.Quizzes |> List.tryFind (fun q -> q.Id = quizId) with
| None -> model, Cmd.none
| Some quiz ->
let form =
{ emptyQuizForm with
Title = quiz.Title
Description = quiz.Description
TimeLimitText = quiz.TimeLimitMinutes |> Option.map string |> Option.defaultValue ""
MaxAttemptsText = quiz.MaxAttempts |> Option.map string |> Option.defaultValue ""
PassingScoreText = quiz.PassingScore |> Option.map string |> Option.defaultValue ""
ShuffleQuestions = quiz.ShuffleQuestions
ShuffleAnswers = quiz.ShuffleAnswers
Sources = quiz.Sources |> List.map toDraft }
{ model with
ShowQuizForm = true
EditingQuizId = Some quizId
QuizForm = form },
Cmd.none
| CancelQuizForm -> { model with ShowQuizForm = false; EditingQuizId = None }, Cmd.none
| SetQuizTitle text -> { model with QuizForm = { model.QuizForm with Title = text } }, Cmd.none
| SetQuizDescription text -> { model with QuizForm = { model.QuizForm with Description = text } }, Cmd.none
| SetQuizTimeLimitText text -> { model with QuizForm = { model.QuizForm with TimeLimitText = text } }, Cmd.none
| SetQuizMaxAttemptsText text ->
{ model with QuizForm = { model.QuizForm with MaxAttemptsText = text } }, Cmd.none
| SetQuizPassingScoreText text ->
{ model with QuizForm = { model.QuizForm with PassingScoreText = text } }, Cmd.none
| SetQuizShuffleQuestions value ->
{ model with QuizForm = { model.QuizForm with ShuffleQuestions = value } }, Cmd.none
| SetQuizShuffleAnswers value ->
{ model with QuizForm = { model.QuizForm with ShuffleAnswers = value } }, Cmd.none
| SelectPickerTopic topicId ->
let cmd =
Cmd.OfAsync.either
(Api.listQuestionsInTopic token)
topicId
(function
| Ok questions -> PickerQuestionsLoaded questions
| Error err -> PickerQuestionsLoadFailed err)
(fun ex -> PickerQuestionsLoadFailed ex.Message)
let existingPoolCount =
model.QuizForm.Sources
|> List.tryPick (function
| PoolDraft(tid, count) when tid = topicId -> Some count
| _ -> None)
{ model with
QuizForm =
{ model.QuizForm with
PickerTopicId = Some topicId
PickerLoading = true
PickerQuestions = []
PoolCountText = existingPoolCount |> Option.map string |> Option.defaultValue "" } },
cmd
| PickerQuestionsLoaded questions ->
{ model with QuizForm = { model.QuizForm with PickerQuestions = questions; PickerLoading = false } }, Cmd.none
| PickerQuestionsLoadFailed err ->
{ model with QuizForm = { model.QuizForm with PickerLoading = false; Error = Some err } }, Cmd.none
| ToggleQuestionPick questionId ->
let form = model.QuizForm
let next =
if form.Sources |> List.exists (function FixedDraft qid -> qid = questionId | _ -> false) then
form.Sources
|> List.filter (function
| FixedDraft qid -> qid <> questionId
| _ -> true)
else
form.Sources @ [ FixedDraft questionId ]
{ model with QuizForm = { form with Sources = next } }, Cmd.none
| SelectAllInTopic ->
let form = model.QuizForm
let alreadySelected =
form.Sources
|> List.choose (function
| FixedDraft qid -> Some qid
| _ -> None)
|> Set.ofList
let toAdd =
form.PickerQuestions
|> List.map (fun q -> q.Id)
|> List.filter (alreadySelected.Contains >> not)
|> List.map FixedDraft
{ model with QuizForm = { form with Sources = form.Sources @ toAdd } }, Cmd.none
| SetPoolCountText text -> { model with QuizForm = { model.QuizForm with PoolCountText = text } }, Cmd.none
| AddRandomPool ->
let form = model.QuizForm
match form.PickerTopicId with
| None -> model, Cmd.none
| Some topicId ->
match System.Int32.TryParse form.PoolCountText with
| true, count when count > 0 ->
let withoutOldPool =
form.Sources
|> List.filter (function
| PoolDraft(tid, _) -> tid <> topicId
| _ -> true)
{ model with QuizForm = { form with Sources = withoutOldPool @ [ PoolDraft(topicId, count) ]; Error = None } },
Cmd.none
| _ ->
{ model with QuizForm = { form with Error = Some "Количество случайных вопросов должно быть положительным числом" } },
Cmd.none
| RemoveRandomPool topicId ->
let form = model.QuizForm
let next =
form.Sources
|> List.filter (function
| PoolDraft(tid, _) -> tid <> topicId
| _ -> true)
let poolCountText = if form.PickerTopicId = Some topicId then "" else form.PoolCountText
{ model with QuizForm = { form with Sources = next; PoolCountText = poolCountText } }, Cmd.none
| SubmitQuizForm ->
match buildQuizFields model.QuizForm with
| Error err -> { model with QuizForm = { model.QuizForm with Error = Some err } }, Cmd.none
| Ok(timeLimit, maxAttempts, passingScore) ->
let form = { model.QuizForm with IsSubmitting = true; Error = None }
let sources = form.Sources |> List.map toSourceInput
let cmd =
match model.EditingQuizId with
| None ->
let req: CreateQuizRequest =
{ Title = form.Title
Description = form.Description
TimeLimitMinutes = timeLimit
MaxAttempts = maxAttempts
PassingScore = passingScore
ShuffleQuestions = form.ShuffleQuestions
ShuffleAnswers = form.ShuffleAnswers
Sources = sources }
Cmd.OfAsync.either
(Api.createQuiz token)
req
(function
| Ok summary -> QuizSaved summary
| Error err -> QuizSaveFailed err)
(fun ex -> QuizSaveFailed ex.Message)
| Some quizId ->
let req: UpdateQuizRequest =
{ QuizId = quizId
Title = form.Title
Description = form.Description
TimeLimitMinutes = timeLimit
MaxAttempts = maxAttempts
PassingScore = passingScore
ShuffleQuestions = form.ShuffleQuestions
ShuffleAnswers = form.ShuffleAnswers
Sources = sources }
Cmd.OfAsync.either
(Api.updateQuiz token)
req
(function
| Ok summary -> QuizSaved summary
| Error err -> QuizSaveFailed err)
(fun ex -> QuizSaveFailed ex.Message)
{ model with QuizForm = form }, cmd
| QuizSaved summary ->
let alreadyExists = model.Quizzes |> List.exists (fun q -> q.Id = summary.Id)
{ model with
Quizzes =
if alreadyExists then
model.Quizzes |> List.map (fun q -> if q.Id = summary.Id then summary else q)
else
model.Quizzes @ [ summary ]
ShowQuizForm = false
EditingQuizId = None },
Cmd.none
| QuizSaveFailed err -> { model with QuizForm = { model.QuizForm with IsSubmitting = false; Error = Some err } }, Cmd.none
| RequestDeleteQuiz quizId ->
let cmd =
Cmd.OfAsync.either
(Api.deleteQuiz token)
quizId
(function
| Ok() -> QuizDeleted quizId
| Error err -> QuizDeleteFailed err)
(fun ex -> QuizDeleteFailed ex.Message)
model, cmd
| QuizDeleted quizId ->
{ model with
Quizzes = model.Quizzes |> List.filter (fun q -> q.Id <> quizId)
SelectedQuizId = if model.SelectedQuizId = Some quizId then None else model.SelectedQuizId
EditingQuizId = if model.EditingQuizId = Some quizId then None else model.EditingQuizId
ShowQuizForm = if model.EditingQuizId = Some quizId then false else model.ShowQuizForm },
Cmd.none
| QuizDeleteFailed err -> { model with QuizzesError = Some err }, Cmd.none
| ShowResults quizId ->
let cmd =
Cmd.OfAsync.either
(Api.getQuizResults token)
quizId
(function
| Ok results -> ResultsLoaded results
| Error err -> ResultsLoadFailed err)
(fun ex -> ResultsLoadFailed ex.Message)
{ model with
ResultsQuizId = Some quizId
ResultsLoading = true
ResultsError = None
Results = []
SelectedQuizId = None
ShowQuizForm = false },
cmd
| ResultsLoaded results -> { model with Results = results; ResultsLoading = false }, Cmd.none
| ResultsLoadFailed err -> { model with ResultsLoading = false; ResultsError = Some err }, Cmd.none
| CancelResults -> { model with ResultsQuizId = None }, Cmd.none