plans/hvp-gradebook-audit
H5P Gradebook Audit โ hvp:audit-gradebook
A read-only Artisan command that finds H5P marks that never reached Moodle's gradebook (defect D4a, ticket STU-8). It is the H5P counterpart of quiz:audit-gradebook.
Numbers from the local copy of the live database, 13 Oct 2026. The 8 missing marks are spread over 8 activities in 2 courses.
Overview (5W)
What
A console command, hvp:audit-gradebook, that compares each student's H5P score with their grade in Moodle's gradebook and reports every mismatch:
- G1: the xAPI score exists but the gradebook has no grade
- G2: both exist but they disagree
- G3: a teacher overrode or locked the grade (report only)
It prints a summary, a per-activity table and can export every finding to CSV. It never writes anything.
When
An operator runs it on demand. It is not part of any HTTP request.
- Before and after a repair, to measure the D4a backlog
- Scoped to one course, activity or student when looking into a complaint (
--course,--hvp,--student) - Full scan with
--csv=to hand the list to someone else
Why
STU-8 asks for a full repair of missed marks. Quizzes already had an audit and a push (quiz:audit-gradebook --force โ gradesync.php); H5P had neither.
Moodle's events bridge never had a grade path (D4/D4a), so some H5P scores never landed in grade_grades. Students saw a result in the activity but no mark in the gradebook.
No push is included: it is not confirmed that gradesync.php accepts H5P, and re-sending attempt_submitted would trigger notifications and logstore rows for old attempts.
Where
- Entry point:
app/Console/Commands/AuditHvpGradebook.php - Module:
app/Modules/Hvp/โ Service, Repository (+ interface), DTOs, Enum - Binding:
app/Providers/AppServiceProvider.php - Tests:
tests/Feature/Hvp/HvpGradebookAuditTest.php - Moodle tables (read only):
hvp_xapi_results,hvp,course,course_modules,modules,grade_items,grade_grades
How
- Source of truth: the root
hvp_xapi_resultsrow (parent_idnull) per user and activity. Child rows are per-question results and are skipped. - Expected grade:
raw_score / max_score ร grade_items.grademax, the same formula as Moodle'shvp_grade_item_update(). - One lazy query: the repository joins xAPI โ
hvpโgrade_items(itemmodulehvp, itemnumber 0,GRADE_TYPE_VALUE), left-joinscourseandgrade_grades, and reads thecmidthrough a scalar subquery so it cannot fan out. It streams withlazyById()in chunks (--chunk, default 1000). - Classification: the service compares the gradebook's
rawgrade(orfinalgrade) with the expected grade, within0.00001(five decimals, whatgrade_gradesstores). Overridden, grade-locked or item-locked rows become G3, asgrade_item::update_raw_grade()would refuse to change them. - Ignored on purpose: content with
max_score0, non-value grade items, and gradebook grades with no xAPI result (normal for H5P: grade and xAPI are saved by separate requests โ so there is no G4). - Output: summary by G code, per-activity table sorted by most findings (
--top, 0 shows all), optional CSV with every finding.
Sequence Diagram
sequenceDiagram
autonumber
actor Op as Operator (CLI)
participant Cmd as AuditHvpGradebook
participant Svc as HvpGradebookAuditService
participant Repo as HvpGradebookAuditRepository
participant DB as Shared MySQL (Moodle schema)
participant CSV as CSV file
Op->>Cmd: php artisan hvp:audit-gradebook --course --hvp --student --chunk --top --csv
Cmd->>Cmd: Build HvpGradebookAuditScope (positive int ids, chunk at least 1)
Cmd-->>Op: "READ ONLY (no changes written)"
Cmd->>Svc: audit(scope)
Svc->>Repo: sourceRows(scope)
Repo-->>Svc: LazyCollection (not run yet)
loop Each chunk, lazyById on x.id
Repo->>DB: SELECT root xAPI rows JOIN hvp, grade_items LEFT JOIN course, grade_grades + cmid subquery
DB-->>Repo: Up to chunk rows
Repo-->>Svc: Rows
loop Each row
Svc->>Svc: expected = raw_score / max_score x grademax
alt Grades agree within 0.00001
Svc->>Svc: Skip
else Missing, stale or protected
Svc->>Svc: Push HvpGradebookFinding G1 / G2 / G3
end
end
end
Svc-->>Cmd: HvpGradebookAuditReport (findings, resultsScanned)
Cmd-->>Op: "H5P scores scanned: N"
alt No findings
Cmd-->>Op: "No gradebook findings" (exit 0)
else Findings exist
Cmd-->>Op: Summary table (G code, label, count)
Cmd-->>Op: Per-activity table, busiest first, limited by --top
opt --csv given
Cmd->>CSV: fputcsv header + one row per finding
alt File opens
Cmd-->>Op: "Wrote N finding(s)" (exit 0)
else File cannot open
Cmd-->>Op: Error (exit 1)
end
end
end
There is no HTTP request, controller, FormRequest or event in this feature. The command is the entry point and the only output is the console and an optional CSV file.
Flowchart
flowchart TD
A([Operator runs hvp:audit-gradebook]) --> B[Parse options into HvpGradebookAuditScope]
B --> C[Repository streams root xAPI rows in chunks]
C --> F1{"parent_id null, max_score > 0, gradetype = VALUE?"}
F1 -- No --> X1[Excluded by the query]
F1 -- Yes --> D["expected = raw_score / max_score x grademax"]
D --> E{"Gradebook row present and finalgrade not null?"}
E -- No --> P1{"Overridden or locked?"}
P1 -- Yes --> G3a[G3: overridden or locked]
P1 -- No --> G1[G1: missing gradebook grade]
E -- Yes --> M{"abs of stored minus expected within 0.00001?"}
M -- Yes --> OK[Match: no finding]
M -- No --> P2{"Overridden or locked?"}
P2 -- Yes --> G3b[G3: overridden or locked]
P2 -- No --> G2[G2: stale gradebook grade]
G1 --> R[Collect into HvpGradebookAuditReport]
G2 --> R
G3a --> R
G3b --> R
OK --> R
R --> T{"Any findings?"}
T -- No --> S0(["Print clean message, exit 0"])
T -- Yes --> S1[Print summary and per-activity tables]
S1 --> CS{"--csv given?"}
CS -- No --> S2([exit 0])
CS -- Yes --> FO{"File opens for writing?"}
FO -- Yes --> S3(["Write CSV, exit 0"])
FO -- No --> S4(["Print error, exit 1"])
classDef good fill:#064e3b,stroke:#10b981,color:#e2e8f0
classDef warn fill:#451a03,stroke:#f59e0b,color:#e2e8f0
classDef bad fill:#450a0a,stroke:#ef4444,color:#e2e8f0
class OK,S0,S2,S3 good
class G3a,G3b,X1 warn
class G1,G2,S4 bad
This is a CLI command, so outcomes are exit codes, not HTTP status codes: 0 (SUCCESS) for a clean or reported run, 1 (FAILURE) only when the CSV file cannot be opened.
Finding codes
| Code | Enum case | Meaning | Action |
|---|---|---|---|
| G1 | MissingGradebookGrade | xAPI score exists, no gradebook grade (row absent or finalgrade null) | Repair when a push exists |
| G2 | StaleGradebookGrade | Both exist, they differ by more than 0.00001 | Repair when a push exists |
| G3 | OverriddenOrLocked | Grade overridden, grade locked or grade item locked by a teacher | Leave alone |
Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Console/Commands/AuditHvpGradebook.php | Command | Entry point hvp:audit-gradebook. Parses --course/--hvp/--student/--chunk/--top/--csv into a scope, calls the service, prints the summary and per-activity tables, and writes the CSV export. |
| app/Modules/Hvp/Services/HvpGradebookAuditService.php | Service | Scales each root xAPI score to grademax, compares it with the gradebook within 0.00001, and classifies G1/G2/G3. Counts scanned rows and builds the report. |
| app/Modules/Hvp/Repositories/HvpGradebookAuditRepository.php | Repository | One read-only Query Builder query joining hvp_xapi_results, hvp, course, grade_items, grade_grades, plus a scalar cmid subquery. Applies scope filters and streams with lazyById(). |
| app/Modules/Hvp/Repositories/HvpGradebookAuditRepositoryInterface.php | Repository | Contract for sourceRows(scope): LazyCollection, so the service depends on an interface. |
| app/Modules/Hvp/DTOs/HvpGradebookAuditScope.php | DTO | Course, activity and student id filters plus the chunk size. Empty lists mean "all". |
| app/Modules/Hvp/DTOs/HvpGradebookFinding.php | DTO | One mismatch: type, course, activity, cmid, user, grade item, raw/max score, expected grade, gradebook raw/final, overridden and locked flags. |
| app/Modules/Hvp/DTOs/HvpGradebookAuditReport.php | DTO | Collection of findings and the number of scores scanned, with countFor(type) and total(). |
| app/Modules/Hvp/Enums/HvpGradebookFindingType.php | Enum | Backed enum for the three finding types with code() (G1โG3) and label() via match. Documents why there is no G4. |
| app/Providers/AppServiceProvider.php | Config | Binds HvpGradebookAuditRepositoryInterface to HvpGradebookAuditRepository. |
| tests/Feature/Hvp/HvpGradebookAuditTest.php | Test | 12 feature tests: G1 (absent row, null grade), G2, exact match, G3 (overridden, locked item), ignored child/unscored rows, non-value items, scope filters, command output with no writes, CSV export, clean run. |
| plans/hvp-gradebook-audit/plan.md | Docs | Goal, scope, source of truth, finding codes, layers, Moodle tables touched and open questions. |
| plans/hvp-gradebook-audit/tasks.md | Docs | Task checklist and Done Definition. |
12 files, 962 lines added (commit f66fb93). No migration, route, controller or OpenAPI/Postman change: the feature adds no API endpoint.
Rules Applied
๐๏ธ Architecture
- Layering kept: Command โ Service โ Repository. The command holds no SQL and no classification logic.
- Service depends on
HvpGradebookAuditRepositoryInterface, bound inAppServiceProvider. - Constructor injection in the command and service; no
app()orresolve(). - Data passed between layers as
final readonlyDTOs (scope, finding, report). - Shared database: only SELECTs on seven Moodle tables. No migration, no write.
- Bare Moodle table names (
hvp_xapi_results,grade_gradesโฆ);DB_TABLE_PREFIXis applied automatically. - Lives in the self-contained
Hvpmodule, beside the existing H5P code. - No domain event dispatched: the command is read-only, and re-sending events would trigger notifications for old attempts.
๐ Security
- No raw SQL: Query Builder with bound
where/whereIn; the subquery useswhereColumn. - Explicit column list in
select(), noSELECT *on Moodle data. - CLI-only tool: no endpoint exposes student grades, so no new auth or rate-limit surface.
- Option input cleaned: ids cast to int and only positive values kept; chunk forced to at least 1.
- Grades a teacher overrode or locked are reported as G3 and marked "leave alone", matching Moodle's own protection.
โ๏ธ Coding Style
- Backed enum for finding types with
matchforcode()andlabel()โ no string constants. - Typed class constants instead of magic numbers:
GRADE_TYPE_VALUE,HVP_ITEM_NUMBER,GRADEBOOK_PRECISION. - All classes
final; DTOsfinal readonlywith constructor promotion. - Named arguments when building the scope, finding and report.
- Laravel first:
collect(),LazyCollection,Str::limit,groupBy/sortByDesc/pluck. - PHPDoc on every class and public method; comments explain WHY (root row only, LEFT join on course, scalar subquery to avoid fan-out).
fopen/fputcsvused for the CSV, with a comment explaining that Laravel has no CSV helper.
๐งช Testing
- TDD: 11 failing tests written first (red), then the enum, DTOs, repository and service (green); 12/12 pass.
- Full suite green after the change: 2,031 tests.
RefreshDatabaseand a real database with the Moodle tables the query reads.- Names follow
test_it_โฆand describe the outcome (e.g.test_it_reports_g3_instead_of_g2_when_the_grade_is_overridden). - One scenario per test, no branching: separate tests for absent row vs null grade, overridden vs locked item.
Http::fake()in the command test to prove nothing is sent out, and checks that no table is written.- Edge cases covered: precision tolerance, child rows,
max_score0, non-value grade items, scope filters.
๐ฟ Git Workflow & Delivery
- Conventional commit:
feat(grade): report H5P marks missing from Moodle's gradebook, body explains WHAT and WHY,Refs STU-8. - Tests in the same commit as the code they test; Pint clean.
- Plan and tasks written to
plans/hvp-gradebook-audit/; this report closes the Done Definition. - Open: confirm whether production
gradesync.phpacceptsmodule=hvpbefore adding--force, and who runs the audit and repair on production.