I07 — Quiz Module
Overview (5W)
Exposes Moodle's mod_quiz activity to the student portal. Students can list quizzes per course,
view quiz settings and attempt history, start or resume an attempt, navigate questions page-by-page,
auto-save answers, preview a pre-submit summary, finalize the attempt, and review completed attempts.
Eight question types are supported: multichoice, truefalse, shortanswer, essay, numerical, match,
calculated, and description. Review content (marks, correct answers, feedback) is gated by Moodle's
per-quiz review-option bitmask per time context.
Triggered on every student HTTP request. Auto-save writes occur on each page navigation (POST /save). Grading runs synchronously at finish time inside a DB transaction. Overdue detection fires reactively on every save/show/finish request when a time limit is configured — no background jobs. Event forwarding to Moodle runs asynchronously on the Laravel queue after each attempt lifecycle transition (started, saved, finished, reviewed).
Quizzes are the primary summative assessment tool in Flexi's curriculum alongside assignments. The student portal needs its own quiz API — Moodle's web UI is not part of this portal. Teacher grading, quiz creation, and quiz reports remain in Moodle. The module writes directly to Moodle's question-engine tables (with formally approved write access) so Moodle's gradebook, reporting, and analytics remain fully consistent.
New top-level module at app/Modules/Quiz/ (mirrors Assignment module structure, not nested
inside Course). Twenty dedicated Shared models in app/Shared/Models/ cover all quiz and
question-engine Moodle tables. Routes registered under /api/v1/ via
QuizServiceProvider. Question-engine write operations are isolated inside
QuestionEngineService and QuestionEngineRepository.
Attempt bootstrap: AttemptStartService creates a
question_usages record, bootstraps question_attempts for each slot, and writes
initial question_attempt_steps (state=todo) in one DB::transaction().
Page layout is parsed from the quiz_attempts.layout string (comma-separated slot numbers with
0 as page separator).
Saving: AttemptSaveService appends new
question_attempt_steps + question_attempt_step_data rows per slot — append-only,
immutable history mirroring Moodle's native question engine.
Grading: GradingService dispatches per QuestionType enum using
a match expression. Essay always lands in needsgrading; all others are auto-graded.
Grade aggregation respects quiz.grademethod (Highest/Average/First/Last).
Override resolution merges user override → group override → quiz defaults via
OverrideResolverService. All four attempt lifecycle events extend BaseEvent and
are forwarded to Moodle asynchronously via the existing ForwardEventToMoodle listener.
Sequence Diagram
Flowchart — Attempt Lifecycle
Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Modules/Quiz/Controllers/QuizController.php | Controller | index (quiz list) and show (full quiz detail) — thin HTTP delegates to QuizListService / QuizShowService |
| app/Modules/Quiz/Controllers/QuizAttemptController.php | Controller | 6 actions: store (start), show (page), save, summary, finish, review — delegates to respective services |
| app/Modules/Quiz/Requests/ListQuizzesRequest.php | Request | Validates courseId; authorizes that student is enrolled |
| app/Modules/Quiz/Requests/ShowQuizRequest.php | Request | Validates quizId; authorizes enrollment in quiz's course |
| app/Modules/Quiz/Requests/StartAttemptRequest.php | Request | Optional password (max:64) and force_new (boolean); enrollment authorization |
| app/Modules/Quiz/Requests/SaveAnswersRequest.php | Request | page (int ≥ 0) + responses array; custom rule ensures keys are valid integer slot numbers — prevents slot injection |
| app/Modules/Quiz/Requests/FinishAttemptRequest.php | Request | Same rules as SaveAnswersRequest; used for finalizing the attempt |
| app/Modules/Quiz/Requests/ShowAttemptPageRequest.php | Request | Validates optional page query parameter for GET /quiz-attempts/{id} |
| app/Modules/Quiz/Requests/AttemptSummaryRequest.php | Request | Authorization guard for the pre-submit summary endpoint |
| app/Modules/Quiz/Requests/ReviewAttemptRequest.php | Request | No body; authorizes that attempt belongs to the authenticated student |
| app/Modules/Quiz/Services/QuizListService.php | Service | Lists paginated quizzes with override-resolved effective dates, best grade, and attempt counts |
| app/Modules/Quiz/Services/QuizShowService.php | Service | Full quiz detail including attempt history, grade bands, current overall grade, and override resolution |
| app/Modules/Quiz/Services/AttemptStartService.php | Service | Guards quiz open/password/attempt count; resumes or creates attempt; bootstraps question engine in DB::transaction |
| app/Modules/Quiz/Services/AttemptSaveService.php | Service | Saves page answers; detects overdue via overduehandling; sequential nav enforcement; dispatches QuizAttemptAnswersSaved |
| app/Modules/Quiz/Services/AttemptSummaryService.php | Service | Compiles pre-submit summary: all slots with current state, answered/unanswered counts, time remaining |
| app/Modules/Quiz/Services/AttemptFinishService.php | Service | Saves final answers, grades all slots, computes sumgrades, finalizes attempt, upserts quiz_grades — full DB::transaction |
| app/Modules/Quiz/Services/AttemptReviewService.php | Service | Guards finished state; builds per-question review payload gated by ReviewPermissionResolver bitmask |
| app/Modules/Quiz/Services/QuestionEngineService.php | Service | Orchestrates question_usages / question_attempts / steps / step_data writes; overdue action dispatch |
| app/Modules/Quiz/Services/GradingService.php | Service | Per-type auto-grading via match expression: multichoice, truefalse, shortanswer, essay (needsgrading), numerical (tolerance), match, calculated, description |
| app/Modules/Quiz/Services/OverrideResolverService.php | Service | Resolves effective timeopen/timeclose/timelimit/attempts/password: user override → group override → quiz defaults |
| app/Modules/Quiz/Services/ReviewPermissionResolver.php | Service | Pure logic: determines QuizReviewContext from attempt state + time; decodes review bitmask per field |
| app/Modules/Quiz/Repositories/QuizRepository.php | Repository | findForStudent, listForCourse (paginated), findCmId, findCmContextId |
| app/Modules/Quiz/Repositories/QuizAttemptRepository.php | Repository | findById, findInProgress, countFinished, getAllFinished, create, updateState, updateCurrentPage, finalize |
| app/Modules/Quiz/Repositories/QuizGradeRepository.php | Repository | upsert and find quiz_grades — grade aggregation after every finish |
| app/Modules/Quiz/Repositories/QuestionRepository.php | Repository | findById, findAnswers, findTypeOptions (dispatches per QuestionType), findMatchSubquestions, findQuestionsForAttempt |
| app/Modules/Quiz/Repositories/QuestionEngineRepository.php | Repository | createUsage, createAttempt, getAttemptBySlot, getLatestStep, createStep, createStepData, updateAttemptSummary |
| app/Modules/Quiz/Repositories/OverrideRepository.php | Repository | findUserOverride, findGroupOverrides — reads quiz_overrides for effective settings resolution |
| app/Modules/Quiz/Interfaces/QuizRepositoryInterface.php | Interface | Contract for QuizRepository — enables constructor DI through abstraction |
| app/Modules/Quiz/Interfaces/QuizAttemptRepositoryInterface.php | Interface | Contract for QuizAttemptRepository |
| app/Modules/Quiz/Interfaces/QuizGradeRepositoryInterface.php | Interface | Contract for QuizGradeRepository |
| app/Modules/Quiz/Interfaces/QuestionRepositoryInterface.php | Interface | Contract for QuestionRepository |
| app/Modules/Quiz/Interfaces/QuestionEngineRepositoryInterface.php | Interface | Contract for QuestionEngineRepository |
| app/Modules/Quiz/Interfaces/OverrideRepositoryInterface.php | Interface | Contract for OverrideRepository |
| app/Modules/Quiz/Resources/QuizListResource.php | Resource | Lightweight list shape: id, name, intro_excerpt, effective dates, best_grade, has_unfinished_attempt |
| app/Modules/Quiz/Resources/QuizResource.php | Resource | Full quiz detail: all list fields + intro (filtered HTML), attempts[], grade_bands[], effective settings |
| app/Modules/Quiz/Resources/AttemptResource.php | Resource | Attempt metadata + navigation array + embedded AttemptPageResource for current page |
| app/Modules/Quiz/Resources/QuestionResource.php | Resource | All 8 question types via QuestionType match expression; type_config varies per type |
| app/Modules/Quiz/Resources/AttemptSummaryResource.php | Resource | Pre-submit summary: total/answered/not_answered counts + full navigation slot states |
| app/Modules/Quiz/Resources/AttemptFinishResource.php | Resource | Finish result: state, time_finish, sum_grades, grade, grade_feedback (if permitted), can_review_now |
| app/Modules/Quiz/Resources/AttemptReviewResource.php | Resource | Full review: attempt metadata + questions (ReviewQuestionResource collection) + overall_feedback (gated) |
| app/Modules/Quiz/Resources/ReviewQuestionResource.php | Resource | Per-question review: awarded_mark, show_marks/correctness/right_answer (bitmask gated), specific/general feedback |
| app/Modules/Quiz/Events/QuizAttemptStarted.php | Event | Extends BaseEvent; component=mod_quiz, target=attempt, objectTable=quiz_attempts; forwarded to Moodle |
| app/Modules/Quiz/Events/QuizAttemptAnswersSaved.php | Event | Extends BaseEvent; dispatched on every page save (pending PM decision on Moodle forwarding) |
| app/Modules/Quiz/Events/QuizAttemptFinished.php | Event | Extends BaseEvent; dispatched after attempt finalized and quiz_grades updated |
| app/Modules/Quiz/Events/QuizAttemptReviewed.php | Event | Extends BaseEvent; dispatched when student accesses the review endpoint |
| app/Modules/Quiz/Enums/QuizGradeMethod.php | Enum | int enum: Highest(1), Average(2), First(3), Last(4) — grade aggregation strategy |
| app/Modules/Quiz/Enums/QuizNavMethod.php | Enum | string enum: Free('free'), Sequential('seq') — controls page navigation enforcement |
| app/Modules/Quiz/Enums/QuizOverdueHandling.php | Enum | string enum: Autosubmit, Graceperiod, Autoabandon — time expiry behaviour |
| app/Modules/Quiz/Enums/QuizAttemptState.php | Enum | string enum with isEditable() and isReviewable() helpers per state |
| app/Modules/Quiz/Enums/QuestionState.php | Enum | string enum with isAnswered(), isGraded(), isCorrect() helpers for all 8 states |
| app/Modules/Quiz/Enums/QuestionType.php | Enum | string enum for 8 question types; supportsAutoGrading() and fromMoodleType() named constructor |
| app/Modules/Quiz/Enums/QuizReviewContext.php | Enum | int enum bitmask: DuringAttempt(65536), ImmediatelyAfter(4096), LaterOpen(256), AfterClose(16) |
| app/Modules/Quiz/Enums/QuizErrorCode.php | Enum | int enum: error codes 5001–5015 for all domain errors |
| app/Modules/Quiz/Exceptions/QuizNotFoundException.php | Exception | Quiz not found or student not enrolled — code 5001 |
| app/Modules/Quiz/Exceptions/QuizNotOpenException.php | Exception | Quiz outside availability window — code 5002 |
| app/Modules/Quiz/Exceptions/AttemptsExhaustedException.php | Exception | All allowed attempts used — code 5003 |
| app/Modules/Quiz/Exceptions/QuizPasswordRequiredException.php | Exception | Password required or incorrect — code 5004 |
| app/Modules/Quiz/Exceptions/AttemptNotFoundException.php | Exception | Attempt ID does not exist or belongs to another student — code 5005 |
| app/Modules/Quiz/Exceptions/AttemptAlreadyFinishedException.php | Exception | Attempt already finished or abandoned — code 5006 |
| app/Modules/Quiz/Exceptions/AttemptNotFinishedException.php | Exception | Review requested on unfinished attempt — code 5007 |
| app/Modules/Quiz/Exceptions/AttemptNoQuestionsException.php | Exception | Quiz has no questions configured — code 5008 |
| app/Modules/Quiz/Exceptions/AttemptOverdueException.php | Exception | Attempt is overdue — code 5009 |
| app/Modules/Quiz/Exceptions/AttemptAbandonedException.php | Exception | Attempt was abandoned — code 5010 |
| app/Modules/Quiz/Exceptions/InvalidPageException.php | Exception | Page number out of range — code 5011 |
| app/Modules/Quiz/Exceptions/SequentialNavViolationException.php | Exception | Sequential quiz non-sequential page jump — code 5012 |
| app/Modules/Quiz/Exceptions/ResponseValidationException.php | Exception | Response data failed type-specific validation — code 5013 |
| app/Modules/Quiz/Exceptions/QuestionEngineWriteException.php | Exception | DB write to question engine tables failed — code 5014 |
| app/Modules/Quiz/Exceptions/ReviewNotPermittedException.php | Exception | Current time context does not allow reviewing — code 5015 |
| app/Modules/Quiz/DTOs/ListQuizzesDTO.php | DTO | courseId, studentId, perPage=15 — input to QuizListService |
| app/Modules/Quiz/DTOs/ShowQuizDTO.php | DTO | quizId, studentId — input to QuizShowService |
| app/Modules/Quiz/DTOs/StartAttemptDTO.php | DTO | quizId, studentId, password?, forceNew — input to AttemptStartService |
| app/Modules/Quiz/DTOs/SaveAnswersDTO.php | DTO | attemptId, studentId, page, responses[] — input to AttemptSaveService |
| app/Modules/Quiz/DTOs/FinishAttemptDTO.php | DTO | Same shape as SaveAnswersDTO — input to AttemptFinishService |
| app/Modules/Quiz/DTOs/ReviewAttemptDTO.php | DTO | attemptId, studentId — input to AttemptReviewService |
| app/Modules/Quiz/DTOs/AttemptContext.php | DTO | Internal value object: MoodleQuizAttempt + MoodleQuiz + resolved effective settings |
| app/Modules/Quiz/DTOs/GradingResult.php | DTO | QuestionState, fraction, rightAnswer, responseSummary — output of GradingService per slot |
| app/Modules/Quiz/DTOs/LayoutPage.php | DTO | Internal: pageNumber + slots[] — parsed from quiz_attempts.layout comma-separated string |
| app/Modules/Quiz/routes.php | Route | 8 routes under /api/v1/; throttle:60,1 on reads, throttle:20,1 on writes; auth:sanctum + moodle.active |
| app/Shared/Models/MoodleQuiz.php | Model | Extended with slots(), sections(), overrides(), feedback(), attempts() relationships + enum casts for grademethod/navmethod/overduehandling |
| app/Shared/Models/MoodleQuizSlot.php | Model | Read-only; table quiz_slots; relationships to MoodleQuiz + MoodleQuestion; slot/page/maxmark casts |
| app/Shared/Models/MoodleQuizSection.php | Model | Read-only; table quiz_sections; firstslot, shufflequestions casts |
| app/Shared/Models/MoodleQuizFeedback.php | Model | Read-only; table quiz_feedback; grade-band mingrade/maxgrade used in show endpoint |
| app/Shared/Models/MoodleQuizOverride.php | Model | Read-only; table quiz_overrides; user/group overrides for effective settings resolution |
| app/Shared/Models/MoodleQuizAttempt.php | Model | WRITE; table quiz_attempts; state cast to QuizAttemptState enum; fillable columns defined |
| app/Shared/Models/MoodleQuizGrade.php | Model | WRITE; table quiz_grades; upserted after every finish per grademethod |
| app/Shared/Models/MoodleQuestion.php | Model | Read-only; table question; has relationships to answers + all qtype option models |
| app/Shared/Models/MoodleQuestionCategory.php | Model | Read-only; table question_categories; parent/contextid casts |
| app/Shared/Models/MoodleQuestionAnswer.php | Model | Read-only; table question_answers; fraction used for multichoice/truefalse/shortanswer grading |
| app/Shared/Models/MoodleQuestionUsage.php | Model | WRITE; table question_usages; one per attempt; component=mod_quiz; contextid cast |
| app/Shared/Models/MoodleQuestionAttempt.php | Model | WRITE; table question_attempts; one per slot per attempt; hasMany steps(); flagged cast to bool |
| app/Shared/Models/MoodleQuestionAttemptStep.php | Model | WRITE; table question_attempt_steps; state cast to QuestionState; append-only immutable history |
| app/Shared/Models/MoodleQuestionAttemptStepData.php | Model | WRITE; table question_attempt_step_data; key-value pairs per step (response data per question type) |
| app/Shared/Models/MoodleQuestionNumerical.php | Model | Read-only; table question_numerical; tolerance used in numerical grading (parsed as float at service layer) |
| app/Shared/Models/MoodleQtypeMultichoiceOptions.php | Model | Read-only; table qtype_multichoice_options; single/shuffleanswers bool casts |
| app/Shared/Models/MoodleQtypeShortanswerOptions.php | Model | Read-only; table qtype_shortanswer_options; usecase (case sensitivity) bool cast |
| app/Shared/Models/MoodleQtypeEssayOptions.php | Model | Read-only; table qtype_essay_options; responseformat, attachments for type_config in QuestionResource |
| app/Shared/Models/MoodleQtypeMatchOptions.php | Model | Read-only; table qtype_match_options; shuffleanswers flag |
| app/Shared/Models/MoodleQtypeMatchSubquestion.php | Model | Read-only; table qtype_match_subquestions; sub-items for match question display and grading |
| tests/Feature/Quiz/QuizListTest.php | Test | Feature: paginated list, enrollment guard, override effective dates, best grade, unfinished attempt flag |
| tests/Feature/Quiz/QuizAttemptStartTest.php | Test | Feature: new attempt creation + DB row verification, resume, exhausted attempts, quiz not open, password, event firing, layout building |
| tests/Feature/Quiz/QuizAttemptFinishTest.php | Test | Feature: grading per question type, state transitions, quiz_grades upsert, already-finished guard, event firing |
| tests/Unit/Modules/Quiz/Services/ReviewPermissionResolverTest.php | Test | Unit: all 4 review contexts × 7 review flags — 28+ assertions, no DB |
| tests/Unit/Modules/Quiz/Services/GradingServiceTest.php | Test | Unit: per-type graders — correct, wrong, partial, edge cases (case sensitivity, tolerance bounds, multi-correct multichoice) |
| tests/Unit/Modules/Quiz/Services/QuestionEngineServiceTest.php | Test | Unit: layout building for questionsperpage=0/1/N, sequential/free nav page parsing, shuffled sections |
| tests/Unit/Modules/Quiz/Enums/QuizAttemptStateTest.php | Test | Unit: isEditable() and isReviewable() per state |
| tests/Unit/Modules/Quiz/Enums/QuestionStateTest.php | Test | Unit: isAnswered(), isGraded(), isCorrect() per state |
Rules Applied
Architecture — Layer Separation
- All 8 controller methods are 5–15 lines; zero business logic in controllers
- Full stack enforced: Route → Middleware → Controller → FormRequest → Service → Repository → Model
- No layer skipping — services never touch Request objects; controllers never touch DB
- QuestionEngineService isolates all question-engine write orchestration from domain services
- GradingService encapsulates all grading logic; services call only the single grade() entry point
Architecture — Shared Database Rules
- All Moodle table access via dedicated Shared models in app/Shared/Models/
- Read-only models override save/delete to throw exceptions — write guard enforced at model level
- 6 write-enabled models (quiz_attempts, quiz_grades, question_usages, question_attempts, question_attempt_steps, question_attempt_step_data) documented in write-access-approval.md
- No migrations created — quiz and question tables are Moodle-owned
- DB_TABLE_PREFIX applied uniformly — no manual prefix in model $table properties
Architecture — Events System
- All 4 quiz events extend App\Shared\Events\BaseEvent
- BaseEvent fields populated: component=mod_quiz, target=attempt, objectTable=quiz_attempts
- ForwardEventToMoodle listener handles all BaseEvent subclasses — no changes required to listener
- All event dispatching is queued async — never blocks HTTP response
- Event::fake() used in feature tests to assert dispatch without queue execution
Architecture — API Design
- All routes under /api/v1/ — version in URL path, not headers
- Resource naming: plural nouns (/quizzes, /quiz-attempts), kebab-case URIs
- Correct HTTP verbs: GET for reads, POST for start/save/finish
- Standard envelope on every response: success, message, data, errors, code
- Rate limiting: throttle:60,1 on reads; throttle:20,1 on write endpoints
Coding Style — PHP 8.3 Features
- 8 enums used for all fixed value sets — no string constants or magic numbers
- All DTOs are final readonly classes with constructor-promoted typed properties
- match expressions used throughout GradingService for per-type dispatch
- Named arguments used for clarity on 3+ parameter calls
- All properties and parameters have explicit types — no mixed anywhere
- Strict comparison (===) enforced throughout — no type juggling
Coding Style — Laravel First
- Collection::map / ::filter over array_map / array_filter throughout
- Carbon for all timestamp arithmetic (timestart, timefinish, time_remaining)
- DB::transaction() for both attempt bootstrap and finish/grading atomic writes
- Http::fake() for Moodle REST API mocking in feature tests
- Constructor injection everywhere — no app() helper in services/repositories
- All 6 repository interfaces bound in QuizServiceProvider — services depend on contracts
Security
- Every attempt access verifies quiz_attempts.userid === auth()->id() before any read or write
- All authorization in FormRequest::authorize() — not in services or controllers
- SaveAnswersRequest validates that response keys are valid slot numbers — prevents slot injection
- Password check in StartAttemptRequest (FormRequest layer) before service invocation
- Review endpoint guards QuizAttemptState::Finished before decoding review permissions
- No raw SQL — all DB access via Eloquent or Query Builder with parameter binding
- No Moodle table names or raw DB errors leaked in 5xx responses
Testing — TDD Workflow
- Red → Green → Refactor enforced per task (per tasks.md TDD Rule callout)
- No Moodle* factory files — tests seed via DB::table()->insert() in setUp()
- RefreshDatabase trait on all feature tests
- Event::fake() for all attempt lifecycle event dispatch assertions
- Unit tests have zero DB calls (ReviewPermissionResolverTest, GradingServiceTest, enum tests)
- All test methods prefixed test_it_ in snake_case with descriptive outcome names
- AAA pattern (Arrange, Act, Assert) in every test method — no test logic (if/else)