diff --git a/changelog/add-grading-service-migration b/changelog/add-grading-service-migration
new file mode 100644
index 0000000000..e54dfa9673
--- /dev/null
+++ b/changelog/add-grading-service-migration
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Internal: Integrate HPPS in grading backend.
diff --git a/changelog/fix-grading-tab-counts-lesson-filter b/changelog/fix-grading-tab-counts-lesson-filter
new file mode 100644
index 0000000000..0399049cc3
--- /dev/null
+++ b/changelog/fix-grading-tab-counts-lesson-filter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Fix Grading page status tab counts not updating when filtering by a specific lesson.
diff --git a/changelog/fix-grading-teacher-restrictions b/changelog/fix-grading-teacher-restrictions
new file mode 100644
index 0000000000..30954848dc
--- /dev/null
+++ b/changelog/fix-grading-teacher-restrictions
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Fix teachers not seeing all grading rows for their courses due to post-filter pagination mismatch.
diff --git a/config/psalm/psalm-baseline.xml b/config/psalm/psalm-baseline.xml
index f6b625d566..69d4923e01 100644
--- a/config/psalm/psalm-baseline.xml
+++ b/config/psalm/psalm-baseline.xml
@@ -1620,17 +1620,13 @@
-
- $counts['complete']
- $counts['failed']
- $counts['graded']
- $counts['in-progress']
- $counts['passed']
- $counts['ungraded']
-
-
- $args
-
+
+ $item->grade
+ $item->lesson_id
+ $item->status
+ $item->updated_at
+ $item->user_id
+
$course_id
$lesson_id
@@ -1642,6 +1638,9 @@
$search
+
+ $args
+
Sensei_Grading_Main
Sensei_Grading_Main
@@ -4556,6 +4555,11 @@
$row->user_id
+
+
+ $comment->comment_ID
+
+
$row->days_to_complete_count
@@ -4568,6 +4572,21 @@
ARRAY_A
+
+
+ $column
+
+
+ $row->effective_status
+ $row->final_grade
+ $row->post_id
+ $row->updated_at
+ $row->user_id
+
+
+ ARRAY_A
+
+
$row->days_to_complete_count
diff --git a/includes/class-sensei-grading-main.php b/includes/class-sensei-grading-main.php
index e45265dde1..0e0cba95f4 100755
--- a/includes/class-sensei-grading-main.php
+++ b/includes/class-sensei-grading-main.php
@@ -3,6 +3,10 @@
exit; // Exit if accessed directly
}
+use Sensei\Internal\Services\Grading_Listing_Service_Interface;
+use Sensei\Internal\Services\Grading_Item;
+use Sensei\Internal\Services\Progress_Query_Service_Factory;
+
/**
* Admin Grading Overview Data Table in Sensei.
*
@@ -19,12 +23,22 @@ class Sensei_Grading_Main extends Sensei_List_Table {
public $user_ids = false;
public $page_slug = 'sensei_grading';
+ /**
+ * The grading listing service.
+ *
+ * @var Grading_Listing_Service_Interface
+ */
+ private Grading_Listing_Service_Interface $grading_listing_service;
+
/**
* Constructor
*
* @since 1.3.0
+ *
+ * @param array|null $args Constructor arguments.
+ * @param Grading_Listing_Service_Interface|null $grading_listing_service The grading listing service.
*/
- public function __construct( $args = null ) {
+ public function __construct( $args = null, ?Grading_Listing_Service_Interface $grading_listing_service = null ) {
$defaults = array(
'course_id' => 0,
@@ -44,6 +58,9 @@ public function __construct( $args = null ) {
$this->view = $args['view'];
}
+ $this->grading_listing_service = $grading_listing_service
+ ?? ( new Progress_Query_Service_Factory() )->create_grading_listing_service();
+
// Load Parent token into constructor
parent::__construct( 'grading_main' );
@@ -237,30 +254,45 @@ public function prepare_items() {
*/
$activity_args = apply_filters( 'sensei_grading_filter_statuses', $activity_args );
- // WP_Comment_Query doesn't support SQL_CALC_FOUND_ROWS, so instead do this twice
- $total_statuses = Sensei_Utils::sensei_check_for_activity(
- array_merge(
- $activity_args,
- array(
- 'count' => true,
- 'offset' => 0,
- 'number' => 0,
- )
- )
- );
-
- // Ensure we change our range to fit (in case a search threw off the pagination) - Should this be added to all views?
- if ( $total_statuses < $activity_args['offset'] ) {
- $new_paged = floor( $total_statuses / $activity_args['number'] );
- $activity_args['offset'] = $new_paged * $activity_args['number'];
+ // Apply teacher and temp-user restrictions so that both listing rows
+ // and cached per-status counts reflect these filters. For tables-based
+ // storage, these args are applied as SQL clauses. For comments-based,
+ // post__in flows through to WP_Comment_Query and the remaining args
+ // are handled by existing post-filters on sensei_check_for_activity.
+ $count_restrictions = apply_filters( 'sensei_count_statuses_args', array( 'type' => 'lesson' ) );
+
+ // Merge teacher's post__in restriction.
+ if ( ! empty( $count_restrictions['post__in'] ) ) {
+ if ( ! empty( $activity_args['post__in'] ) ) {
+ // Intersect: keep only lessons in both the course filter and teacher filter.
+ $intersected = array_values(
+ array_intersect( $activity_args['post__in'], $count_restrictions['post__in'] )
+ );
+
+ // Force no-results when the intersection is empty (e.g. teacher
+ // does not own any lessons in the selected course).
+ $activity_args['post__in'] = empty( $intersected ) ? array( 0 ) : $intersected;
+ } elseif ( ! empty( $activity_args['post_id'] ) ) {
+ // Validate that the specific lesson belongs to this teacher's courses.
+ if ( ! in_array( (int) $activity_args['post_id'], array_map( 'intval', $count_restrictions['post__in'] ), true ) ) {
+ $activity_args['post__in'] = array( 0 );
+ }
+ } else {
+ $activity_args['post__in'] = $count_restrictions['post__in'];
+ }
}
- $statuses = Sensei_Utils::sensei_check_for_activity( $activity_args, true );
- // Need to always return an array, even with only 1 item
- if ( ! is_array( $statuses ) ) {
- $statuses = array( $statuses );
+
+ // Pass through temp-user exclusion for the listing service.
+ if ( ! empty( $count_restrictions['exclude_user_login_prefixes'] ) ) {
+ $activity_args['exclude_user_login_prefixes'] = $count_restrictions['exclude_user_login_prefixes'];
+ if ( ! empty( $count_restrictions['include_statuses_override'] ) ) {
+ $activity_args['include_statuses_override'] = $count_restrictions['include_statuses_override'];
+ }
}
- $this->total_items = $total_statuses;
- $this->items = $statuses;
+
+ $result = $this->grading_listing_service->get_lesson_progress_items( $activity_args );
+ $this->total_items = $result['total_count'];
+ $this->items = $result['items'];
$total_items = $this->total_items;
$total_pages = ceil( $total_items / $per_page );
@@ -277,25 +309,31 @@ public function prepare_items() {
* Generates content for a single row of the table, overriding parent
*
* @since 1.7.0
- * @param object $item The current item
+ * @param Grading_Item $item The current item.
*/
protected function get_row_data( $item ) {
- global $wp_version;
+ $status = $item->status;
+ $user_id = $item->user_id;
+ $lesson_id = $item->lesson_id;
+ $updated = $item->updated_at;
+ $grade_val = $item->grade;
+
+ $grade_display = null !== $grade_val ? $grade_val . '%' : __( 'N/A', 'sensei-lms' );
$grade = '';
- if ( 'complete' == $item->comment_approved ) {
+ if ( 'complete' == $status ) {
$status_html = '' . esc_html__( 'Completed', 'sensei-lms' ) . '';
$grade = __( 'No Grade', 'sensei-lms' );
- } elseif ( 'graded' == $item->comment_approved ) {
+ } elseif ( 'graded' == $status ) {
$status_html = '' . esc_html__( 'Graded', 'sensei-lms' ) . '';
- $grade = get_comment_meta( $item->comment_ID, 'grade', true ) . '%';
- } elseif ( 'passed' == $item->comment_approved ) {
+ $grade = $grade_display;
+ } elseif ( 'passed' == $status ) {
$status_html = '' . esc_html__( 'Passed', 'sensei-lms' ) . '';
- $grade = get_comment_meta( $item->comment_ID, 'grade', true ) . '%';
- } elseif ( 'failed' == $item->comment_approved ) {
+ $grade = $grade_display;
+ } elseif ( 'failed' == $status ) {
$status_html = '' . esc_html__( 'Failed', 'sensei-lms' ) . '';
- $grade = get_comment_meta( $item->comment_ID, 'grade', true ) . '%';
- } elseif ( 'ungraded' == $item->comment_approved ) {
+ $grade = $grade_display;
+ } elseif ( 'ungraded' == $status ) {
$status_html = '' . esc_html__( 'Ungraded', 'sensei-lms' ) . '';
$grade = __( 'N/A', 'sensei-lms' );
} else {
@@ -303,20 +341,20 @@ protected function get_row_data( $item ) {
$grade = __( 'N/A', 'sensei-lms' );
}
- $title = Sensei_Learner::get_full_name( $item->user_id );
+ $title = Sensei_Learner::get_full_name( $user_id );
- $quiz_id = Sensei()->lesson->lesson_quizzes( $item->comment_post_ID, 'any' );
+ $quiz_id = Sensei()->lesson->lesson_quizzes( $lesson_id, 'any' );
$quiz_link = add_query_arg(
array(
'page' => $this->page_slug,
- 'user' => $item->user_id,
+ 'user' => $user_id,
'quiz_id' => $quiz_id,
),
admin_url( 'admin.php' )
);
$grade_link = '';
- switch ( $item->comment_approved ) {
+ switch ( $status ) {
case 'ungraded':
$grade_link = '' . esc_html__( 'Grade quiz', 'sensei-lms' ) . '';
break;
@@ -328,7 +366,7 @@ protected function get_row_data( $item ) {
break;
}
- $course_id = get_post_meta( $item->comment_post_ID, '_lesson_course', true );
+ $course_id = get_post_meta( $lesson_id, '_lesson_course', true );
$course_title = '';
if ( ! empty( $course_id ) ) {
@@ -347,11 +385,11 @@ protected function get_row_data( $item ) {
add_query_arg(
array(
'page' => $this->page_slug,
- 'lesson_id' => $item->comment_post_ID,
+ 'lesson_id' => $lesson_id,
),
admin_url( 'admin.php' )
)
- ) . '">' . esc_html( get_the_title( $item->comment_post_ID ) ) . '';
+ ) . '">' . esc_html( get_the_title( $lesson_id ) ) . '';
/**
* Filter columns data for the Grading list table.
@@ -359,7 +397,7 @@ protected function get_row_data( $item ) {
* @hook sensei_grading_main_column_data
*
* @param {array} $column_data Column data for a row.
- * @param {object} $item Activity comment object.
+ * @param {Grading_Item} $item Grading item for the row.
* @param {int} $course_id The course ID.
* @return {array} Filtered column data.
*/
@@ -370,14 +408,14 @@ protected function get_row_data( $item ) {
add_query_arg(
array(
'page' => $this->page_slug,
- 'user_id' => $item->user_id,
+ 'user_id' => $user_id,
),
admin_url( 'admin.php' )
)
) . '">' . esc_html( $title ) . '',
'course' => $course_title,
'lesson' => $lesson_title,
- 'updated' => $item->comment_date,
+ 'updated' => $updated,
'user_status' => $status_html,
'user_grade' => $grade,
'action' => $grade_link,
@@ -566,7 +604,26 @@ public function get_views() {
*/
$count_args = apply_filters( 'sensei_grading_count_statuses', $count_args );
- $counts = Sensei()->grading->count_statuses( $count_args );
+ // Use cached per-status counts from prepare_items() when available,
+ // avoiding a second full-table scan with the same JOINs.
+ // Skip the cache if a plugin is filtering $count_args, since the
+ // cached counts would not reflect those modifications.
+ $has_count_filter = has_filter( 'sensei_grading_count_statuses' ) || has_filter( 'sensei_grading_count_statues' );
+ $cached_counts = $this->grading_listing_service->get_status_counts();
+ if ( null !== $cached_counts && ! $has_count_filter ) {
+ // Ensure all expected statuses exist with 0 defaults, matching
+ // the shape that count_statuses() returns.
+ $defaults = array_fill_keys(
+ array( 'graded', 'ungraded', 'passed', 'failed', 'in-progress', 'complete' ),
+ 0
+ );
+ $counts = array_merge( $defaults, $cached_counts );
+
+ /** This filter is documented in includes/class-sensei-grading.php */
+ $counts = apply_filters( 'sensei_count_statuses', $counts, 'sensei_lesson_status' );
+ } else {
+ $counts = Sensei()->grading->count_statuses( $count_args );
+ }
$inprogress_lessons_count = $counts['in-progress'];
$ungraded_lessons_count = $counts['ungraded'];
diff --git a/includes/class-sensei-grading.php b/includes/class-sensei-grading.php
index 4944115ebf..c38fd5958c 100755
--- a/includes/class-sensei-grading.php
+++ b/includes/class-sensei-grading.php
@@ -555,9 +555,10 @@ public function get_stati( $type ) {
*/
public function count_statuses( $args = array() ) {
/**
- * Filter fires inside Sensei_Grading::count_statuses
+ * Filter the arguments used to count progress statuses.
*
- * Alter the post_in array to determine which posts the comment query should be limited to.
+ * Alter the query arguments (post restrictions, user exclusions) used
+ * to count progress statuses on the Grading page.
*
* @since 1.8.0
*
diff --git a/includes/internal/services/class-comments-based-grading-listing-service.php b/includes/internal/services/class-comments-based-grading-listing-service.php
new file mode 100644
index 0000000000..b58bafa1dd
--- /dev/null
+++ b/includes/internal/services/class-comments-based-grading-listing-service.php
@@ -0,0 +1,101 @@
+ true,
+ 'offset' => 0,
+ 'number' => 0,
+ ]
+ )
+ );
+
+ // If the requested offset is beyond the total (e.g. in case a search
+ // threw off the pagination), snap back to the last valid page.
+ $offset = $args['offset'] ?? 0;
+ $number = $args['number'] ?? 10;
+ if ( $number > 0 && $total_count > 0 && $offset >= $total_count ) {
+ $last_page = max( 0, (int) ceil( $total_count / $number ) - 1 );
+ $args['offset'] = $last_page * $number;
+ }
+
+ $statuses = \Sensei_Utils::sensei_check_for_activity( $args, true );
+
+ // sensei_check_for_activity returns a single object when there is
+ // exactly one result — normalize to an array.
+ if ( ! is_array( $statuses ) ) {
+ $statuses = [ $statuses ];
+ }
+
+ $items = [];
+ foreach ( $statuses as $comment ) {
+ // sensei_check_for_activity can return false when no results are
+ // found; skip anything that isn't a real comment.
+ if ( ! $comment instanceof \WP_Comment ) {
+ continue;
+ }
+
+ $grade_value = get_comment_meta( $comment->comment_ID, 'grade', true );
+
+ $items[] = new Grading_Item(
+ $comment->comment_approved,
+ (int) $comment->user_id,
+ (int) $comment->comment_post_ID,
+ $comment->comment_date,
+ '' !== $grade_value ? (float) $grade_value : null
+ );
+ }
+
+ return [
+ 'items' => $items,
+ 'total_count' => (int) $total_count,
+ ];
+ }
+
+ /**
+ * Get cached per-status counts.
+ *
+ * Not supported by comments-based implementation.
+ *
+ * @since $$next-version$$
+ *
+ * @return array|null Always null for comments-based storage.
+ */
+ public function get_status_counts(): ?array {
+ return null;
+ }
+}
diff --git a/includes/internal/services/class-comments-based-progress-aggregation-service.php b/includes/internal/services/class-comments-based-progress-aggregation-service.php
index a506cfa0df..e7ebe51b94 100644
--- a/includes/internal/services/class-comments-based-progress-aggregation-service.php
+++ b/includes/internal/services/class-comments-based-progress-aggregation-service.php
@@ -78,7 +78,6 @@ public function count_statuses( array $args ): array {
$query .= $this->build_post_filter_clause( $args );
$query .= $this->build_user_filter_clause( $args );
$query .= $this->build_user_exclusion_clause( $args );
- $query .= $this->build_unsubmitted_quiz_exclusion_clause( $args );
if ( isset( $args['query'] ) ) {
$query .= $args['query'];
@@ -88,6 +87,7 @@ public function count_statuses( array $args ): array {
// 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, 'Comments-based status counts' );
$counts = [];
foreach ( $results as $row ) {
@@ -139,6 +139,7 @@ public function get_lesson_totals( array $lesson_ids ): array {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared in advance. Caching handled by callers.
$row = $wpdb->get_row( $query );
+ Utils::log_query_error( $wpdb, 'Comments-based lesson totals' );
if ( ! $row ) {
return $defaults;
@@ -239,36 +240,4 @@ private function build_user_exclusion_clause( array $args ): string {
return " AND $exclusion_sql";
}
-
- /**
- * Build SQL clause for excluding completed lessons with no quiz submission.
- *
- * When enabled, excludes lessons where a quiz exists but the student has
- * no quiz answers — there is nothing to grade. This covers both 'complete'
- * (never submitted) and orphaned 'passed'/'graded'/'failed' records with
- * no answer data.
- *
- * @since $$next-version$$
- *
- * @param array $args Query arguments.
- * @return string SQL clause.
- */
- private function build_unsubmitted_quiz_exclusion_clause( array $args ): string {
- $exclude = $args['exclude_unsubmitted_quiz_completions'] ?? false;
-
- if ( ! $exclude ) {
- return '';
- }
-
- $wpdb = $this->wpdb;
-
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from wpdb.
- return " AND NOT ( comment_approved != 'in-progress'"
- . " AND EXISTS ( SELECT 1 FROM {$wpdb->postmeta} pm"
- . " WHERE pm.post_id = {$wpdb->comments}.comment_post_ID"
- . " AND pm.meta_key = '_lesson_quiz' AND pm.meta_value > 0 )"
- . " AND NOT EXISTS ( SELECT 1 FROM {$wpdb->commentmeta} cm"
- . " WHERE cm.comment_id = {$wpdb->comments}.comment_ID"
- . " AND cm.meta_key = 'quiz_answers' ) )";
- }
}
diff --git a/includes/internal/services/class-grading-listing-service-interface.php b/includes/internal/services/class-grading-listing-service-interface.php
new file mode 100644
index 0000000000..d469a0a0f7
--- /dev/null
+++ b/includes/internal/services/class-grading-listing-service-interface.php
@@ -0,0 +1,58 @@
+|null Associative array of status => count, or null.
+ */
+ public function get_status_counts(): ?array;
+}
diff --git a/includes/internal/services/class-progress-aggregation-service-interface.php b/includes/internal/services/class-progress-aggregation-service-interface.php
index a5058c0943..ce6c503b5a 100644
--- a/includes/internal/services/class-progress-aggregation-service-interface.php
+++ b/includes/internal/services/class-progress-aggregation-service-interface.php
@@ -37,7 +37,6 @@ interface Progress_Aggregation_Service_Interface {
* @type int|array $user_id Restrict to specific user IDs.
* @type string[] $exclude_user_login_prefixes User login prefixes to exclude.
* @type string[] $include_statuses_override Statuses that bypass user exclusion.
- * @type bool $exclude_unsubmitted_quiz_completions Exclude completed lessons with no quiz submission (default: false).
* }
* @return array Associative array of status => count.
*/
@@ -52,7 +51,7 @@ public function count_statuses( array $args ): array;
* @return array {
* @type int $unique_student_count Number of distinct students.
* @type int $lesson_start_count Number of lesson starts.
- * @type int $lesson_completed_count Number of completed lessons (all non-in-progress statuses).
+ * @type int $lesson_completed_count Number of lessons with a finished status (complete, graded, passed, failed, or ungraded).
* @type int $days_to_complete_count Number of lessons with a valid completion date.
* @type int $days_to_complete_sum Sum of days to complete.
* }
diff --git a/includes/internal/services/class-progress-query-service-factory.php b/includes/internal/services/class-progress-query-service-factory.php
index 17c25cec09..91b7b6db91 100644
--- a/includes/internal/services/class-progress-query-service-factory.php
+++ b/includes/internal/services/class-progress-query-service-factory.php
@@ -43,6 +43,23 @@ public function create_clauses_service(): Progress_Clauses_Service_Interface {
return new Comments_Based_Progress_Clauses_Service( $wpdb );
}
+ /**
+ * Create a Grading_Listing_Service_Interface instance.
+ *
+ * @since $$next-version$$
+ *
+ * @return Grading_Listing_Service_Interface The grading listing service.
+ */
+ public function create_grading_listing_service(): Grading_Listing_Service_Interface {
+ global $wpdb;
+
+ if ( Progress_Storage_Settings::is_hpps_enabled() && Progress_Storage_Settings::is_tables_repository() ) {
+ return new Tables_Based_Grading_Listing_Service( $wpdb );
+ }
+
+ return new Comments_Based_Grading_Listing_Service();
+ }
+
/**
* Create a Progress_Aggregation_Service_Interface instance.
*
diff --git a/includes/internal/services/class-tables-based-grading-listing-service.php b/includes/internal/services/class-tables-based-grading-listing-service.php
new file mode 100644
index 0000000000..3e87ce2b1e
--- /dev/null
+++ b/includes/internal/services/class-tables-based-grading-listing-service.php
@@ -0,0 +1,296 @@
+|null
+ */
+ private ?array $status_counts = null;
+
+ /**
+ * Constructor.
+ *
+ * @since $$next-version$$
+ *
+ * @param \wpdb $wpdb WordPress database object.
+ */
+ public function __construct( \wpdb $wpdb ) {
+ $this->wpdb = $wpdb;
+ }
+
+ /**
+ * Get the progress table name.
+ *
+ * @since $$next-version$$
+ *
+ * @return string
+ */
+ private function get_progress_table_name(): string {
+ return $this->wpdb->prefix . 'sensei_lms_progress';
+ }
+
+ /**
+ * Get lesson progress items for the grading listing.
+ *
+ * @since $$next-version$$
+ *
+ * @param array $args Arguments for the query (see interface).
+ * @return array{ items: Grading_Item[], total_count: int }
+ */
+ public function get_lesson_progress_items( array $args ): array {
+ $wpdb = $this->wpdb;
+ $table = $this->get_progress_table_name();
+ $submissions_table = $wpdb->prefix . 'sensei_lms_quiz_submissions';
+
+ // Count all statuses in a single query for the All/Ungraded/Graded/In Progress tabs.
+ $count_args = $args;
+ $count_args['status'] = 'any';
+ $count_base_query = $this->build_base_query( $table, $submissions_table, $count_args );
+
+ // Get per-status counts from a single GROUP BY query.
+ $status_count_query = "SELECT effective_status, COUNT(*) AS total FROM ( $count_base_query ) AS counted GROUP BY effective_status";
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared via build_base_query. Caching handled by callers.
+ $status_rows = (array) $wpdb->get_results( $status_count_query, ARRAY_A );
+ Utils::log_query_error( $wpdb, 'Grading listing status counts' );
+
+ $this->status_counts = [];
+ foreach ( $status_rows as $row ) {
+ $this->status_counts[ $row['effective_status'] ] = (int) $row['total'];
+ }
+
+ // Derive total_count from status counts.
+ if ( empty( $args['status'] ) || 'any' === $args['status'] ) {
+ $total_count = array_sum( $this->status_counts );
+ } else {
+ $total_count = 0;
+ foreach ( (array) $args['status'] as $s ) {
+ $total_count += $this->status_counts[ $s ] ?? 0;
+ }
+ }
+
+ // Build the full base query WITH status filter for the paginated listing.
+ $base_query = $this->build_base_query( $table, $submissions_table, $args );
+
+ // If the requested offset is beyond the total (e.g. in case a search
+ // threw off the pagination), snap back to the last valid page.
+ $offset = $args['offset'] ?? 0;
+ $number = $args['number'] ?? 10;
+ if ( $number > 0 && $total_count > 0 && $offset >= $total_count ) {
+ $last_page = max( 0, ceil( $total_count / $number ) - 1 );
+ $offset = (int) ( $last_page * $number );
+ }
+
+ // Append ordering and pagination to the base query for the items fetch.
+ $items_query = $base_query;
+ $items_query .= $this->build_order_clause( $args );
+ $items_query .= $wpdb->prepare( ' LIMIT %d OFFSET %d', $number, $offset );
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared via build_base_query. Caching handled by callers.
+ $rows = (array) $wpdb->get_results( $items_query );
+ Utils::log_query_error( $wpdb, 'Grading listing items' );
+
+ $items = [];
+ foreach ( $rows as $row ) {
+ $items[] = new Grading_Item(
+ $row->effective_status,
+ (int) $row->user_id,
+ (int) $row->post_id,
+ get_date_from_gmt( $row->updated_at ),
+ null !== $row->final_grade ? (float) $row->final_grade : null
+ );
+ }
+
+ return [
+ 'items' => $items,
+ 'total_count' => $total_count,
+ ];
+ }
+
+ /**
+ * Build the base SELECT query.
+ *
+ * @since $$next-version$$
+ *
+ * @param string $table Progress table name.
+ * @param string $submissions_table Quiz submissions table name.
+ * @param array $args Query arguments.
+ * @return string SQL query.
+ */
+ private function build_base_query( string $table, string $submissions_table, array $args ): string {
+ $wpdb = $this->wpdb;
+
+ $query = 'SELECT p.post_id, p.user_id, p.updated_at, COALESCE( q.status, p.status ) AS effective_status, qs.final_grade';
+ $query .= " FROM {$table} p";
+ $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 {$submissions_table} qs ON qs.quiz_id = pm.meta_value AND qs.user_id = p.user_id";
+ // Quiz progress is joined without requiring a submission to exist,
+ // so that the effective_status reflects the quiz result even when
+ // the quiz_submissions row is missing (e.g. migrated data).
+ // 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 .= " WHERE p.type = 'lesson'";
+
+ $query .= $this->build_post_filter( $args );
+ $query .= $this->build_user_filter( $args );
+ $query .= $this->build_user_exclusion_filter( $args );
+ $query .= $this->build_status_filter( $args );
+
+ return $query;
+ }
+
+ /**
+ * Build SQL clause for filtering by post ID(s).
+ *
+ * @since $$next-version$$
+ *
+ * @param array $args Query arguments.
+ * @return string SQL clause.
+ */
+ private function build_post_filter( array $args ): string {
+ $wpdb = $this->wpdb;
+
+ if ( ! empty( $args['post__in'] ) && is_array( $args['post__in'] ) ) {
+ $placeholders = implode( ', ', array_fill( 0, count( $args['post__in'] ), '%d' ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ return $wpdb->prepare( " AND p.post_id IN ( $placeholders )", $args['post__in'] );
+ }
+
+ if ( ! empty( $args['post_id'] ) ) {
+ return $wpdb->prepare( ' AND p.post_id = %d', $args['post_id'] );
+ }
+
+ return '';
+ }
+
+ /**
+ * Build SQL clause for filtering by user ID(s).
+ *
+ * @since $$next-version$$
+ *
+ * @param array $args Query arguments.
+ * @return string SQL clause.
+ */
+ private function build_user_filter( array $args ): string {
+ $wpdb = $this->wpdb;
+
+ if ( ! empty( $args['user_id'] ) && is_array( $args['user_id'] ) ) {
+ $placeholders = implode( ', ', array_fill( 0, count( $args['user_id'] ), '%d' ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ return $wpdb->prepare( " AND p.user_id IN ( $placeholders )", $args['user_id'] );
+ }
+
+ if ( ! empty( $args['user_id'] ) ) {
+ return $wpdb->prepare( ' AND p.user_id = %d', $args['user_id'] );
+ }
+
+ return '';
+ }
+
+ /**
+ * Build SQL clause for excluding users by login prefix.
+ *
+ * @since $$next-version$$
+ *
+ * @param array $args Query arguments.
+ * @return string SQL clause.
+ */
+ private function build_user_exclusion_filter( array $args ): string {
+ $status_column = 'COALESCE( q.status, p.status )';
+ return Utils::build_user_exclusion_clause( $this->wpdb, $args, $status_column );
+ }
+
+ /**
+ * Build SQL clause for filtering by status.
+ *
+ * @since $$next-version$$
+ *
+ * @param array $args Query arguments.
+ * @return string SQL clause.
+ */
+ private function build_status_filter( array $args ): string {
+ if ( empty( $args['status'] ) || 'any' === $args['status'] ) {
+ return '';
+ }
+
+ $wpdb = $this->wpdb;
+ $statuses = (array) $args['status'];
+
+ $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+ return $wpdb->prepare( " AND COALESCE( q.status, p.status ) IN ( $placeholders )", $statuses );
+ }
+
+ /**
+ * Get cached per-status counts from the most recent query.
+ *
+ * Returns null if counts are not available (e.g. if
+ * get_lesson_progress_items has not been called yet).
+ *
+ * @since $$next-version$$
+ *
+ * @return array|null Associative array of status => count, or null.
+ */
+ public function get_status_counts(): ?array {
+ return $this->status_counts;
+ }
+
+ /**
+ * Build ORDER BY clause.
+ *
+ * @since $$next-version$$
+ *
+ * @param array $args Query arguments.
+ * @return string SQL ORDER BY clause.
+ */
+ private function build_order_clause( array $args ): string {
+ $order = isset( $args['order'] ) && 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC';
+ $orderby = $args['orderby'] ?? '';
+
+ // Title, course, and lesson columns map to post_id as a simplified
+ // approximation — actual title/course name ordering would require
+ // additional JOINs that are not worth the performance cost here.
+ // This means rows are sorted by numeric ID rather than alphabetically.
+ $orderby_map = [
+ 'title' => 'p.post_id',
+ 'course' => 'p.post_id',
+ 'lesson' => 'p.post_id',
+ 'updated' => 'p.updated_at',
+ 'user_status' => 'effective_status',
+ 'user_grade' => 'qs.final_grade',
+ ];
+
+ $column = esc_sql( $orderby_map[ $orderby ] ?? 'p.updated_at' );
+
+ return " ORDER BY $column $order";
+ }
+}
diff --git a/includes/internal/services/class-tables-based-progress-aggregation-service.php b/includes/internal/services/class-tables-based-progress-aggregation-service.php
index 14fc1035b1..dc752db9a4 100644
--- a/includes/internal/services/class-tables-based-progress-aggregation-service.php
+++ b/includes/internal/services/class-tables-based-progress-aggregation-service.php
@@ -144,6 +144,7 @@ public function get_lesson_totals( array $lesson_ids ): array {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared in advance. Caching handled by callers.
$row = $wpdb->get_row( $query );
+ Utils::log_query_error( $wpdb, 'Tables-based lesson totals' );
if ( ! $row ) {
return $defaults;
@@ -166,9 +167,8 @@ public function get_lesson_totals( array $lesson_ids ): array {
* This mirrors the comments-based behavior where a single comment per lesson
* stores the quiz-derived status directly.
*
- * Uses COALESCE(CASE WHEN qs.id IS NOT NULL THEN q.status END, p.status)
- * so quiz status takes precedence only when a quiz submission exists;
- * otherwise falls back to lesson status.
+ * Uses COALESCE(q.status, p.status) so quiz progress status takes
+ * precedence when it exists; otherwise falls back to lesson status.
*
* @since $$next-version$$
*
@@ -181,7 +181,7 @@ private function count_lesson_statuses_with_quiz( array $args ): array {
$submissions_table = $wpdb->prefix . 'sensei_lms_quiz_submissions';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from wpdb prefix.
- $query = "SELECT COALESCE( CASE WHEN qs.id IS NOT NULL THEN q.status END, p.status ) AS effective_status, COUNT( * ) AS total FROM {$table} p";
+ $query = "SELECT COALESCE( q.status, p.status ) AS effective_status, COUNT( * ) AS total FROM {$table} p";
$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 {$submissions_table} qs ON qs.quiz_id = pm.meta_value AND qs.user_id = p.user_id";
@@ -189,16 +189,16 @@ private function count_lesson_statuses_with_quiz( array $args ): array {
$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_unsubmitted_quiz_exclusion_clause( $args );
$query .= $this->build_post_filter_clause( $args );
$query .= $this->build_user_filter_clause( $args );
- $query .= $this->build_user_exclusion_clause( $args, 'COALESCE( CASE WHEN qs.id IS NOT NULL THEN q.status END, p.status )' );
+ $query .= $this->build_user_exclusion_clause( $args, 'COALESCE( q.status, p.status )' );
$query .= ' GROUP BY 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' );
$counts = [];
foreach ( $results as $row ) {
@@ -233,6 +233,7 @@ private function count_course_statuses( array $args ): array {
// 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' );
$counts = [];
foreach ( $results as $row ) {
@@ -304,26 +305,4 @@ private function build_user_filter_clause( array $args ): string {
private function build_user_exclusion_clause( array $args, string $status_column = 'p.status' ): string {
return Utils::build_user_exclusion_clause( $this->wpdb, $args, $status_column );
}
-
- /**
- * Build SQL clause for excluding completed lessons with no quiz submission.
- *
- * When enabled, excludes lessons where a quiz exists but the student never
- * submitted it and the lesson is already complete — there is nothing to grade.
- * Used by the Grading page; the Reports page passes false to include all students.
- *
- * @since $$next-version$$
- *
- * @param array $args Query arguments.
- * @return string SQL clause.
- */
- private function build_unsubmitted_quiz_exclusion_clause( array $args ): string {
- $exclude = $args['exclude_unsubmitted_quiz_completions'] ?? false;
-
- if ( ! $exclude ) {
- return '';
- }
-
- return " AND NOT ( pm.meta_value IS NOT NULL AND qs.id IS NULL AND p.status = 'complete' )";
- }
}
diff --git a/includes/internal/services/class-tables-based-progress-clauses-service.php b/includes/internal/services/class-tables-based-progress-clauses-service.php
index 2fdad826e1..0cfcaa018a 100644
--- a/includes/internal/services/class-tables-based-progress-clauses-service.php
+++ b/includes/internal/services/class-tables-based-progress-clauses-service.php
@@ -81,7 +81,7 @@ public function add_last_activity_to_courses_clauses( array $clauses ): array {
AND p.status = 'complete'
GROUP BY p.post_id";
- // Map lessons to courses via postmeta, then take the most recent completion date across all lessons per course.
+ // Map lessons to courses via postmeta, then take the most recent activity date across all lessons per course.
$course_query = "SELECT pm.meta_value AS course_id, MAX(lq.last_activity_date) AS last_activity_date
FROM {$wpdb->postmeta} pm
JOIN ({$lessons_query}) lq ON lq.lesson_id = pm.post_id
diff --git a/includes/internal/services/class-utils.php b/includes/internal/services/class-utils.php
index 78e8c04233..43458c1670 100644
--- a/includes/internal/services/class-utils.php
+++ b/includes/internal/services/class-utils.php
@@ -74,6 +74,21 @@ public static function build_user_exclusion_clause( \wpdb $wpdb, array $args, st
return $wpdb->prepare( " AND p.user_id NOT IN ( $id_placeholders )", $excluded_user_ids );
}
+ /**
+ * Log a database query error if one occurred.
+ *
+ * @since $$next-version$$
+ *
+ * @param \wpdb $wpdb WordPress database object.
+ * @param string $context Description of the query for debugging.
+ */
+ public static function log_query_error( \wpdb $wpdb, string $context ): void {
+ if ( ! empty( $wpdb->last_error ) ) {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional debug logging for query failures.
+ error_log( 'Sensei: ' . $context . ' query failed: ' . $wpdb->last_error );
+ }
+ }
+
/**
* Get user IDs whose login matches any of the given prefixes.
*
@@ -101,6 +116,9 @@ private static function get_user_ids_by_login_prefixes( \wpdb $wpdb, array $pref
$where = implode( ' OR ', $like_clauses );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Dynamic WHERE built from prepared clauses. Caching handled by callers.
- return array_map( 'intval', $wpdb->get_col( "SELECT ID FROM {$wpdb->users} WHERE $where" ) );
+ $result = (array) $wpdb->get_col( "SELECT ID FROM {$wpdb->users} WHERE $where" );
+ self::log_query_error( $wpdb, 'User ID lookup by login prefix' );
+
+ return array_map( 'intval', $result );
}
}
diff --git a/tests/unit-tests/internal/services/test-class-comments-based-grading-listing-service.php b/tests/unit-tests/internal/services/test-class-comments-based-grading-listing-service.php
new file mode 100644
index 0000000000..cf4e25b047
--- /dev/null
+++ b/tests/unit-tests/internal/services/test-class-comments-based-grading-listing-service.php
@@ -0,0 +1,179 @@
+sensei_factory = new \Sensei_Factory();
+ }
+
+ /**
+ * Build default query args for get_lesson_progress_items.
+ *
+ * @param array $overrides Args to override.
+ * @return array
+ */
+ private function get_default_args( array $overrides = [] ): array {
+ return array_merge(
+ [
+ 'type' => 'sensei_lesson_status',
+ 'number' => 10,
+ 'offset' => 0,
+ 'orderby' => '',
+ 'order' => 'DESC',
+ 'status' => 'any',
+ ],
+ $overrides
+ );
+ }
+
+ public function testGetLessonProgressItems_WithLessonStatus_ReturnsGradingItems(): void {
+ /* Arrange. */
+ $user_id = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ \Sensei_Utils::update_lesson_status( $user_id, $lesson_id, 'in-progress' );
+
+ $service = new Comments_Based_Grading_Listing_Service();
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args( [ 'post_id' => $lesson_id ] )
+ );
+
+ /* Assert. */
+ $this->assertSame( 1, $result['total_count'], 'Expected exactly one result.' );
+ $this->assertCount( 1, $result['items'], 'Expected exactly one item.' );
+ $this->assertInstanceOf( Grading_Item::class, $result['items'][0], 'Expected a Grading_Item instance.' );
+ $this->assertSame( 'in-progress', $result['items'][0]->status, 'Expected in-progress status.' );
+ $this->assertSame( $user_id, $result['items'][0]->user_id, 'Expected matching user ID.' );
+ $this->assertSame( $lesson_id, $result['items'][0]->lesson_id, 'Expected matching lesson ID.' );
+ $this->assertNull( $result['items'][0]->grade, 'Expected null grade for non-graded item.' );
+ }
+
+ public function testGetLessonProgressItems_WithGradedStatus_ReturnsGradeValue(): void {
+ /* Arrange. */
+ $user_id = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $comment_id = \Sensei_Utils::update_lesson_status( $user_id, $lesson_id, 'graded' );
+ update_comment_meta( $comment_id, 'grade', 85 );
+
+ $service = new Comments_Based_Grading_Listing_Service();
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args( [ 'post_id' => $lesson_id ] )
+ );
+
+ /* Assert. */
+ $this->assertSame( 85.0, $result['items'][0]->grade );
+ }
+
+ public function testGetLessonProgressItems_WithStatusFilter_FiltersResults(): void {
+ /* Arrange. */
+ $user1 = $this->sensei_factory->user->create();
+ $user2 = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ \Sensei_Utils::update_lesson_status( $user1, $lesson_id, 'in-progress' );
+ \Sensei_Utils::update_lesson_status( $user2, $lesson_id, 'in-progress' );
+ \Sensei_Utils::update_lesson_status( $user2, $lesson_id, 'complete' );
+
+ $service = new Comments_Based_Grading_Listing_Service();
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'status' => 'in-progress',
+ 'post_id' => $lesson_id,
+ ]
+ )
+ );
+
+ /* Assert. */
+ $this->assertSame( 1, $result['total_count'], 'Expected one in-progress result.' );
+ $this->assertSame( 'in-progress', $result['items'][0]->status, 'Expected in-progress status.' );
+ }
+
+ public function testGetLessonProgressItems_WithUserIdFilter_RestrictsToUser(): void {
+ /* Arrange. */
+ $user1 = $this->sensei_factory->user->create();
+ $user2 = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ \Sensei_Utils::update_lesson_status( $user1, $lesson_id, 'in-progress' );
+ \Sensei_Utils::update_lesson_status( $user2, $lesson_id, 'in-progress' );
+
+ $service = new Comments_Based_Grading_Listing_Service();
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'user_id' => $user1,
+ 'post_id' => $lesson_id,
+ ]
+ )
+ );
+
+ /* Assert. */
+ $this->assertSame( 1, $result['total_count'], 'Expected one result for filtered user.' );
+ $this->assertSame( $user1, $result['items'][0]->user_id, 'Expected matching user ID.' );
+ }
+
+ public function testGetStatusCounts_ReturnsNull(): void {
+ /* Arrange. */
+ $service = new Comments_Based_Grading_Listing_Service();
+
+ /* Act & Assert. */
+ $this->assertNull( $service->get_status_counts(), 'Comments-based service should always return null.' );
+ }
+
+ public function testGetLessonProgressItems_WithOffsetBeyondTotal_CorrectsPagination(): void {
+ /* Arrange. */
+ $user_id = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ \Sensei_Utils::update_lesson_status( $user_id, $lesson_id, 'in-progress' );
+
+ $service = new Comments_Based_Grading_Listing_Service();
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'offset' => 100,
+ 'post_id' => $lesson_id,
+ ]
+ )
+ );
+
+ /* Assert. */
+ $this->assertSame( 1, $result['total_count'], 'Expected one total item.' );
+ $this->assertCount( 1, $result['items'], 'Expected offset correction to return items.' );
+ }
+}
diff --git a/tests/unit-tests/internal/services/test-class-comments-based-progress-aggregation-service.php b/tests/unit-tests/internal/services/test-class-comments-based-progress-aggregation-service.php
index 1e45d72d97..a05a67a866 100644
--- a/tests/unit-tests/internal/services/test-class-comments-based-progress-aggregation-service.php
+++ b/tests/unit-tests/internal/services/test-class-comments-based-progress-aggregation-service.php
@@ -375,70 +375,4 @@ public function testCountStatuses_LessonWithQuizButNoAnswers_IncludedByDefault()
/* Assert. */
$this->assertSame( 1, $result['complete'], 'Completed lesson with quiz but no answers should be included by default.' );
}
-
- public function testCountStatuses_LessonWithQuizButNoAnswers_ExcludedWhenFlagSet(): void {
- /* Arrange. */
- global $wpdb;
-
- $user_id = $this->sensei_factory->user->create();
- $course_id = $this->sensei_factory->course->create();
- $lesson_id = $this->sensei_factory->lesson->create(
- [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
- );
- $quiz_id = $this->sensei_factory->quiz->create(
- [
- 'post_parent' => $lesson_id,
- 'meta_input' => [ '_quiz_lesson' => $lesson_id ],
- ]
- );
- update_post_meta( $lesson_id, '_lesson_quiz', $quiz_id );
-
- \Sensei_Utils::update_lesson_status( $user_id, $lesson_id, 'complete' );
-
- $service = new Comments_Based_Progress_Aggregation_Service( $wpdb );
-
- /* Act. */
- $result = $service->count_statuses(
- [
- 'type' => 'lesson',
- 'exclude_unsubmitted_quiz_completions' => true,
- ]
- );
-
- /* Assert. */
- $this->assertArrayNotHasKey( 'complete', $result, 'Completed lesson with quiz but no answers should be excluded when flag is set.' );
- }
-
- public function testCountStatuses_InProgressWithQuizButNoAnswers_NotExcludedWhenFlagSet(): void {
- /* Arrange. */
- global $wpdb;
-
- $user_id = $this->sensei_factory->user->create();
- $course_id = $this->sensei_factory->course->create();
- $lesson_id = $this->sensei_factory->lesson->create(
- [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
- );
- $quiz_id = $this->sensei_factory->quiz->create(
- [
- 'post_parent' => $lesson_id,
- 'meta_input' => [ '_quiz_lesson' => $lesson_id ],
- ]
- );
- update_post_meta( $lesson_id, '_lesson_quiz', $quiz_id );
-
- \Sensei_Utils::update_lesson_status( $user_id, $lesson_id, 'in-progress' );
-
- $service = new Comments_Based_Progress_Aggregation_Service( $wpdb );
-
- /* Act. */
- $result = $service->count_statuses(
- [
- 'type' => 'lesson',
- 'exclude_unsubmitted_quiz_completions' => true,
- ]
- );
-
- /* Assert. */
- $this->assertSame( 1, $result['in-progress'], 'In-progress lesson should not be excluded even when flag is set.' );
- }
}
diff --git a/tests/unit-tests/internal/services/test-class-progress-query-service-factory.php b/tests/unit-tests/internal/services/test-class-progress-query-service-factory.php
index 8c4a5a9cd4..7bd107fce9 100644
--- a/tests/unit-tests/internal/services/test-class-progress-query-service-factory.php
+++ b/tests/unit-tests/internal/services/test-class-progress-query-service-factory.php
@@ -2,9 +2,11 @@
namespace SenseiTest\Internal\Services;
+use Sensei\Internal\Services\Comments_Based_Grading_Listing_Service;
use Sensei\Internal\Services\Comments_Based_Progress_Aggregation_Service;
use Sensei\Internal\Services\Comments_Based_Progress_Clauses_Service;
use Sensei\Internal\Services\Progress_Query_Service_Factory;
+use Sensei\Internal\Services\Tables_Based_Grading_Listing_Service;
use Sensei\Internal\Services\Tables_Based_Progress_Aggregation_Service;
use Sensei\Internal\Services\Tables_Based_Progress_Clauses_Service;
@@ -66,4 +68,30 @@ public function testCreateAggregationService_WhenHppsEnabled_ReturnsTablesBased(
/* Assert. */
$this->assertInstanceOf( Tables_Based_Progress_Aggregation_Service::class, $service );
}
+
+ public function testCreateGradingListingService_WhenHppsDisabled_ReturnsCommentsBased(): void {
+ /* Arrange. */
+ \Sensei()->settings->settings['experimental_progress_storage'] = false;
+ \Sensei()->settings->settings['experimental_progress_storage_repository'] = 'comments';
+ $factory = new Progress_Query_Service_Factory();
+
+ /* Act. */
+ $service = $factory->create_grading_listing_service();
+
+ /* Assert. */
+ $this->assertInstanceOf( Comments_Based_Grading_Listing_Service::class, $service );
+ }
+
+ public function testCreateGradingListingService_WhenHppsEnabled_ReturnsTablesBased(): void {
+ /* Arrange. */
+ \Sensei()->settings->settings['experimental_progress_storage'] = true;
+ \Sensei()->settings->settings['experimental_progress_storage_repository'] = 'custom_tables';
+ $factory = new Progress_Query_Service_Factory();
+
+ /* Act. */
+ $service = $factory->create_grading_listing_service();
+
+ /* Assert. */
+ $this->assertInstanceOf( Tables_Based_Grading_Listing_Service::class, $service );
+ }
}
diff --git a/tests/unit-tests/internal/services/test-class-tables-based-grading-listing-service.php b/tests/unit-tests/internal/services/test-class-tables-based-grading-listing-service.php
new file mode 100644
index 0000000000..6d167d2511
--- /dev/null
+++ b/tests/unit-tests/internal/services/test-class-tables-based-grading-listing-service.php
@@ -0,0 +1,540 @@
+sensei_factory = new \Sensei_Factory();
+ }
+
+ /**
+ * Insert a progress row directly into the HPPS progress table.
+ *
+ * @param int $post_id The post ID.
+ * @param int $user_id The user ID.
+ * @param string $type The progress type.
+ * @param string $status The progress status.
+ * @param int|null $parent_post_id The parent post ID.
+ */
+ private function insert_progress( int $post_id, int $user_id, string $type, string $status, ?int $parent_post_id = null ): void {
+ $wpdb = $GLOBALS['wpdb'];
+ $table = $wpdb->prefix . 'sensei_lms_progress';
+ $now = current_time( 'mysql' );
+ $data = [
+ 'post_id' => $post_id,
+ 'user_id' => $user_id,
+ 'type' => $type,
+ 'status' => $status,
+ 'started_at' => $now,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ];
+ $format = [ '%d', '%d', '%s', '%s', '%s', '%s', '%s' ];
+ if ( null !== $parent_post_id ) {
+ $data['parent_post_id'] = $parent_post_id;
+ $format[] = '%d';
+ }
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Test helper.
+ $wpdb->insert( $table, $data, $format );
+ }
+
+ /**
+ * Insert a quiz submission row.
+ *
+ * @param int $quiz_id The quiz post ID.
+ * @param int $user_id The user ID.
+ * @param int|null $final_grade The final grade.
+ */
+ private function insert_quiz_submission( int $quiz_id, int $user_id, ?int $final_grade = null ): void {
+ $wpdb = $GLOBALS['wpdb'];
+ $table = $wpdb->prefix . 'sensei_lms_quiz_submissions';
+ $now = current_time( 'mysql' );
+ $data = [
+ 'quiz_id' => $quiz_id,
+ 'user_id' => $user_id,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ];
+ $format = [ '%d', '%d', '%s', '%s' ];
+ if ( null !== $final_grade ) {
+ $data['final_grade'] = $final_grade;
+ $format[] = '%d';
+ }
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Test helper.
+ $wpdb->insert( $table, $data, $format );
+ }
+
+ /**
+ * Build default query args for get_lesson_progress_items.
+ *
+ * @param array $overrides Args to override.
+ * @return array
+ */
+ private function get_default_args( array $overrides = [] ): array {
+ return array_merge(
+ [
+ 'type' => 'sensei_lesson_status',
+ 'number' => 10,
+ 'offset' => 0,
+ 'orderby' => '',
+ 'order' => 'DESC',
+ 'status' => 'any',
+ ],
+ $overrides
+ );
+ }
+
+ public function testGetLessonProgressItems_WithLessonStatus_ReturnsGradingItems(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user_id = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $this->insert_progress( $lesson_id, $user_id, 'lesson', 'in-progress', $course_id );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args( [ 'post_id' => $lesson_id ] )
+ );
+
+ /* Assert. */
+ $this->assertSame( 1, $result['total_count'], 'Expected exactly one result.' );
+ $this->assertCount( 1, $result['items'], 'Expected exactly one item.' );
+ $this->assertInstanceOf( Grading_Item::class, $result['items'][0], 'Expected a Grading_Item instance.' );
+ $this->assertSame( 'in-progress', $result['items'][0]->status, 'Expected in-progress status.' );
+ $this->assertSame( $user_id, $result['items'][0]->user_id, 'Expected matching user ID.' );
+ $this->assertSame( $lesson_id, $result['items'][0]->lesson_id, 'Expected matching lesson ID.' );
+ $this->assertNull( $result['items'][0]->grade, 'Expected null grade for non-graded item.' );
+ }
+
+ public function testGetLessonProgressItems_WithQuizStatus_UsesCoalescedStatus(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user_id = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $quiz_id = $this->sensei_factory->quiz->create(
+ [
+ 'post_parent' => $lesson_id,
+ 'meta_input' => [ '_quiz_lesson' => $lesson_id ],
+ ]
+ );
+ update_post_meta( $lesson_id, '_lesson_quiz', $quiz_id );
+ $this->insert_progress( $lesson_id, $user_id, 'lesson', 'complete', $course_id );
+ $this->insert_progress( $quiz_id, $user_id, 'quiz', 'passed', $lesson_id );
+ $this->insert_quiz_submission( $quiz_id, $user_id, 90 );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args( [ 'post_id' => $lesson_id ] )
+ );
+
+ /* Assert. */
+ $this->assertSame( 'passed', $result['items'][0]->status, 'Quiz status should override lesson status.' );
+ $this->assertSame( 90.0, $result['items'][0]->grade, 'Expected grade from quiz submission.' );
+ }
+
+ public function testGetLessonProgressItems_WithPostInFilter_ReturnsMatchingLessons(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user_id = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson1 = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $lesson2 = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $lesson3 = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $this->insert_progress( $lesson1, $user_id, 'lesson', 'in-progress', $course_id );
+ $this->insert_progress( $lesson2, $user_id, 'lesson', 'complete', $course_id );
+ $this->insert_progress( $lesson3, $user_id, 'lesson', 'in-progress', $course_id );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args( [ 'post__in' => [ $lesson1, $lesson2 ] ] )
+ );
+
+ /* Assert. */
+ $this->assertSame( 2, $result['total_count'], 'Expected two items for the two filtered lessons.' );
+ $returned_lesson_ids = array_map(
+ function ( $item ) {
+ return $item->lesson_id;
+ },
+ $result['items']
+ );
+ $this->assertContains( $lesson1, $returned_lesson_ids, 'Expected lesson1 in results.' );
+ $this->assertContains( $lesson2, $returned_lesson_ids, 'Expected lesson2 in results.' );
+ $this->assertNotContains( $lesson3, $returned_lesson_ids, 'Expected lesson3 excluded from results.' );
+ }
+
+ public function testGetLessonProgressItems_WithOffsetBeyondTotal_CorrectsPagination(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user_id = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $this->insert_progress( $lesson_id, $user_id, 'lesson', 'in-progress', $course_id );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'offset' => 100,
+ 'post_id' => $lesson_id,
+ ]
+ )
+ );
+
+ /* Assert. */
+ $this->assertSame( 1, $result['total_count'], 'Expected one total item.' );
+ $this->assertCount( 1, $result['items'], 'Expected offset correction to return items.' );
+ }
+
+ public function testGetLessonProgressItems_WithMultipleStatusArray_FiltersCorrectly(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user1 = $this->sensei_factory->user->create();
+ $user2 = $this->sensei_factory->user->create();
+ $user3 = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $quiz_id = $this->sensei_factory->quiz->create(
+ [
+ 'post_parent' => $lesson_id,
+ 'meta_input' => [ '_quiz_lesson' => $lesson_id ],
+ ]
+ );
+ update_post_meta( $lesson_id, '_lesson_quiz', $quiz_id );
+
+ $this->insert_progress( $lesson_id, $user1, 'lesson', 'in-progress', $course_id );
+ // User2 completed and submitted the quiz so is not excluded.
+ $this->insert_progress( $lesson_id, $user2, 'lesson', 'complete', $course_id );
+ $this->insert_progress( $quiz_id, $user2, 'quiz', 'complete', $lesson_id );
+ $this->insert_quiz_submission( $quiz_id, $user2 );
+
+ $this->insert_progress( $lesson_id, $user3, 'lesson', 'complete', $course_id );
+ $this->insert_progress( $quiz_id, $user3, 'quiz', 'graded', $lesson_id );
+ $this->insert_quiz_submission( $quiz_id, $user3 );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'status' => [ 'in-progress', 'complete' ],
+ 'post_id' => $lesson_id,
+ ]
+ )
+ );
+
+ /* Assert. */
+ $this->assertSame( 2, $result['total_count'], 'Expected two items matching in-progress or complete.' );
+ $statuses = array_map(
+ function ( $item ) {
+ return $item->status;
+ },
+ $result['items']
+ );
+ $this->assertNotContains( 'graded', $statuses, 'Graded status should be excluded.' );
+ }
+
+ public function testGetLessonProgressItems_WithStatusFilter_FiltersResults(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user1 = $this->sensei_factory->user->create();
+ $user2 = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $this->insert_progress( $lesson_id, $user1, 'lesson', 'in-progress', $course_id );
+ $this->insert_progress( $lesson_id, $user2, 'lesson', 'complete', $course_id );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'status' => 'in-progress',
+ 'post_id' => $lesson_id,
+ ]
+ )
+ );
+
+ /* Assert. */
+ $this->assertSame( 1, $result['total_count'] );
+ $this->assertSame( 'in-progress', $result['items'][0]->status );
+ }
+
+ public function testGetLessonProgressItems_WithPagination_RespectsLimitAndOffset(): void {
+ /* Arrange. */
+ global $wpdb;
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ for ( $i = 0; $i < 3; $i++ ) {
+ $user_id = $this->sensei_factory->user->create();
+ $this->insert_progress( $lesson_id, $user_id, 'lesson', 'in-progress', $course_id );
+ }
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'number' => 2,
+ 'post_id' => $lesson_id,
+ ]
+ )
+ );
+
+ /* Assert. */
+ $this->assertSame( 3, $result['total_count'] );
+ $this->assertCount( 2, $result['items'] );
+ }
+
+ public function testGetLessonProgressItems_WithUserIdFilter_RestrictsToUser(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user1 = $this->sensei_factory->user->create();
+ $user2 = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $this->insert_progress( $lesson_id, $user1, 'lesson', 'in-progress', $course_id );
+ $this->insert_progress( $lesson_id, $user2, 'lesson', 'complete', $course_id );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args( [ 'user_id' => $user1 ] )
+ );
+
+ /* Assert. */
+ $this->assertSame( 1, $result['total_count'] );
+ $this->assertSame( $user1, $result['items'][0]->user_id );
+ }
+
+ public function testGetStatusCounts_AfterGetLessonProgressItems_ReturnsPerStatusCounts(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user1 = $this->sensei_factory->user->create();
+ $user2 = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $quiz_id = $this->sensei_factory->quiz->create(
+ [
+ 'post_parent' => $lesson_id,
+ 'meta_input' => [ '_quiz_lesson' => $lesson_id ],
+ ]
+ );
+ update_post_meta( $lesson_id, '_lesson_quiz', $quiz_id );
+
+ // User1: in-progress lesson (no quiz interaction).
+ $this->insert_progress( $lesson_id, $user1, 'lesson', 'in-progress', $course_id );
+
+ // User2: completed lesson with passed quiz.
+ $this->insert_progress( $lesson_id, $user2, 'lesson', 'complete', $course_id );
+ $this->insert_progress( $quiz_id, $user2, 'quiz', 'passed', $lesson_id );
+ $this->insert_quiz_submission( $quiz_id, $user2, 85 );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $service->get_lesson_progress_items(
+ $this->get_default_args( [ 'post_id' => $lesson_id ] )
+ );
+ $counts = $service->get_status_counts();
+
+ /* Assert. */
+ $this->assertIsArray( $counts, 'Expected an array of status counts.' );
+ $this->assertSame( 1, $counts['in-progress'] ?? 0, 'Expected 1 in-progress.' );
+ $this->assertSame( 1, $counts['passed'] ?? 0, 'Expected 1 passed (coalesced from quiz).' );
+ }
+
+ public function testGetStatusCounts_BeforeGetLessonProgressItems_ReturnsNull(): void {
+ /* Arrange. */
+ global $wpdb;
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act & Assert. */
+ $this->assertNull( $service->get_status_counts(), 'Expected null before any query.' );
+ }
+
+ public function testGetLessonProgressItems_WithExcludeUserLoginPrefixes_ExcludesMatchingUsers(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user1 = $this->sensei_factory->user->create( [ 'user_login' => 'real_student' ] );
+ $user2 = $this->sensei_factory->user->create( [ 'user_login' => 'preview_guest_123' ] );
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $this->insert_progress( $lesson_id, $user1, 'lesson', 'in-progress', $course_id );
+ $this->insert_progress( $lesson_id, $user2, 'lesson', 'in-progress', $course_id );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'post_id' => $lesson_id,
+ 'exclude_user_login_prefixes' => [ 'preview_' ],
+ ]
+ )
+ );
+
+ /* Assert. */
+ $this->assertSame( 1, $result['total_count'], 'Expected excluded user to be filtered out.' );
+ $this->assertSame( $user1, $result['items'][0]->user_id, 'Expected only the real student.' );
+ }
+
+ public function testGetLessonProgressItems_WithIncludeStatusesOverride_KeepsExcludedUserWithMatchingStatus(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user1 = $this->sensei_factory->user->create( [ 'user_login' => 'real_student' ] );
+ $user2 = $this->sensei_factory->user->create( [ 'user_login' => 'preview_guest_456' ] );
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $quiz_id = $this->sensei_factory->quiz->create(
+ [
+ 'post_parent' => $lesson_id,
+ 'meta_input' => [ '_quiz_lesson' => $lesson_id ],
+ ]
+ );
+ update_post_meta( $lesson_id, '_lesson_quiz', $quiz_id );
+
+ // Real student: in-progress.
+ $this->insert_progress( $lesson_id, $user1, 'lesson', 'in-progress', $course_id );
+
+ // Preview user: completed with ungraded quiz — should be kept because of override.
+ $this->insert_progress( $lesson_id, $user2, 'lesson', 'complete', $course_id );
+ $this->insert_progress( $quiz_id, $user2, 'quiz', 'ungraded', $lesson_id );
+ $this->insert_quiz_submission( $quiz_id, $user2 );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $result = $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'post_id' => $lesson_id,
+ 'exclude_user_login_prefixes' => [ 'preview_' ],
+ 'include_statuses_override' => [ 'ungraded' ],
+ ]
+ )
+ );
+
+ /* Assert. */
+ $this->assertSame( 2, $result['total_count'], 'Expected both users — preview user kept by status override.' );
+ $returned_user_ids = array_map(
+ function ( $item ) {
+ return $item->user_id;
+ },
+ $result['items']
+ );
+ $this->assertContains( $user1, $returned_user_ids, 'Expected real student in results.' );
+ $this->assertContains( $user2, $returned_user_ids, 'Expected preview user kept by ungraded override.' );
+ }
+
+ public function testGetStatusCounts_WithExcludeUserLoginPrefixes_ExcludesFromCounts(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user1 = $this->sensei_factory->user->create( [ 'user_login' => 'real_student2' ] );
+ $user2 = $this->sensei_factory->user->create( [ 'user_login' => 'preview_guest_789' ] );
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $this->insert_progress( $lesson_id, $user1, 'lesson', 'in-progress', $course_id );
+ $this->insert_progress( $lesson_id, $user2, 'lesson', 'in-progress', $course_id );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act. */
+ $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'post_id' => $lesson_id,
+ 'exclude_user_login_prefixes' => [ 'preview_' ],
+ ]
+ )
+ );
+ $counts = $service->get_status_counts();
+
+ /* Assert. */
+ $this->assertSame( 1, $counts['in-progress'] ?? 0, 'Expected cached counts to also exclude the preview user.' );
+ }
+
+ public function testGetStatusCounts_WithStatusFilter_ReturnsAllStatuses(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user1 = $this->sensei_factory->user->create();
+ $user2 = $this->sensei_factory->user->create();
+ $course_id = $this->sensei_factory->course->create();
+ $lesson_id = $this->sensei_factory->lesson->create(
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+ $this->insert_progress( $lesson_id, $user1, 'lesson', 'in-progress', $course_id );
+ $this->insert_progress( $lesson_id, $user2, 'lesson', 'complete', $course_id );
+
+ $service = new Tables_Based_Grading_Listing_Service( $wpdb );
+
+ /* Act -- query with status filter for in-progress only. */
+ $service->get_lesson_progress_items(
+ $this->get_default_args(
+ [
+ 'status' => 'in-progress',
+ 'post_id' => $lesson_id,
+ ]
+ )
+ );
+ $counts = $service->get_status_counts();
+
+ /* Assert -- counts should include ALL statuses, not just in-progress. */
+ $this->assertSame( 1, $counts['in-progress'] ?? 0, 'Expected 1 in-progress.' );
+ $this->assertSame( 1, $counts['complete'] ?? 0, 'Expected 1 complete even though status filter was in-progress.' );
+ }
+}
diff --git a/tests/unit-tests/internal/services/test-class-tables-based-progress-aggregation-service.php b/tests/unit-tests/internal/services/test-class-tables-based-progress-aggregation-service.php
index 5367160f10..08abaa26be 100644
--- a/tests/unit-tests/internal/services/test-class-tables-based-progress-aggregation-service.php
+++ b/tests/unit-tests/internal/services/test-class-tables-based-progress-aggregation-service.php
@@ -616,7 +616,7 @@ public function testCountStatuses_WithIncludeStatusesOverride_KeepsExcludedUsers
$this->assertSame( 1, $result['ungraded'], 'Excluded user with override status should still be counted.' );
}
- public function testCountStatuses_LessonWithQuizButNoSubmission_IncludedByDefault(): void {
+ public function testCountStatuses_LessonWithQuizButNoSubmission_UsesQuizStatus(): void {
/* Arrange. */
global $wpdb;
@@ -648,45 +648,8 @@ public function testCountStatuses_LessonWithQuizButNoSubmission_IncludedByDefaul
);
/* Assert. */
- $this->assertSame( 1, $result['complete'], 'Completed lesson with quiz but no submission should be included by default.' );
- $this->assertArrayNotHasKey( 'passed', $result, 'Quiz progress without submission should not count as graded.' );
- }
-
- public function testCountStatuses_LessonWithQuizButNoSubmission_ExcludedWhenFlagSet(): void {
- /* Arrange. */
- global $wpdb;
-
- $user_id = $this->sensei_factory->user->create();
- $course_id = $this->sensei_factory->course->create();
- $lesson_id = $this->sensei_factory->lesson->create(
- [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
- );
- $quiz_id = $this->sensei_factory->quiz->create(
- [
- 'post_parent' => $lesson_id,
- 'meta_input' => [ '_quiz_lesson' => $lesson_id ],
- ]
- );
- update_post_meta( $lesson_id, '_lesson_quiz', $quiz_id );
- update_post_meta( $lesson_id, '_quiz_has_questions', 1 );
-
- // Quiz progress exists but no quiz submission (e.g. migration phantom or lost data).
- $this->insert_progress( $lesson_id, $user_id, 'lesson', 'complete', $course_id );
- $this->insert_progress( $quiz_id, $user_id, 'quiz', 'passed', $lesson_id );
-
- $service = new Tables_Based_Progress_Aggregation_Service( $wpdb );
-
- /* Act. */
- $result = $service->count_statuses(
- [
- 'type' => 'lesson',
- 'exclude_unsubmitted_quiz_completions' => true,
- ]
- );
-
- /* Assert. */
- $this->assertArrayNotHasKey( 'complete', $result, 'Completed lesson with quiz but no submission should be excluded when flag is set.' );
- $this->assertArrayNotHasKey( 'passed', $result, 'Quiz progress without submission should not count as graded.' );
+ $this->assertSame( 1, $result['passed'], 'Quiz progress status should take precedence over lesson status.' );
+ $this->assertArrayNotHasKey( 'complete', $result, 'Lesson status should not appear when quiz progress exists.' );
}
public function testCountStatuses_CourseType_ReturnsStatusCounts(): void {
diff --git a/tests/unit-tests/test-class-grading.php b/tests/unit-tests/test-class-grading.php
index 08e438651c..b986c7f414 100644
--- a/tests/unit-tests/test-class-grading.php
+++ b/tests/unit-tests/test-class-grading.php
@@ -31,6 +31,64 @@ public function testClassInstance() {
$this->assertTrue( isset( Sensei()->grading ), 'Sensei Grading class is not loaded' );
}
+ /**
+ * Tests that prepare_items() applies sensei_count_statuses_args
+ * restrictions to listing rows for tables-based storage.
+ *
+ * @covers Sensei_Grading_Main::prepare_items
+ */
+ public function testPrepareItems_TablesBasedWithCountStatusesArgsRestriction_RestrictsListingRows(): void {
+ /* Arrange. */
+ global $wpdb;
+ $user_id = $this->factory->user->create();
+ $course_id = $this->factory->course->create();
+ $lesson_ids = $this->factory->lesson->create_many(
+ 2,
+ [ 'meta_input' => [ '_lesson_course' => $course_id ] ]
+ );
+
+ $table = $wpdb->prefix . 'sensei_lms_progress';
+ $now = current_time( 'mysql' );
+ foreach ( $lesson_ids as $lid ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Test helper.
+ $wpdb->insert(
+ $table,
+ [
+ 'post_id' => $lid,
+ 'user_id' => $user_id,
+ 'type' => 'lesson',
+ 'status' => 'in-progress',
+ 'started_at' => $now,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ],
+ [ '%d', '%d', '%s', '%s', '%s', '%s', '%s' ]
+ );
+ }
+
+ // Simulate teacher restriction: only allow the first lesson.
+ $restrict_filter = function ( $args ) use ( $lesson_ids ) {
+ $args['post__in'] = [ $lesson_ids[0] ];
+ return $args;
+ };
+ add_filter( 'sensei_count_statuses_args', $restrict_filter );
+
+ try {
+ $this->login_as_admin();
+ $service = new \Sensei\Internal\Services\Tables_Based_Grading_Listing_Service( $wpdb );
+ $grading_main = new Sensei_Grading_Main( [ 'view' => 'all' ], $service );
+
+ /* Act. */
+ $grading_main->prepare_items();
+
+ /* Assert. */
+ $this->assertCount( 1, $grading_main->items, 'Listing should only show items for the allowed lesson.' );
+ $this->assertSame( $lesson_ids[0], $grading_main->items[0]->lesson_id, 'Listing item should be for the restricted lesson.' );
+ } finally {
+ remove_filter( 'sensei_count_statuses_args', $restrict_filter );
+ }
+ }
+
/**
* Tests that the ungraded quiz count is not displayed in the Grading menu.
*