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_results row (parent_id null) 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's hvp_grade_item_update().
  • One lazy query: the repository joins xAPI โ†’ hvp โ†’ grade_items (itemmodule hvp, itemnumber 0, GRADE_TYPE_VALUE), left-joins course and grade_grades, and reads the cmid through a scalar subquery so it cannot fan out. It streams with lazyById() in chunks (--chunk, default 1000).
  • Classification: the service compares the gradebook's rawgrade (or finalgrade) with the expected grade, within 0.00001 (five decimals, what grade_grades stores). Overridden, grade-locked or item-locked rows become G3, as grade_item::update_raw_grade() would refuse to change them.
  • Ignored on purpose: content with max_score 0, 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

CodeEnum caseMeaningAction
G1MissingGradebookGradexAPI score exists, no gradebook grade (row absent or finalgrade null)Repair when a push exists
G2StaleGradebookGradeBoth exist, they differ by more than 0.00001Repair when a push exists
G3OverriddenOrLockedGrade overridden, grade locked or grade item locked by a teacherLeave alone

Files Changed

Command Service Repository DTO Enum Config Test Docs
File PathLayerDescription
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 in AppServiceProvider.
  • Constructor injection in the command and service; no app() or resolve().
  • Data passed between layers as final readonly DTOs (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_PREFIX is applied automatically.
  • Lives in the self-contained Hvp module, 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 uses whereColumn.
  • Explicit column list in select(), no SELECT * 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 match for code() and label() โ€” no string constants.
  • Typed class constants instead of magic numbers: GRADE_TYPE_VALUE, HVP_ITEM_NUMBER, GRADEBOOK_PRECISION.
  • All classes final; DTOs final readonly with 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/fputcsv used 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.
  • RefreshDatabase and 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_score 0, 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.php accepts module=hvp before adding --force, and who runs the audit and repair on production.