Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b8d00d7
Route reports overview header counts through aggregation service
donnapep Aug 14, 2026
ea2b791
Untrack accidentally committed docs plan file
donnapep Aug 14, 2026
f1abd8e
Add count_statuses_by_user to progress aggregation service
donnapep Aug 14, 2026
31062d4
Prime students per-row course counts from aggregation service
donnapep Aug 14, 2026
cbd5d1a
Add get_grade_totals_by_user to grading stats service
donnapep Aug 14, 2026
f632df9
Prime students per-row average grade from grading stats service
donnapep Aug 14, 2026
84b599e
Add per-course grouped aggregates to progress aggregation service
donnapep Aug 14, 2026
7ce9ec3
Route courses overview aggregates through aggregation service
donnapep Aug 14, 2026
190b093
Derive students last activity from progress tables in HPPS mode
donnapep Aug 14, 2026
629f229
Add get_grade_totals_by_course to grading stats service
donnapep Aug 14, 2026
b9fc665
Prime courses per-row average grade from grading stats service
donnapep Aug 14, 2026
c37b504
Add per-course average progress method to courses reports service
donnapep Aug 14, 2026
fd2a727
Prime courses per-row completions and average progress from tables
donnapep Aug 14, 2026
1d95072
Consolidate reports overview HPPS changelog entries into one
donnapep Aug 14, 2026
4e6e0bd
Inject progress query services via constructor to match convention
donnapep Aug 14, 2026
1a452a5
Correct students header comment to name the actual columns
donnapep Aug 14, 2026
c379ed6
Merge branch 'trunk' into hpps-reports-overview-aggregates
donnapep Aug 25, 2026
1f623f7
Show N/A for course average progress when it cannot be computed
donnapep Aug 25, 2026
63fb263
Move use statements above the ABSPATH guard
donnapep Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/fix-reports-overview-hpps
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Reports Overview: Students and Courses tabs now read progress from High-Performance Progress Storage when it is enabled.
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,110 @@
return (float) ( $row->grade_sum / $row->grade_count );
}

/**
* Get grade count and sum grouped by user.
*
* @since $$next-version$$
*
* @param int[] $user_ids User IDs to include.
* @return array<int, array{count:int, sum:float}> Map of user_id => totals.
*/
public function get_grade_totals_by_user( array $user_ids ): array {
if ( empty( $user_ids ) ) {
return array();
}

$wpdb = $this->wpdb;
$placeholders = implode( ', ', array_fill( 0, count( $user_ids ), '%d' ) );

// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Statuses from constants; placeholders dynamic; caching by callers.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT c.user_id AS user_id, COUNT(*) AS grade_count, COALESCE( SUM( cm.meta_value ), 0 ) AS grade_sum
FROM `{$wpdb->comments}` c
INNER JOIN `{$wpdb->commentmeta}` cm ON c.comment_ID = cm.comment_id
WHERE c.comment_type = 'sensei_lesson_status'
AND c.comment_approved IN " . $this->get_graded_statuses_sql() . "
AND cm.meta_key = 'grade'
AND EXISTS (
SELECT 1 FROM `{$wpdb->commentmeta}` cm2
WHERE cm2.comment_id = c.comment_ID AND cm2.meta_key = 'quiz_answers'
)
AND c.user_id IN ( $placeholders )
GROUP BY c.user_id",
$user_ids
)
);
// phpcs:enable
Utils::log_query_error( $wpdb, 'Comments-based grade totals by user' );

$totals = array();
foreach ( (array) $rows as $row ) {
$totals[ (int) $row->user_id ] = array(

Check failure on line 268 in includes/internal/services/class-comments-based-grading-stats-service.php

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

PossiblyInvalidPropertyFetch

includes/internal/services/class-comments-based-grading-stats-service.php:268:19: PossiblyInvalidPropertyFetch: Cannot fetch property on possible non-object $row of type array<array-key, mixed> (see https://psalm.dev/114)
'count' => (int) $row->grade_count,

Check failure on line 269 in includes/internal/services/class-comments-based-grading-stats-service.php

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

PossiblyInvalidPropertyFetch

includes/internal/services/class-comments-based-grading-stats-service.php:269:22: PossiblyInvalidPropertyFetch: Cannot fetch property on possible non-object $row of type array<array-key, mixed> (see https://psalm.dev/114)
'sum' => (float) $row->grade_sum,

Check failure on line 270 in includes/internal/services/class-comments-based-grading-stats-service.php

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

PossiblyInvalidPropertyFetch

includes/internal/services/class-comments-based-grading-stats-service.php:270:24: PossiblyInvalidPropertyFetch: Cannot fetch property on possible non-object $row of type array<array-key, mixed> (see https://psalm.dev/114)
);
}

return $totals;
}

/**
* Get grade count and sum grouped by course.
*
* @since $$next-version$$
*
* @param int[] $course_ids Course post IDs.
* @return array<int, array{count:int, sum:float}> Map of course_id => totals.
*/
public function get_grade_totals_by_course( array $course_ids ): array {
if ( empty( $course_ids ) ) {
return array();
}

$wpdb = $this->wpdb;
$placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) );

// The quiz_answers EXISTS check restricts results to attempts where the
// student actually submitted answers. This excludes auto-passed students
// whose lesson was marked passed without ever taking the quiz.
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Statuses from constants. Placeholders created dynamically. Caching handled by callers.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT course.meta_value AS course_id, COUNT(*) AS grade_count, COALESCE( SUM( cm.meta_value ), 0 ) AS grade_sum
FROM `{$wpdb->comments}` c
INNER JOIN `{$wpdb->commentmeta}` cm ON c.comment_ID = cm.comment_id
INNER JOIN `{$wpdb->postmeta}` course ON c.comment_post_ID = course.post_id
INNER JOIN `{$wpdb->posts}` p ON p.ID = course.meta_value
WHERE c.comment_type = 'sensei_lesson_status'
AND c.comment_approved IN " . $this->get_graded_statuses_sql() . "
AND cm.meta_key = 'grade'
AND course.meta_key = '_lesson_course'
AND course.meta_value <> ''
AND EXISTS (
SELECT 1 FROM `{$wpdb->commentmeta}` cm2
WHERE cm2.comment_id = c.comment_ID
AND cm2.meta_key = 'quiz_answers'
)
AND course.meta_value IN ( $placeholders )
GROUP BY course.meta_value",
$course_ids
)
);
// phpcs:enable
Utils::log_query_error( $wpdb, 'Comments-based grade totals by course' );

$totals = array();
foreach ( (array) $rows as $row ) {
$totals[ (int) $row->course_id ] = array(

Check failure on line 324 in includes/internal/services/class-comments-based-grading-stats-service.php

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

PossiblyInvalidPropertyFetch

includes/internal/services/class-comments-based-grading-stats-service.php:324:19: PossiblyInvalidPropertyFetch: Cannot fetch property on possible non-object $row of type array<array-key, mixed> (see https://psalm.dev/114)
'count' => (int) $row->grade_count,

Check failure on line 325 in includes/internal/services/class-comments-based-grading-stats-service.php

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

PossiblyInvalidPropertyFetch

includes/internal/services/class-comments-based-grading-stats-service.php:325:22: PossiblyInvalidPropertyFetch: Cannot fetch property on possible non-object $row of type array<array-key, mixed> (see https://psalm.dev/114)
'sum' => (float) $row->grade_sum,

Check failure on line 326 in includes/internal/services/class-comments-based-grading-stats-service.php

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

PossiblyInvalidPropertyFetch

includes/internal/services/class-comments-based-grading-stats-service.php:326:24: PossiblyInvalidPropertyFetch: Cannot fetch property on possible non-object $row of type array<array-key, mixed> (see https://psalm.dev/114)
);
}

return $totals;
}

/**
* Build SQL clause for filtering by user ID.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,50 @@
$results = (array) $wpdb->get_results( $query, ARRAY_A );
Utils::log_query_error( $wpdb, 'Comments-based status counts' );

$counts = [];
$counts = array();
foreach ( $results as $row ) {
$counts[ $row['comment_approved'] ] = (int) $row['total'];
}

return $counts;
}

/**
* Count progress records grouped by user and status.
*
* @since $$next-version$$
*
* @param array $args Same shape as count_statuses(); 'type' and 'user_id' honored.
* @return array<int, array<string, int>> Map of user_id => [ status => count ].
*/
public function count_statuses_by_user( array $args ): array {
if ( empty( $args['type'] ) || ! in_array( $args['type'], array( 'course', 'lesson' ), true ) ) {
_doing_it_wrong( __METHOD__, 'The "type" argument must be "course" or "lesson".', '$$next-version$$' );
return array();
}

$wpdb = $this->wpdb;
$comment_type = 'course' === $args['type'] ? 'sensei_course_status' : 'sensei_lesson_status';

// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from wpdb.
$query = $wpdb->prepare( "SELECT user_id, comment_approved, COUNT(*) AS total FROM {$wpdb->comments} INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$wpdb->comments}.comment_post_ID AND {$wpdb->posts}.post_status IN ( 'publish', 'private' ) WHERE comment_type = %s", $comment_type );
$query .= $this->build_post_filter_clause( $args );
$query .= $this->build_user_filter_clause( $args );
$query .= $this->build_user_exclusion_clause( $args );
$query .= ' GROUP BY user_id, comment_approved';

// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared in advance. Caching handled by callers.
$results = (array) $wpdb->get_results( $query, ARRAY_A );

Check failure on line 125 in includes/internal/services/class-comments-based-progress-aggregation-service.php

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

UndefinedConstant

includes/internal/services/class-comments-based-progress-aggregation-service.php:125:50: UndefinedConstant: Const ARRAY_A is not defined (see https://psalm.dev/020)
Utils::log_query_error( $wpdb, 'Comments-based status counts by user' );

$counts = array();
foreach ( $results as $row ) {
$counts[ (int) $row['user_id'] ][ $row['comment_approved'] ] = (int) $row['total'];
}

return $counts;
}

/**
* Get aggregate totals for a set of lessons.
*
Expand All @@ -106,13 +142,13 @@
* @return array Associative array with keys: unique_student_count, lesson_start_count, lesson_completed_count, days_to_complete_count, days_to_complete_sum.
*/
public function get_lesson_totals( array $lesson_ids ): array {
$defaults = [
$defaults = array(
'unique_student_count' => 0,
'lesson_start_count' => 0,
'lesson_completed_count' => 0,
'days_to_complete_count' => 0,
'days_to_complete_sum' => 0,
];
);

if ( empty( $lesson_ids ) ) {
return $defaults;
Expand All @@ -134,7 +170,7 @@
INNER JOIN {$wpdb->posts} post ON post.ID = lesson_students.comment_post_ID AND post.post_status IN ( 'publish', 'private' )
LEFT JOIN {$wpdb->commentmeta} lesson_start ON lesson_start.comment_id = lesson_students.comment_id
WHERE lesson_start.meta_key = 'start' AND lesson_students.comment_post_id IN ( $placeholders )",
array_merge( [ '%Y-%m-%d %H:%i:%s' ], $lesson_ids )
array_merge( array( '%Y-%m-%d %H:%i:%s' ), $lesson_ids )
);
// phpcs:enable

Expand All @@ -146,13 +182,13 @@
return $defaults;
}

return [
return array(
'unique_student_count' => (int) $row->unique_student_count,
'lesson_start_count' => (int) $row->lesson_start_count,
'lesson_completed_count' => (int) $row->lesson_completed_count,
'days_to_complete_count' => (int) $row->days_to_complete_count,
'days_to_complete_sum' => (int) $row->days_to_complete_sum,
];
);
}

/**
Expand Down Expand Up @@ -186,6 +222,129 @@
return $count;
}

/**
* Count progress records grouped by post and status.
*
* @since $$next-version$$
*
* @param array $args Same shape as count_statuses(); 'type' and 'post__in' honored.
* @return array<int, array<string, int>> Map of post_id => [ status => count ].
*/
public function count_statuses_by_post( array $args ): array {
if ( empty( $args['type'] ) || ! in_array( $args['type'], array( 'course', 'lesson' ), true ) ) {
_doing_it_wrong( __METHOD__, 'The "type" argument must be "course" or "lesson".', '$$next-version$$' );
return array();
}

$wpdb = $this->wpdb;
$comment_type = 'course' === $args['type'] ? 'sensei_course_status' : 'sensei_lesson_status';

// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from wpdb.
$query = $wpdb->prepare( "SELECT comment_post_ID, comment_approved, COUNT(*) AS total FROM {$wpdb->comments} INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$wpdb->comments}.comment_post_ID AND {$wpdb->posts}.post_status IN ( 'publish', 'private' ) WHERE comment_type = %s", $comment_type );
$query .= $this->build_post_filter_clause( $args );
$query .= $this->build_user_filter_clause( $args );
$query .= $this->build_user_exclusion_clause( $args );
$query .= ' GROUP BY comment_post_ID, comment_approved';

// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared in advance. Caching handled by callers.
$results = (array) $wpdb->get_results( $query, ARRAY_A );

Check failure on line 250 in includes/internal/services/class-comments-based-progress-aggregation-service.php

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

UndefinedConstant

includes/internal/services/class-comments-based-progress-aggregation-service.php:250:50: UndefinedConstant: Const ARRAY_A is not defined (see https://psalm.dev/020)
Utils::log_query_error( $wpdb, 'Comments-based status counts by post' );

$counts = array();
foreach ( $results as $row ) {
$counts[ (int) $row['comment_post_ID'] ][ $row['comment_approved'] ] = (int) $row['total'];
}

return $counts;
}

/**
* Count completed lesson progress per lesson.
*
* @since $$next-version$$
*
* @param int[] $lesson_ids Lesson post IDs.
* @return array<int, int> Map of lesson_id => completion count.
*/
public function get_lesson_completion_counts( array $lesson_ids ): array {
if ( empty( $lesson_ids ) ) {
return array();
}

$wpdb = $this->wpdb;
$placeholders = implode( ', ', array_fill( 0, count( $lesson_ids ), '%d' ) );

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table names from wpdb. Placeholders created dynamically.
$query = $wpdb->prepare(
"SELECT wcom.comment_post_id AS lesson_id, COUNT(*) AS completion_count
FROM {$wpdb->comments} wcom
WHERE wcom.comment_approved IN ('graded', 'ungraded', 'passed', 'failed','complete')
AND comment_type IN ('sensei_lesson_status')
AND wcom.comment_post_ID IN ( $placeholders )
AND wcom.comment_post_ID IN
(
SELECT wpm.post_id FROM {$wpdb->posts} wpc
JOIN {$wpdb->postmeta} wpm ON wpm.meta_value = wpc.id
WHERE wpm.meta_key = '_lesson_course'
AND wpc.post_status IN ('publish','private')
)
GROUP BY wcom.comment_post_id",
$lesson_ids
);
// phpcs:enable

// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared in advance. Caching handled by callers.
$results = (array) $wpdb->get_results( $query, ARRAY_A );

Check failure on line 297 in includes/internal/services/class-comments-based-progress-aggregation-service.php

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

UndefinedConstant

includes/internal/services/class-comments-based-progress-aggregation-service.php:297:50: UndefinedConstant: Const ARRAY_A is not defined (see https://psalm.dev/020)
Utils::log_query_error( $wpdb, 'Comments-based lesson completion counts' );

$counts = array();
foreach ( $results as $row ) {
$counts[ (int) $row['lesson_id'] ] = (int) $row['completion_count'];
}

return $counts;
}

/**
* Average days-to-completion across the given courses (AVG of per-course averages).
*
* @since $$next-version$$
*
* @param int[] $course_ids Course post IDs.
* @return float
*/
public function get_courses_average_days_to_completion( array $course_ids ): float {
if ( empty( $course_ids ) ) {
return 0.0;
}

$wpdb = $this->wpdb;
$placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) );

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table names from wpdb. Placeholders created dynamically. Date format string passed as %s to avoid conflicting with prepare().
$query = $wpdb->prepare(
"SELECT AVG( aggregated.days_to_completion )
FROM (
SELECT CEIL( SUM( ABS( DATEDIFF( {$wpdb->comments}.comment_date, STR_TO_DATE( {$wpdb->commentmeta}.meta_value, %s ) ) ) + 1 ) / COUNT({$wpdb->commentmeta}.comment_id) ) AS days_to_completion
FROM {$wpdb->comments}
LEFT JOIN {$wpdb->commentmeta} ON {$wpdb->comments}.comment_ID = {$wpdb->commentmeta}.comment_id
AND {$wpdb->commentmeta}.meta_key = 'start'
WHERE {$wpdb->comments}.comment_type = 'sensei_course_status'
AND {$wpdb->comments}.comment_approved = 'complete'
AND {$wpdb->comments}.comment_post_ID IN ( $placeholders )
GROUP BY {$wpdb->comments}.comment_post_ID
) AS aggregated",
array_merge( array( '%Y-%m-%d %H:%i:%s' ), $course_ids )
);
// phpcs:enable

// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared in advance. Caching handled by callers.
$result = $wpdb->get_var( $query );
Utils::log_query_error( $wpdb, 'Comments-based courses average days to completion' );

return (float) $result;
}

/**
* Build SQL clause for filtering by post ID(s).
*
Expand Down Expand Up @@ -255,7 +414,7 @@
}

$wpdb = $this->wpdb;
$not_like_clauses = [];
$not_like_clauses = array();
foreach ( $prefixes as $prefix ) {
$escaped_prefix = $wpdb->esc_like( $prefix );
$not_like_clauses[] = $wpdb->prepare( 'comment_author NOT LIKE %s', $escaped_prefix . '%' );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,24 @@ public function get_courses_average_grade( array $course_ids = array() ): float;
* @return float
*/
public function get_users_average_grade( array $user_ids ): float;

/**
* Get grade count and sum grouped by user.
*
* @since $$next-version$$
*
* @param int[] $user_ids User IDs to include.
* @return array<int, array{count:int, sum:float}> Map of user_id => totals.
*/
public function get_grade_totals_by_user( array $user_ids ): array;

/**
* Get grade count and sum grouped by course.
*
* @since $$next-version$$
*
* @param int[] $course_ids Course post IDs.
* @return array<int, array{count:int, sum:float}> Map of course_id => totals.
*/
public function get_grade_totals_by_course( array $course_ids ): array;
}
Loading
Loading