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-average-grade-hpps
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: changed

Reports Overview courses per-row average grade now reads HPPS progress tables
Original file line number Diff line number Diff line change
Expand Up @@ -265,15 +265,71 @@

$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 @@ -69,4 +69,14 @@ public function get_users_average_grade( array $user_ids ): float;
* @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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@

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

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

View workflow job for this annotation

GitHub Actions / Psalm (8.2)

PossiblyInvalidPropertyFetch

includes/internal/services/class-tables-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,
'sum' => (float) $row->grade_sum,
);
Expand All @@ -274,6 +274,59 @@
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;
$table = $this->get_progress_table_name();
$submissions_table = $this->get_submissions_table_name();
$placeholders = implode( ', ', array_fill( 0, count( $course_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 lesson_course.meta_value AS course_id, COUNT(*) AS grade_count, COALESCE( SUM( qs.final_grade ), 0 ) AS grade_sum
FROM `$table` p
INNER JOIN `{$wpdb->postmeta}` lesson_course ON lesson_course.post_id = p.post_id
AND lesson_course.meta_key = '_lesson_course'
AND lesson_course.meta_value <> ''
INNER JOIN `{$wpdb->postmeta}` lesson_quiz ON lesson_quiz.post_id = p.post_id
AND lesson_quiz.meta_key = '_lesson_quiz'
AND lesson_quiz.meta_value > 0
INNER JOIN `$table` q ON q.post_id = lesson_quiz.meta_value AND q.user_id = p.user_id AND q.type = 'quiz'
INNER JOIN `$submissions_table` qs ON qs.quiz_id = q.post_id AND qs.user_id = p.user_id
WHERE p.type = 'lesson'
AND q.status IN " . $this->get_graded_statuses_sql() . "
AND qs.final_grade IS NOT NULL
AND lesson_course.meta_value IN ( $placeholders )
GROUP BY lesson_course.meta_value",
$course_ids
)
);
// phpcs:enable
Utils::log_query_error( $wpdb, 'Tables-based grade totals by course' );

$totals = array();
foreach ( (array) $rows as $row ) {
$totals[ (int) $row->course_id ] = array(
'count' => (int) $row->grade_count,
'sum' => (float) $row->grade_sum,
);
}

return $totals;
}

/**
* Build SQL clause for filtering by user ID.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ class Sensei_Reports_Overview_List_Table_Courses extends Sensei_Reports_Overview
*/
private $reports_overview_service_courses;

/**
* Per-course grade totals cache for the current page.
*
* @var array<int, array{count:int, sum:float}>
*/
private $grade_totals_by_course = array();

/**
* Constructor
Expand All @@ -54,6 +60,50 @@ public function __construct( Sensei_Grading $grading, Sensei_Course $course, Sen
$this->grading = $grading;
$this->course = $course;
$this->reports_overview_service_courses = $reports_overview_service_courses;

if ( has_filter( 'sensei_analysis_course_percentage' ) ) {
_deprecated_hook( 'sensei_analysis_course_percentage', '$$next-version$$' );
}
}

/**
* Prepare the table items and prime the per-course grade totals cache for the current page.
*/
public function prepare_items() {
parent::prepare_items();
$this->prime_row_aggregates( $this->items );
}

/**
* Prime the per-course grade totals cache before generating CSV report rows.
*
* @param array $items The items that will be exported.
*/
protected function before_generate_report_rows( array $items ) {
$this->prime_row_aggregates( $items );
}

/**
* Prime the per-course grade totals cache for the given page items.
*
* @param array $items Current page items (course post objects with an ID).
*/
private function prime_row_aggregates( array $items ) {
$course_ids = array_map(
static function ( $item ) {
return (int) $item->ID;
},
$items
);

$this->grade_totals_by_course = array();
if ( empty( $course_ids ) ) {
return;
}

$this->grade_totals_by_course = ( new Progress_Query_Service_Factory() )
->create_grading_stats_service()
->get_grade_totals_by_course( $course_ids );
}

/**
Expand Down Expand Up @@ -223,24 +273,12 @@ protected function get_row_data( $item ) {

// Get grades only if the course has lessons and quizzes.
if ( ! empty( $lessons ) && $this->course->course_quizzes( $item->ID, true ) ) {
$grade_args = array(
'post__in' => $lessons,
'type' => 'sensei_lesson_status',
'status' => array( 'graded', 'passed', 'failed' ),
'meta_key' => 'grade', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$grade_totals = $this->grade_totals_by_course[ (int) $item->ID ] ?? array(
'count' => 0,
'sum' => 0,
);

/**
* Filter the course completion percentage query arguments.
*
* @hook sensei_analysis_course_percentage
*
* @param {array} $grade_args Array of query arguments for course percentage.
* @param {WP_Post} $item Current course post object.
* @return {array} Filtered array of query arguments for course percentage.
*/
$percent_count = Sensei_Utils::sensei_check_for_activity( apply_filters( 'sensei_analysis_course_percentage', $grade_args, $item ), false );
$percent_total = $this->grading::get_course_users_grades_sum( $item->ID );
$percent_count = $grade_totals['count'];
$percent_total = $grade_totals['sum'];

if ( $percent_count > 0 && $percent_total >= 0 ) {
$average_grade = Sensei_Utils::quotient_as_absolute_rounded_number( $percent_total, $percent_count, 2 ) . '%';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,4 +486,148 @@ public function testGetGradeTotalsByUser_WithAutoPassedLesson_ExcludesFromTotals
$this->assertSame( 2, $result[ $user ]['count'], 'Auto-passed lesson without quiz_answers should be excluded.' );
$this->assertSame( 140.0, $result[ $user ]['sum'] );
}

/**
* Test testGetGradeTotalsByCourse_WithNoCourseIds_ReturnsEmptyArray.
*/
public function testGetGradeTotalsByCourse_WithNoCourseIds_ReturnsEmptyArray(): void {
global $wpdb;
$service = new Comments_Based_Grading_Stats_Service( $wpdb );

$result = $service->get_grade_totals_by_course( array() );

$this->assertSame( array(), $result );
}

/**
* Test testGetGradeTotalsByCourse_WithMultipleStudents_GroupsByCourse.
*/
public function testGetGradeTotalsByCourse_WithMultipleStudents_GroupsByCourse(): void {
global $wpdb;
$user_1 = $this->sensei_factory->user->create();
$user_2 = $this->sensei_factory->user->create();
$course_id = $this->sensei_factory->course->create();
$lesson_id = $this->sensei_factory->lesson->create(
array(
'meta_input' => array(
'_lesson_course' => $course_id,
),
)
);

$this->create_lesson_status_with_grade( $lesson_id, $user_1, 'graded', 80 );
$this->create_lesson_status_with_grade( $lesson_id, $user_2, 'passed', 60 );

$service = new Comments_Based_Grading_Stats_Service( $wpdb );
$result = $service->get_grade_totals_by_course( array( $course_id ) );

$this->assertSame( 2, $result[ $course_id ]['count'] );
$this->assertSame( 140.0, $result[ $course_id ]['sum'] );
}

/**
* Test testGetGradeTotalsByCourse_WithMultipleCourses_ReturnsSeparateTotals.
*/
public function testGetGradeTotalsByCourse_WithMultipleCourses_ReturnsSeparateTotals(): void {
global $wpdb;
$user_id = $this->sensei_factory->user->create();
$course_1 = $this->sensei_factory->course->create();
$course_2 = $this->sensei_factory->course->create();
$lesson_1 = $this->sensei_factory->lesson->create(
array(
'meta_input' => array(
'_lesson_course' => $course_1,
),
)
);
$lesson_2 = $this->sensei_factory->lesson->create(
array(
'meta_input' => array(
'_lesson_course' => $course_2,
),
)
);

$this->create_lesson_status_with_grade( $lesson_1, $user_id, 'graded', 80 );
$this->create_lesson_status_with_grade( $lesson_2, $user_id, 'failed', 90 );

$service = new Comments_Based_Grading_Stats_Service( $wpdb );
$result = $service->get_grade_totals_by_course( array( $course_1, $course_2 ) );

$this->assertSame( 1, $result[ $course_1 ]['count'] );
$this->assertSame( 80.0, $result[ $course_1 ]['sum'] );
$this->assertSame( 1, $result[ $course_2 ]['count'] );
$this->assertSame( 90.0, $result[ $course_2 ]['sum'] );
}

/**
* Test testGetGradeTotalsByCourse_WithCourseIdsFilter_ExcludesUnrequestedCourses.
*/
public function testGetGradeTotalsByCourse_WithCourseIdsFilter_ExcludesUnrequestedCourses(): void {
global $wpdb;
$user_id = $this->sensei_factory->user->create();
$course_1 = $this->sensei_factory->course->create();
$course_2 = $this->sensei_factory->course->create();
$lesson_1 = $this->sensei_factory->lesson->create(
array(
'meta_input' => array(
'_lesson_course' => $course_1,
),
)
);
$lesson_2 = $this->sensei_factory->lesson->create(
array(
'meta_input' => array(
'_lesson_course' => $course_2,
),
)
);

$this->create_lesson_status_with_grade( $lesson_1, $user_id, 'graded', 80 );
$this->create_lesson_status_with_grade( $lesson_2, $user_id, 'graded', 60 );

$service = new Comments_Based_Grading_Stats_Service( $wpdb );
$result = $service->get_grade_totals_by_course( array( $course_1 ) );

$this->assertArrayHasKey( $course_1, $result );
$this->assertArrayNotHasKey( $course_2, $result, 'Only the requested course should be present in the result.' );
}

/**
* Test testGetGradeTotalsByCourse_WithAutoPassedLesson_ExcludesFromTotals.
*
* Pins parity with the tables-based implementation: an auto-passed lesson
* (graded, but with no quiz_answers meta because the student never took the
* quiz) must not be counted, mirroring how the tables-based implementation
* excludes lessons without a quiz submission row.
*/
public function testGetGradeTotalsByCourse_WithAutoPassedLesson_ExcludesFromTotals(): void {
global $wpdb;
$user_id = $this->sensei_factory->user->create();
$course_id = $this->sensei_factory->course->create();
$lesson_1 = $this->sensei_factory->lesson->create(
array(
'meta_input' => array(
'_lesson_course' => $course_id,
),
)
);
$lesson_2 = $this->sensei_factory->lesson->create(
array(
'meta_input' => array(
'_lesson_course' => $course_id,
),
)
);

$this->create_lesson_status_with_grade( $lesson_1, $user_id, 'graded', 80 );
// Auto-passed lesson: graded, but no quiz_answers meta.
$this->create_lesson_status_with_grade( $lesson_2, $user_id, 'graded', 100, false );

$service = new Comments_Based_Grading_Stats_Service( $wpdb );
$result = $service->get_grade_totals_by_course( array( $course_id ) );

$this->assertSame( 1, $result[ $course_id ]['count'], 'Auto-passed lesson without quiz_answers should be excluded.' );
$this->assertSame( 80.0, $result[ $course_id ]['sum'] );
}
}
Loading
Loading