Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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-courses-aggregates-hpps
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: changed

Reports Overview courses aggregates now read HPPS progress tables
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
$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();
Expand Down Expand Up @@ -222,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
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,34 @@ public function get_lesson_totals( array $lesson_ids ): array;
* @return int Number of ungraded quiz submissions for live (publish or private) lessons.
*/
public function count_ungraded_quizzes( array $args = array() ): int;

/**
* 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;

/**
* 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;

/**
* 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@
$query .= ' GROUP BY p.status';

// 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 307 in includes/internal/services/class-tables-based-progress-aggregation-service.php

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

UndefinedConstant

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

$counts = array();
Expand All @@ -315,6 +315,190 @@
return $counts;
}

/**
* 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();
}

if ( 'lesson' === $args['type'] ) {
return $this->count_lesson_statuses_with_quiz_by_post( $args );
}

return $this->count_course_statuses_by_post( $args );
}

/**
* 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;
$table = $this->get_progress_table_name();
$placeholders = implode( ', ', array_fill( 0, count( $lesson_ids ), '%d' ) );

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table names from wpdb prefix. Placeholders created dynamically.
$query = $wpdb->prepare(
"SELECT p.post_id AS lesson_id, COUNT(*) AS completion_count
FROM {$table} p
LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.post_id AND pm.meta_key = '_lesson_quiz' AND pm.meta_value > 0
LEFT JOIN {$table} q ON q.post_id = pm.meta_value AND q.user_id = p.user_id AND q.type = 'quiz'
WHERE p.type = 'lesson'
AND p.post_id IN ( $placeholders )
AND COALESCE( q.status, p.status ) IN ('graded', 'ungraded', 'passed', 'failed', 'complete')
GROUP BY p.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 );
Utils::log_query_error( $wpdb, 'Tables-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;
$table = $this->get_progress_table_name();
$placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) );
$utc_offset = Utils::get_utc_offset_string();

// Convert to site-local time before DATEDIFF so results match the comments-based
// implementation, which reads already-local comment_date / commentmeta 'start' values.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name from wpdb prefix. Placeholders created dynamically.
$query = $wpdb->prepare(
"SELECT AVG( aggregated.days_to_completion )
FROM (
SELECT CEIL( SUM( ABS( DATEDIFF( CONVERT_TZ( p.completed_at, '+00:00', '$utc_offset' ), CONVERT_TZ( p.started_at, '+00:00', '$utc_offset' ) ) ) + 1 ) / COUNT(*) ) AS days_to_completion
FROM {$table} p
WHERE p.type = 'course'
AND p.status = 'complete'
AND p.post_id IN ( $placeholders )
GROUP BY p.post_id
) AS aggregated",
$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, 'Tables-based courses average days to completion' );

return (float) $result;
}

/**
* Count course progress grouped by post and status.
*
* @since $$next-version$$
*
* @param array $args Query arguments (see count_statuses).
* @return array<int, array<string, int>> Map of post_id => [ status => count ].
*/
private function count_course_statuses_by_post( array $args ): array {
$wpdb = $this->wpdb;
$table = $this->get_progress_table_name();

// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from wpdb prefix.
$query = "SELECT p.post_id, p.status, COUNT(*) AS total FROM {$table} p";
$query .= " INNER JOIN {$wpdb->posts} post ON post.ID = p.post_id AND post.post_status IN ( 'publish', 'private' )";

$query .= $wpdb->prepare( ' WHERE p.type = %s', $args['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 p.post_id, p.status';

// 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 );
Utils::log_query_error( $wpdb, 'Tables-based course status counts by post' );

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

return $counts;
}

/**
* Count lesson statuses grouped by post, using quiz status when a quiz exists.
*
* See count_lesson_statuses_with_quiz() for the rationale behind using
* COALESCE(q.status, p.status).
*
* @since $$next-version$$
*
* @param array $args Query arguments (see count_statuses).
* @return array<int, array<string, int>> Map of post_id => [ status => count ].
*/
private function count_lesson_statuses_with_quiz_by_post( array $args ): array {
$wpdb = $this->wpdb;
$table = $this->get_progress_table_name();

// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from wpdb prefix.
$query = "SELECT p.post_id, COALESCE( q.status, p.status ) AS effective_status, COUNT( * ) AS total FROM {$table} p";
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from wpdb prefix.
$query .= " INNER JOIN {$wpdb->posts} post ON post.ID = p.post_id AND post.post_status IN ( 'publish', 'private' )";
$query .= " LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.post_id AND pm.meta_key = '_lesson_quiz' AND pm.meta_value > 0";
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from wpdb prefix.
$query .= " LEFT JOIN {$table} q ON q.post_id = pm.meta_value AND q.user_id = p.user_id AND q.type = 'quiz'";

$query .= $wpdb->prepare( ' WHERE p.type = %s', 'lesson' );

$query .= $this->build_post_filter_clause( $args );
$query .= $this->build_user_filter_clause( $args );
$query .= $this->build_user_exclusion_clause( $args, 'COALESCE( q.status, p.status )' );

$query .= ' GROUP BY p.post_id, effective_status';

// 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 );
Utils::log_query_error( $wpdb, 'Tables-based lesson status counts by post' );

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

return $counts;
}

/**
* Build SQL clause for filtering by post ID(s).
*
Expand Down
Loading
Loading