5W Analysis

📦

What

A single artisan command quiz:repair-regrade-data that scans non-preview quiz attempts, detects two classes of regrade-unsafe rows (missing -finish markers and legacy truefalse answer ids), and inserts or rewrites them so a future Moodle regrade produces correct grades instead of zeroing them. Reports without writing unless --force is given. An ambiguous third class (A2 — blank step before finish) is report-only unless explicitly opted in via --repair-blank-steps.

When

Run once by an operator on the production host before the next Moodle regrade. Re-runnable and idempotent — a second --force run finds nothing. Not scheduled; triggered manually. Scopes to specific attempts, quizzes, students, or a date cutoff via CLI options. A full scan of 273k+ attempts takes 10–20 minutes; --date=2026-01-01 narrows to the repairable set in seconds.

🎯

Why

Commit 3859518 fixed the writes going forward, but rows already on production remain in the broken shape. Finish steps without -finish cause Moodle's regrade to replay them as blank saves, zeroing the student's grade. Truefalse values stored as answer ids cause Moodle to read every answer as “True”. Both defects are self-perpetuating: once regraded, the damage persists through every future regrade. This command breaks the cycle by making the rows Moodle-safe before the next regrade happens.

📁

Where

Console command in app/Console/Commands/. Business logic in app/Modules/Quiz/Services/RegradeDataRepairService. All database queries in app/Modules/Quiz/Repositories/RegradeDataRepairRepository. Six DTOs and one enum in app/Modules/Quiz/DTOs/ and Enums/. An accumulator support class gathers streaming findings. Interface bound in AppServiceProvider. No HTTP surface — console only.

How

The command builds a RegradeRepairScope DTO from CLI options, passes it to the service. The service streams attempts via a LazyCollection cursor, processes them in configurable chunks. Per chunk: (1) collect uniqueids, (2) batch-load all question_attempt_steps, (3) set-difference in PHP identifies data-less steps, (4) classify each maximal contiguous run of data-less steps by what follows it (A1: last step in finished state; A2: next step carries -finish; else: harmless), (5) detect legacy truefalse values via TruefalseFormat::normalize(). Writes are bulk INSERT of -finish markers and single-row UPDATE of truefalse values, wrapped in a DB::transaction per chunk. An accumulator gathers findings while streaming and builds the final immutable report DTO. No raw SQL; no window functions; portable to the SQLite test schema.

Sequence Diagram

sequenceDiagram
    participant Op as Operator (CLI)
    participant Cmd as RepairQuizRegradeData
    participant Svc as RegradeDataRepairService
    participant Acc as RegradeRepairAccumulator
    participant Repo as RegradeDataRepairRepository
    participant DB as MySQL (Moodle DB)
    participant TF as TruefalseFormat

    Op->>Cmd: php artisan quiz:repair-regrade-data [opts]
    Cmd->>Cmd: buildScope() - RegradeRepairScope DTO
    Cmd->>Svc: countAttemptsInScope(scope)
    Svc->>Repo: countAttempts(scope)
    Repo->>DB: SELECT COUNT(*) FROM quiz_attempts
    DB-->>Repo: count
    Repo-->>Svc: int
    Svc-->>Cmd: int
    Cmd->>Cmd: renderBanner() + startProgress()

    Cmd->>Svc: repair(scope, onBatchProcessed)
    Svc->>Acc: new RegradeRepairAccumulator

    loop Per chunk of attempts
        Svc->>Repo: lazyAttempts(scope).chunk(n)
        Repo->>DB: Cursor: quiz_attempts JOIN quiz JOIN course_modules
        DB-->>Repo: LazyCollection of attempt rows
        Repo-->>Svc: batch

        Note over Svc: Detect A1/A2: finish markers
        Svc->>Repo: findSteps(usageIds)
        Repo->>DB: SELECT from question_attempt_steps
        DB-->>Repo: steps
        Svc->>Repo: findStepIdsWithData(stepIds)
        Repo->>DB: SELECT DISTINCT attemptstepid
        DB-->>Repo: set of ids with data
        Svc->>Repo: findStepIdsWithFinishMarker(stepIds)
        Repo->>DB: SELECT WHERE name = '-finish'
        DB-->>Repo: set of ids with marker
        Svc->>Svc: classifyQuestionAttempt() per slot

        Note over Svc: Detect B: truefalse
        Svc->>Repo: findLegacyTruefalseAnswerRows(usageIds)
        Repo->>DB: SELECT WHERE qtype='truefalse' AND value NOT IN ('','0','1')
        DB-->>Repo: candidate rows
        Svc->>Repo: findTruefalseQuestions(questionIds)
        Repo->>DB: MoodleQuestion with answers + truefalseOptions
        DB-->>Repo: keyed collection
        Svc->>TF: normalize(options, answers, raw)
        TF-->>Svc: '1' or '0' or null

        alt scope.apply = true
            Svc->>DB: DB::transaction
            Svc->>Repo: insertFinishMarkers(stepIds)
            Repo->>DB: INSERT INTO question_attempt_step_data
            Svc->>Repo: updateStepDataValue(id, value)
            Repo->>DB: UPDATE question_attempt_step_data
        end

        Svc->>Acc: record(attempt, finding, rows)
        Svc->>Cmd: onBatchProcessed(count)
    end

    Svc->>Acc: toReport()
    Acc-->>Svc: RegradeRepairReport DTO
    Svc-->>Cmd: RegradeRepairReport

    Cmd->>Cmd: renderReport() tables and next steps
    Cmd-->>Op: EXIT 0
      

Flowchart

flowchart TD
    START([php artisan quiz:repair-regrade-data]) --> PARSE[Parse CLI options]
    PARSE --> VALID{Options valid?}
    VALID -->|No| FAIL([EXIT 1 invalid options])
    VALID -->|Yes| SCOPE[Build RegradeRepairScope DTO]
    SCOPE --> BANNER[Render banner: mode + filters]
    BANNER --> COUNT[Count attempts in scope]
    COUNT --> PROGRESS{More than 1 chunk?}
    PROGRESS -->|Yes| BAR[Start progress bar]
    PROGRESS -->|No| SKIP_BAR[No bar needed]
    BAR --> STREAM
    SKIP_BAR --> STREAM

    STREAM[Stream attempts via LazyCollection cursor] --> CHUNK{Next chunk?}
    CHUNK -->|No| REPORT

    CHUNK -->|Yes| A_DETECT[Detect A1/A2: load steps
set-difference for data-less
classify maximal runs] A_DETECT --> B_DETECT[Detect B: find legacy truefalse
answer ids and normalize via
TruefalseFormat] B_DETECT --> FINDINGS{Any findings?} FINDINGS -->|No| ADV[Advance progress bar] FINDINGS -->|Yes| APPLY{scope.apply?} APPLY -->|No dry run| RECORD[Record findings in accumulator] APPLY -->|Yes| WRITE[DB::transaction per chunk] WRITE --> W_A1{A1 findings?} W_A1 -->|Yes| INSERT[Bulk INSERT -finish=1 markers] W_A1 -->|No| W_A2 INSERT --> W_A2{A2 + repairBlankSteps?} W_A2 -->|Yes| INSERT2[INSERT -finish=1 for A2 steps] W_A2 -->|No| W_B INSERT2 --> W_B W_B{B findings?} W_B -->|Yes| UPDATE[UPDATE value to 1 or 0] W_B -->|No| RECORD UPDATE --> RECORD RECORD --> ADV ADV --> CHUNK REPORT[Build RegradeRepairReport DTO] --> HAS{hasFindings?} HAS -->|No| CLEAN[No regrade-unsafe rows found] HAS -->|Yes| TABLES[Render findings table + quiz table] TABLES --> DETAIL{--detail?} DETAIL -->|Yes| ATBL[Render per-attempt table] DETAIL -->|No| NEXT ATBL --> NEXT[Render next steps] NEXT --> A2_CHECK{A2 rows found?} A2_CHECK -->|Yes + repairBlankSteps| REGRADE[NEXT STEP: regrade in Moodle] A2_CHECK -->|Yes + not opted in| REVIEW[NOT REPAIRED: review by hand] A2_CHECK -->|No| DONE CLEAN --> DONE REGRADE --> DONE REVIEW --> DONE DONE([EXIT 0]) style FAIL fill:#3b1c1c,stroke:#ef4444,color:#fca5a5 style DONE fill:#1c2e1c,stroke:#10b981,color:#6ee7b7 style CLEAN fill:#1c2e1c,stroke:#10b981,color:#6ee7b7 style WRITE fill:#2a1f0f,stroke:#f59e0b,color:#fcd34d style INSERT fill:#2a1f0f,stroke:#f59e0b,color:#fcd34d style INSERT2 fill:#2a1f0f,stroke:#f59e0b,color:#fcd34d style UPDATE fill:#2a1f0f,stroke:#f59e0b,color:#fcd34d

Files Changed

File Path Layer Description
app/Console/Commands/RepairQuizRegradeData.php Command Artisan command — parses options into scope DTO, renders banner/progress/report, delegates all logic to the service
app/Modules/Quiz/Services/RegradeDataRepairService.php Service Classifies data-less step runs (A1/A2) and legacy truefalse values (B), orchestrates batch detection and repair, builds the report via the accumulator
app/Modules/Quiz/Repositories/RegradeDataRepairRepository.php Repository All database reads (lazy cursor, step lookups, set-difference queries, truefalse candidates) and writes (bulk insert finish markers, single-row value update)
app/Modules/Quiz/Interfaces/RegradeDataRepairRepositoryInterface.php Interface Contract for the repository — enables mocking in unit tests without touching the database
app/Modules/Quiz/Enums/RegradeRepairFinding.php Enum Four finding classes with labels, codes, action descriptions, writability rules, and Moodle-regrade-needed flags
app/Modules/Quiz/DTOs/RegradeRepairScope.php DTO Filters + execution mode for one run — attempt/quiz/student ids, date cutoff, chunk size, apply flag, repair-blank-steps opt-in
app/Modules/Quiz/DTOs/RegradeRepairReport.php DTO Immutable result of a repair run — totals, per-quiz summaries, per-attempt breakdowns, repairable row count respecting the blank-step opt-in
app/Modules/Quiz/DTOs/RegradeRepairAction.php DTO One detected row and the repair it needs — carries the finding enum, row id, and optional new value
app/Modules/Quiz/DTOs/RegradeRepairFindingTotal.php DTO Per-finding-class total: matched attempts and matched rows
app/Modules/Quiz/DTOs/QuizRegradeRepairSummary.php DTO Per-quiz roll-up — quiz name, course module id, attempt counts, row counts per finding class, manual-review flag
app/Modules/Quiz/DTOs/AttemptRegradeRepairSummary.php DTO Per-attempt roll-up for --detail — attempt/quiz/student ids, state, row counts per finding class
app/Modules/Quiz/Support/RegradeRepairAccumulator.php Support Mutable collector that gathers findings while streaming, then builds the immutable RegradeRepairReport DTO
app/Providers/AppServiceProvider.php Provider Binds RegradeDataRepairRepositoryInterface to RegradeDataRepairRepository
tests/Feature/Quiz/QuizRegradeDataRepairCommandTest.php Test 29 end-to-end artisan tests — detection, dry-run, --force, scoping, idempotency, both defect classes, ambiguous A2 opt-in, edge cases
tests/Unit/Modules/Quiz/Services/RegradeDataRepairServiceTest.php Test 19 unit tests on the classification matrix — every step-run shape, first-step exclusion, state scoping, write guards, report roll-up, per-attempt detail collection

Rules Applied

Architecture Layer separation: Command → Service → Repository

The command is a thin console-layer equivalent of a controller: it only parses options, delegates to the service, and renders output. All business logic (classification, run analysis) sits in the service. All queries and writes sit in the repository. No layer skipping.

Architecture Interface-based dependency injection

The service depends on RegradeDataRepairRepositoryInterface, not the concrete repository. The binding lives in AppServiceProvider. Unit tests mock the interface; feature tests hit the real database.

Architecture DTOs between layers

Six final readonly DTOs carry data between layers: RegradeRepairScope (command to service), RegradeRepairReport (service to command), plus action, total, quiz summary, and attempt summary DTOs. No business logic in any DTO.

Architecture No events dispatched

This is a data backfill, not student behaviour. Per the architecture rules, the Moodle event bridge carries domain events only — a repair does not forward anything to Moodle.

Architecture Moodle table write discipline

Writes to question_attempt_step_data are permitted because that table already has approved write access via the Quiz module. No new table gains write access. All writes are inserts or single-row value updates — no deletes.

Coding Style PHP 8.3 features throughout

Backed enums (RegradeRepairFinding), final readonly DTOs with constructor promotion, match expressions over switch, typed properties everywhere, declare(strict_types=1) in every file, named arguments for clarity.

Coding Style Laravel-first: Collection over array_*

All classification logic uses Collection methods: groupBy, flatMap, filter, reject, keyBy, pluck, map. LazyCollection cursor for streaming. CarbonImmutable for date parsing. DB::transaction for atomicity. No raw PHP array functions.

Coding Style PHPDoc on every class and public method

Every new class has a class-level docblock. Every public method has a brief description. @throws declared where applicable. @param/@return included only when the type signature is insufficient.

Security No raw SQL: Eloquent / Query Builder only

All queries use Laravel's Query Builder with parameter binding. No DB::raw(), no whereRaw(), no string interpolation. Portable to the SQLite test schema.

Security Dry-run default, --dry-run wins over --force

The command writes nothing without --force. If both flags are given, --dry-run wins — an operator who passes both meant to be safe. The ambiguous A2 finding has a second gate: --repair-blank-steps.

Security Shared database safety

Insert-only repairs for finish markers; no step or step-data row is ever deleted. Writes are wrapped in DB::transaction per chunk so a failure rolls back cleanly. Preview attempts are excluded — matching Moodle's own regrade scope.

Testing TDD Red to Green to Refactor

Every finding class and edge case was test-driven. Unit tests pin the classification matrix through a mocked repository. Feature tests run end-to-end against a real SQLite database with RefreshDatabase. 48 total tests across both suites.

Testing test_it_ naming, AAA pattern

All test methods follow test_it_<behaviour> naming. Each test follows Arrange, Act, Assert with a single assertion concept. No shared mutable state; each test seeds its own data.

Testing No Moodle factories: DB::table insert

Tests seed Moodle tables via DB::table()->insert() in setUp() and fixture helpers, following the project rule against database/factories/Moodle*Factory.php. Schema is created inline in the test class.

Testing Mock external, use real DB

Feature tests hit the real database (RefreshDatabase). Unit tests mock the repository interface via Mockery. No mocking of Eloquent or Laravel internals.

Coding Style final by default, small methods, no god classes

Every new class is final. The service has 10 focused methods. The command delegates rendering to private methods. The accumulator separates mutable collection from immutable report construction.