Quiz Regrade-Data Repair
Generated 2026-07-28 · Module: Quiz · Complete
An operator-run artisan command (quiz:repair-regrade-data) that finds and repairs
question-engine rows left in a regrade-unsafe shape by the pre-fix quiz attempt writer. Two
independent defects are handled: finish steps missing Moodle's -finish behaviour var
(A1/A2), and truefalse responses stored as question_answers ids instead of
1/0 (B). Dry-run by default; insert-only repairs — no rows are ever deleted.
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.