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,73 @@
module Client.Features.Quizzes.Browse.Api
open Fable.Core.JsInterop
open Domain
open Domain.Contracts
open Client.Shared.JsonWire
let private decodeQuizSummary (raw: obj) : QuizSummary =
{ Id = decQuizId raw?Id
Title = raw?Title
Description = raw?Description
TotalPoints = raw?TotalPoints
TimeLimitMinutes = raw?TimeLimitMinutes |> optDec unbox<int>
MaxAttempts = raw?MaxAttempts |> optDec unbox<int>
AttemptsCount = raw?AttemptsCount
AlreadyPassed = raw?AlreadyPassed }
let private decodeMyAttemptSummary (raw: obj) : MyAttemptSummary =
{ AttemptId = decAttemptId raw?AttemptId
StartedAt = System.DateTimeOffset.Parse(raw?StartedAt: string)
Score = raw?Score
MaxScore = raw?MaxScore
Passed = raw?Passed |> optDec unbox<bool> }
let private decodeQuestionViewKind (raw: obj) : QuestionViewKind =
match box raw with
| :? string as caseName ->
match caseName with
| "TrueFalseView" -> TrueFalseView
| "ShortAnswerView" -> ShortAnswerView
| "NumericView" -> NumericView
| other -> failwithf "Неизвестный тип вопроса: %s" other
| _ ->
if not (isNullOrUndefined raw?SingleChoiceView) then
let options: (obj * string)[] = raw?SingleChoiceView
SingleChoiceView(options |> Array.toList |> List.map (fun (idRaw, text) -> decOptionId idRaw, text))
elif not (isNullOrUndefined raw?MultipleChoiceView) then
let options: (obj * string)[] = raw?MultipleChoiceView
MultipleChoiceView(options |> Array.toList |> List.map (fun (idRaw, text) -> decOptionId idRaw, text))
else
failwith "Неизвестный тип вопроса"
let private decodeQuestionView (raw: obj) : QuestionView =
{ Id = decQuestionId raw?Id
Text = raw?Text
Points = raw?Points
Kind = decodeQuestionViewKind raw?Kind }
let private decodeQuizForAttempt (raw: obj) : QuizForAttempt =
{ AttemptId = decAttemptId raw?AttemptId
Quiz = decodeQuizSummary raw?Quiz
StartedAt = System.DateTimeOffset.Parse(raw?StartedAt: string)
Questions = (unbox<obj[]> raw?Questions) |> Array.toList |> List.map decodeQuestionView }
let getAvailableQuizzes (token: string option) : Async<QuizSummary list> =
async {
let! raw = callApi token "GET" "/api/quizzes" None
return (unbox<obj[]> raw) |> Array.toList |> List.map decodeQuizSummary
}
let startAttempt (token: string option) (quizId: QuizId) : Async<Result<QuizForAttempt, string>> =
async {
let body = createObj [ "QuizId" ==> encQuizId quizId ]
let! raw = callApi token "POST" "/api/quizzes/start" (Some body)
return decodeResult decodeQuizForAttempt raw
}
let getMyAttempts (token: string option) (quizId: QuizId) : Async<Result<MyAttemptSummary list, string>> =
async {
let body = createObj [ "QuizId" ==> encQuizId quizId ]
let! raw = callApi token "POST" "/api/quizzes/my-attempts" (Some body)
return decodeResult (fun r -> (unbox<obj[]> r) |> Array.toList |> List.map decodeMyAttemptSummary) raw
}

View File

@@ -0,0 +1,49 @@
module Client.Features.Quizzes.Browse.State
open Elmish
open Client.Features.Quizzes.Browse.Types
let init () : Model * Cmd<Msg> = empty, Cmd.ofMsg Load
let private loadCmd (token: string option) =
Cmd.OfAsync.either Api.getAvailableQuizzes token Loaded (fun ex -> LoadFailed ex.Message)
let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Msg> =
match msg with
| Load -> { model with Loading = true; Error = None }, loadCmd token
| Loaded quizzes -> { model with Quizzes = quizzes; Loading = false }, Cmd.none
| LoadFailed err -> { model with Loading = false; Error = Some err }, Cmd.none
| Start quizId ->
let cmd =
Cmd.OfAsync.either
(Api.startAttempt token)
quizId
(function
| Ok data -> Started data
| Error err -> StartFailed err)
(fun ex -> StartFailed ex.Message)
{ model with StartingQuizId = Some quizId; Error = None }, cmd
| Started _ -> { model with StartingQuizId = None }, Cmd.none // handled by the parent, which switches pages
| StartFailed err -> { model with StartingQuizId = None; Error = Some err }, Cmd.none
| ToggleResults quizId ->
if model.ExpandedResultsFor = Some quizId then
{ model with ExpandedResultsFor = None }, Cmd.none
else
let cmd =
Cmd.OfAsync.either
(Api.getMyAttempts token)
quizId
(function
| Ok results -> ResultsLoaded results
| Error err -> ResultsLoadFailed err)
(fun ex -> ResultsLoadFailed ex.Message)
{ model with
ExpandedResultsFor = Some quizId
ResultsLoading = true
ResultsError = None
Results = [] },
cmd
| ResultsLoaded results -> { model with Results = results; ResultsLoading = false }, Cmd.none
| ResultsLoadFailed err -> { model with ResultsLoading = false; ResultsError = Some err }, Cmd.none

View File

@@ -0,0 +1,37 @@
module Client.Features.Quizzes.Browse.Types
open Domain
open Domain.Contracts
type Model =
{ Quizzes: QuizSummary list
Loading: bool
Error: string option
StartingQuizId: QuizId option
/// Which quiz's past-attempts panel is currently expanded, if any
/// only one at a time, closing on a second click of the same button.
ExpandedResultsFor: QuizId option
ResultsLoading: bool
ResultsError: string option
Results: MyAttemptSummary list }
let empty =
{ Quizzes = []
Loading = false
Error = None
StartingQuizId = None
ExpandedResultsFor = None
ResultsLoading = false
ResultsError = None
Results = [] }
type Msg =
| Load
| Loaded of QuizSummary list
| LoadFailed of string
| Start of QuizId
| Started of QuizForAttempt
| StartFailed of string
| ToggleResults of QuizId
| ResultsLoaded of MyAttemptSummary list
| ResultsLoadFailed of string

View File

@@ -0,0 +1,143 @@
module Client.Features.Quizzes.Browse.View
open Feliz
open Domain.Contracts
open Client.Shared
open Client.Features.Quizzes.Browse.Types
let private attemptRow (a: MyAttemptSummary) =
Html.li [
prop.key (string a.AttemptId)
prop.className "attempt-row"
prop.children [
Html.span [
prop.className "tag-mono"
prop.text (a.StartedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm"))
]
Html.span [
prop.className "score-value"
prop.text (sprintf "%s / %s" (Format.points a.Score) (Format.points a.MaxScore))
]
match a.Passed with
| Some true -> Html.span [ prop.className "grade-stamp passed"; prop.text "Пройден" ]
| Some false -> Html.span [ prop.className "grade-stamp failed"; prop.text "Не пройден" ]
| None -> Html.none
]
]
let private resultsPanel (model: Model) =
Html.div [
prop.className "results-panel"
prop.children [
match model.ResultsError with
| Some err -> Html.p [ prop.className "error"; prop.text err ]
| None -> Html.none
if model.ResultsLoading then
Html.p "Загрузка…"
elif model.Results.IsEmpty then
Html.p "Нет завершённых попыток"
else
Html.ul [ prop.children [ for a in model.Results -> attemptRow a ] ]
]
]
let view (model: Model) (dispatch: Msg -> unit) =
Html.div [
prop.className "quiz-list-page"
prop.children [
Html.h2 "Доступные тесты"
match model.Error with
| Some err -> Html.p [ prop.className "error"; prop.text err ]
| None -> Html.none
if model.Loading then
Html.p "Загрузка…"
elif model.Quizzes.IsEmpty then
Html.p "Тесты недоступны"
else
Html.ul [
prop.children [
for quiz in model.Quizzes ->
let remainingAttempts =
quiz.MaxAttempts |> Option.map (fun m -> System.Math.Max(0, m - quiz.AttemptsCount))
Html.li [
prop.key (string quiz.Id)
prop.className "quiz-card"
prop.children [
Html.h3 quiz.Title
Html.p quiz.Description
Html.div [
prop.className "meta-row"
prop.children [
Html.div [
prop.className "meta-item"
prop.children [
Html.span [ prop.className "meta-label"; prop.text "Баллов" ]
Html.span [
prop.className "meta-value"
prop.text (Format.points quiz.TotalPoints)
]
]
]
match quiz.TimeLimitMinutes with
| Some minutes ->
Html.div [
prop.className "meta-item"
prop.children [
Html.span [ prop.className "meta-label"; prop.text "Лимит времени" ]
Html.span [
prop.className "meta-value"
prop.text (sprintf "%d мин" minutes)
]
]
]
| None -> Html.none
match quiz.MaxAttempts, remainingAttempts with
| Some maxAttempts, Some remaining ->
Html.div [
prop.className "meta-item"
prop.children [
Html.span [ prop.className "meta-label"; prop.text "Осталось попыток" ]
Html.span [
prop.className "meta-value"
prop.text (sprintf "%d из %d" remaining maxAttempts)
]
]
]
| _ -> Html.none
]
]
if quiz.AlreadyPassed then
Html.span [ prop.className "status-badge active"; prop.text "Тест пройден успешно" ]
elif remainingAttempts = Some 0 then
Html.p [ prop.className "hint"; prop.text "Попытки исчерпаны" ]
else
Html.button [
prop.disabled (model.StartingQuizId = Some quiz.Id)
prop.onClick (fun _ -> dispatch (Start quiz.Id))
prop.text (
if model.StartingQuizId = Some quiz.Id then
"Запуск…"
else
"Начать тест"
)
]
if quiz.AttemptsCount > 0 then
Html.button [
prop.type'.button
prop.onClick (fun _ -> dispatch (ToggleResults quiz.Id))
prop.text (
if model.ExpandedResultsFor = Some quiz.Id then
"Скрыть результаты"
else
sprintf "Посмотреть результаты (%d)" quiz.AttemptsCount
)
]
if model.ExpandedResultsFor = Some quiz.Id then
resultsPanel model
]
]
]
]
]
]