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:
368
src/Client/Features/Teacher/Tests/State.fs
Normal file
368
src/Client/Features/Teacher/Tests/State.fs
Normal 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
|
||||
Reference in New Issue
Block a user