Ignore Moodle Autosave Steps When Grading
Overview (5W)
Filter out Moodle's autosaved question-attempt steps (negative sequencenumber) from the step history used by grading, saving, and review. A new getStepHistory() repository method returns only real steps (sequencenumber ≥ 0), and getResponseStep() now skips negative steps. This prevents autosave data from being mistaken for the start step's shuffle order, which caused correct answers to be graded as wrong.
Triggered during three request paths:
- Finish —
POST /api/v1/attempts/{id}/finishgrades all questions viaAttemptFinishService::gradeInTransaction(). - Save —
PUT /api/v1/attempts/{id}/pages/{page}builds the response summary viaQuestionEngineService::writeSlotStep(). - Review —
GET /api/v1/attempts/{id}/reviewfetches the student's response viagetResponseStep().
Moodle stores autosaves as step -N. Sorted ascending, -1 comes before step 0 and is mistaken for the start step, which holds the _order shuffle key. Without _order, GradingService::resolveAnswerOrder() falls back to database order — the student's position index points at a different choice. Production attempt 553966 was graded 7/10 instead of 9/10; a total of 7 affected attempts were found.
All changes are within app/Modules/Quiz/:
- Interface —
QuestionEngineRepositoryInterface - Repository —
QuestionEngineRepository - Services —
AttemptFinishService,QuestionEngineService - Tests —
QuizAttemptFinishTest,SaveAnswersTest,QuizCompletionOnFinishTest
No migration, no new endpoint, no config change.
A single new repository method, getStepHistory(), queries question_attempt_steps with WHERE sequencenumber ≥ 0 ORDER BY sequencenumber ASC. Both AttemptFinishService and QuestionEngineService now call this method instead of inline Eloquent queries. getResponseStep() adds the same sequencenumber ≥ 0 filter. This mirrors Moodle's load_from_records() which ignores negative steps.
Sequence Diagram
sequenceDiagram
participant B as Browser
participant C as Controller
participant R as FormRequest
participant FS as AttemptFinishService
participant ES as QuestionEngineService
participant GS as GradingService
participant ER as QuestionEngineRepository
participant DB as Database
B->>C: POST /api/v1/attempts/{id}/finish
C->>R: FinishAttemptRequest (validate)
R-->>C: validated DTO
C->>FS: finish(FinishAttemptDTO)
FS->>DB: BEGIN TRANSACTION
FS->>DB: SELECT FOR UPDATE quiz_attempts
DB-->>FS: locked attempt row
opt responses not empty
FS->>ES: savePageAnswers(attempt, page, responses)
ES->>ER: getStepHistory(qaId)
ER->>DB: SELECT ... WHERE sequencenumber >= 0 ORDER BY sequencenumber
Note over ER,DB: Autosave steps (seq < 0) excluded
DB-->>ER: real steps only
ER-->>ES: Collection of steps
ES->>ER: createStep + createStepData
end
loop each question attempt
FS->>ER: getStepHistory(qaId)
ER->>DB: SELECT ... WHERE sequencenumber >= 0
DB-->>ER: real steps [0, 1, 2, ...]
ER-->>FS: history (no autosave)
FS->>GS: grade(question, latestStepData, history.first().dataAsMap)
Note over GS: history.first() = step 0 with _order
GS-->>FS: GradingResult (state, fraction)
FS->>ER: createStep (finish step)
FS->>ER: createStepData ({-finish: 1})
end
FS->>DB: UPDATE quiz_attempts (state=finished)
FS->>DB: UPSERT quiz_grades
FS->>DB: COMMIT
FS->>FS: dispatch(QuizAttemptFinished)
FS-->>C: result payload
C-->>B: 200 JSON {data: {state, grade, ...}}
Flowchart
flowchart TD
A[POST /api/v1/attempts/id/finish] --> B{Authenticated?}
B -- No --> B1[401 Unauthenticated]
B -- Yes --> C{Attempt exists and belongs to student?}
C -- No --> C1[404 Not Found]
C -- Yes --> D{Attempt state editable?}
D -- Abandoned --> D1[410 Gone]
D -- Finished --> D2[409 Already Finished]
D -- Yes --> E{Quiz visible?}
E -- No --> E1[404 Quiz Not Found]
E -- Yes --> F[BEGIN TRANSACTION + SELECT FOR UPDATE]
F --> G{Lock acquired, still editable?}
G -- No --> D2
G -- Yes --> H[Save page responses if any]
H --> I[Grade all question attempts]
I --> J[getStepHistory - seq >= 0 only]
J --> K{Autosave step -1 present?}
K -- Yes --> K1[Excluded from history]
K -- No --> K2[No change]
K1 --> L[history.first = step 0 with _order]
K2 --> L
L --> M[Grade using correct shuffle order]
M --> N[Create finish step with -finish flag]
N --> O[Finalize attempt + upsert grade]
O --> P[COMMIT]
P --> Q[Dispatch QuizAttemptFinished]
Q --> R[200 JSON response]
style B1 fill:#7f1d1d,stroke:#ef4444,color:#fca5a5
style C1 fill:#7f1d1d,stroke:#ef4444,color:#fca5a5
style D1 fill:#7f1d1d,stroke:#ef4444,color:#fca5a5
style D2 fill:#78350f,stroke:#f59e0b,color:#fde68a
style E1 fill:#7f1d1d,stroke:#ef4444,color:#fca5a5
style K1 fill:#064e3b,stroke:#10b981,color:#6ee7b7
style R fill:#1e1b4b,stroke:#818cf8,color:#c7d2fe
Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Modules/Quiz/Interfaces/QuestionEngineRepositoryInterface.php | Interface | Added getStepHistory(int $questionAttemptId): Collection contract — returns real steps only (sequencenumber ≥ 0), ascending. |
| app/Modules/Quiz/Repositories/QuestionEngineRepository.php | Repository | Implemented getStepHistory() with WHERE sequencenumber >= 0. Updated getResponseStep() to add the same filter so review never returns an autosave as the student's answer. |
| app/Modules/Quiz/Services/AttemptFinishService.php | Service | gradeInTransaction() now reads step history through getStepHistory() instead of an inline Eloquent query, ensuring autosave steps are excluded from grading. |
| app/Modules/Quiz/Services/QuestionEngineService.php | Service | writeSlotStep() now reads step history through getStepHistory(), so the response summary resolves against the real start step's _order. |
| tests/Feature/Quiz/QuizAttemptFinishTest.php | Test | Two regression tests: (1) a shuffled multichoice graded against step 0's _order when a -1 autosave exists; (2) review does not show a stale autosave as the student's answer. |
| tests/Feature/Quiz/SaveAnswersTest.php | Test | Regression test: saving a multichoice answer summarises it from step 0's _order when a -1 step exists. Mock updated to delegate getStepHistory. |
| tests/Feature/Quiz/QuizCompletionOnFinishTest.php | Test | Mock of QuestionEngineRepositoryInterface updated to delegate the new getStepHistory method so existing completion tests keep working. |
Rules Applied
-
Services MUST NOT query the DB directly
architecture
— Moved inline Eloquent queries out of
AttemptFinishServiceandQuestionEngineServiceinto a newQuestionEngineRepository::getStepHistory()method, so only the repository layer touches Eloquent. -
Bind interfaces in service providers
architecture
— The new
getStepHistory()is defined onQuestionEngineRepositoryInterfacefirst; both services depend on the contract, not the concrete class. -
Constructor injection ALWAYS
architecture
— Both services receive
QuestionEngineRepositoryInterfacevia constructor promotion. Noapp()or facade usage in business logic. - TDD: Red → Green → Refactor testing — Three failing tests written first (Phase 1), then the minimum code to make them pass (Phase 2), then cleanup verified with the full suite (Phase 3).
- Tests in the same commit as the code they test git-workflow — All regression tests and production code ship together in one logical change.
-
Feature tests hit the real database
testing
— All three test files use
RefreshDatabaseand seed data viaDB::table()->insert()(no factories for Moodle tables). -
Mock external services, not what you own
testing
—
Http::fake()stubs the Moodle event-forwarding call. The repository mock inSaveAnswersTestonly stubs the firstcreateStepcall to simulate a concurrent-writer collision. -
PHPDoc on every class and public method
coding-style
—
getStepHistory()has a docblock on both the interface and the implementation, explaining why autosave steps are excluded and citing Moodle'sadd_autosaved_step(). -
final by default
coding-style
— Both
QuestionEngineRepositoryandAttemptFinishServiceare declaredfinal. -
No raw SQL strings
security
— The
sequencenumber ≥ 0filter uses Eloquent'swhere('sequencenumber', '>=', 0)with parameter binding, notDB::raw(). -
Read-only models for Moodle tables
architecture
—
MoodleQuestionAttemptStepis a shared read-only model; writes go through the dedicated repository with explicit methods. -
Test naming:
test_it_prefix, describes behaviour testing — e.g.test_it_grades_a_shuffled_multichoice_answer_against_step_0_when_moodle_left_an_autosave_step.