Overview (5W)

📋
What

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.

⏰
When

Triggered during three request paths:

  • Finish — POST /api/v1/attempts/{id}/finish grades all questions via AttemptFinishService::gradeInTransaction().
  • Save — PUT /api/v1/attempts/{id}/pages/{page} builds the response summary via QuestionEngineService::writeSlotStep().
  • Review — GET /api/v1/attempts/{id}/review fetches the student's response via getResponseStep().
⚠
Why

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.

📂
Where

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.

⚙
How

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