From 3ac99903e2bb42f69d5bf95885dd1831145797a5 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Thu, 1 Oct 2026 15:31:39 +0600 Subject: [PATCH 01/15] `/models` files security audit and fixes --- {.agent => .agents}/skills/core-development-skill/SKILL.md | 5 +++++ {.agent => .agents}/skills/wpcs-security-skill/SKILL.md | 5 +++++ 2 files changed, 10 insertions(+) rename {.agent => .agents}/skills/core-development-skill/SKILL.md (99%) rename {.agent => .agents}/skills/wpcs-security-skill/SKILL.md (99%) diff --git a/.agent/skills/core-development-skill/SKILL.md b/.agents/skills/core-development-skill/SKILL.md similarity index 99% rename from .agent/skills/core-development-skill/SKILL.md rename to .agents/skills/core-development-skill/SKILL.md index b5f71641be..1421698b09 100644 --- a/.agent/skills/core-development-skill/SKILL.md +++ b/.agents/skills/core-development-skill/SKILL.md @@ -1,3 +1,8 @@ +--- +name: core-development-skill +description: This skill governs all development, bug fixing, and feature work inside the **Tutor LMS** plugin codebase (`github.com/themeum/tutor`). It covers project structure, namespace conventions, coding patterns, PHPDoc standards, available helpers/utilities, security rules, and AI agent instructions. +--- + # Tutor LMS — Core Development Skill ## Overview diff --git a/.agent/skills/wpcs-security-skill/SKILL.md b/.agents/skills/wpcs-security-skill/SKILL.md similarity index 99% rename from .agent/skills/wpcs-security-skill/SKILL.md rename to .agents/skills/wpcs-security-skill/SKILL.md index 51ebe7845f..4c98fa5a22 100644 --- a/.agent/skills/wpcs-security-skill/SKILL.md +++ b/.agents/skills/wpcs-security-skill/SKILL.md @@ -1,3 +1,8 @@ +--- +name: wpcs-security-skill +description: Enforces strict WordPress coding standards (WPCS) and security best practices for PHP, JS, CSS, and HTML. +--- + # WordPress Coding Standards & Security Skill ## Overview From 7704e7d08142cf77ee80099a56dd7d22b2720f9e Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Thu, 1 Oct 2026 15:35:04 +0600 Subject: [PATCH 02/15] Fix: typcast to int before placing it in string interpolation for query --- models/UserModel.php | 1 + 1 file changed, 1 insertion(+) diff --git a/models/UserModel.php b/models/UserModel.php index 678c6a09de..71f2f356b8 100644 --- a/models/UserModel.php +++ b/models/UserModel.php @@ -60,6 +60,7 @@ public function get_users_list( array $args ) { public function get_unenrolled_users( $object_id, $search_clause = array(), $limit = 10, $offset = 0 ) { global $wpdb; + $object_id = absint( $object_id ); $primary_table = "{$wpdb->users} AS u"; $joining_tables = array( array( From 0b0936c32db3c8914c0a465ace9ce2a066c6e70b Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Thu, 1 Oct 2026 17:05:59 +0600 Subject: [PATCH 03/15] Security Fixes --- models/QuizModel.php | 64 +++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/models/QuizModel.php b/models/QuizModel.php index 0167b3b822..190db3882f 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -10,6 +10,10 @@ namespace Tutor\Models; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use Tutor\Cache\TutorCache; use Tutor\Components\Badge; use TUTOR\Course_List; @@ -974,23 +978,27 @@ public static function get_answers_by_quiz_question( $question_id, $rand = false public static function get_quiz_answers_by_attempt_id( $attempt_id, $add_index = false ) { global $wpdb; - $ids = is_array( $attempt_id ) ? $attempt_id : array( $attempt_id ); - $ids_in = implode( ',', $ids ); + $ids = array_filter( array_map( 'absint', (array) $attempt_id ) ); - if ( empty( $ids_in ) ) { + if ( empty( $ids ) ) { // Prevent empty. return array(); } + $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) ); + //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $results = $wpdb->get_results( - "SELECT answers.*, - question.* - FROM {$wpdb->prefix}tutor_quiz_attempt_answers answers - LEFT JOIN {$wpdb->prefix}tutor_quiz_questions question - ON answers.question_id = question.question_id - WHERE answers.quiz_attempt_id IN ({$ids_in}) - ORDER BY attempt_answer_id ASC;" + $wpdb->prepare( + "SELECT answers.*, + question.* + FROM {$wpdb->prefix}tutor_quiz_attempt_answers answers + LEFT JOIN {$wpdb->prefix}tutor_quiz_questions question + ON answers.question_id = question.question_id + WHERE answers.quiz_attempt_id IN ({$placeholders}) + ORDER BY attempt_answer_id ASC;", + $ids + ) ); //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -1300,7 +1308,8 @@ function ( $id ) { public static function get_quiz_attempt_timing( $attempt_data ) { $attempt_duration = ''; $attempt_duration_taken = ''; - $attempt_info = @unserialize( $attempt_data->attempt_info ); + $raw_info = $attempt_data->attempt_info ?? ''; + $attempt_info = is_string( $raw_info ) ? unserialize( $raw_info, array( 'allowed_classes' => false ) ) : array(); if ( is_array( $attempt_info ) ) { // Allowed duration. if ( isset( $attempt_info['time_limit'] ) ) { @@ -1419,38 +1428,21 @@ public function get_first_or_last_attempt( int $quiz_id, int $user_id = 0, strin * * @since 2.2.0 * - * @param int|array $course_id Course id or array of course ids. + * @param int $course_id Course id. * * @return int */ public static function get_quiz_count_by_course( $course_id ) { - global $wpdb; - - $and_clause = is_array( $course_id ) && count( $course_id ) ? ' AND post_parent IN (' . QueryHelper::prepare_in_clause( $course_id ) . ')' : "AND post_parent = $course_id"; + $topic_ids = tutor_utils()->get_topics( absint( $course_id ), array( 'fields' => 'ids' ) )->get_posts(); - //phpcs:disable - $count = $wpdb->get_var( - $wpdb->prepare( - "SELECT - COUNT(ID) - FROM {$wpdb->posts} - WHERE post_parent IN - (SELECT - ID - FROM {$wpdb->posts} - WHERE post_type = %s - {$and_clause} - AND post_status = %s - ) - AND post_type = %s - AND post_status = %s", - 'topics', - 'publish', - 'tutor_quiz', - 'publish' + $count = QueryHelper::get_count( + 'posts', + array( + 'post_parent' => array( 'IN', $topic_ids ), + 'post_type' => tutor()->quiz_post_type, + 'post_status' => 'publish', ) ); - //phpcs:enable return $count ? $count : 0; } From 1813746ca9ad146fbf498a7412fc52f60ad7bc94 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Fri, 2 Oct 2026 15:45:04 +0600 Subject: [PATCH 04/15] Fix: Query hardened for the several model class --- models/CourseModel.php | 29 ++++++++++++++--------------- models/EnrollmentModel.php | 14 +++++++++++++- models/OrderModel.php | 13 +++++++------ 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/models/CourseModel.php b/models/CourseModel.php index f01d28814e..9bd5a3b101 100644 --- a/models/CourseModel.php +++ b/models/CourseModel.php @@ -10,6 +10,10 @@ namespace Tutor\Models; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use Tutor\Helpers\DateTimeHelper; use TUTOR\Icon; use TUTOR\Course; @@ -164,22 +168,16 @@ public static function get_post_types( $post ) { public static function get_courses( $excludes = array(), $post_status = array( 'publish' ) ) { global $wpdb; - $excludes = (array) $excludes; + $excludes = array_filter( array_map( 'absint', (array) $excludes ) ); $exclude_query = ''; + $post_status = array_intersect( $post_status, self::get_status_list() ); if ( count( $excludes ) ) { - $exclude_query = implode( "','", $excludes ); + $exclude_placeholders = implode( ', ', array_fill( 0, count( $excludes ), '%d' ) ); + $exclude_query = "AND ID NOT IN({$exclude_placeholders})"; } - $post_status = array_map( - function ( $element ) { - return "'" . $element . "'"; - }, - $post_status - ); - - $post_status = implode( ',', $post_status ); - $course_post_type = tutor()->course_post_type; + $post_status = QueryHelper::prepare_in_clause( $post_status ); //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $query = $wpdb->get_results( @@ -192,10 +190,11 @@ function ( $element ) { menu_order FROM {$wpdb->posts} WHERE post_status IN ({$post_status}) - AND ID NOT IN('$exclude_query') + {$exclude_query} AND post_type = %s; ", - $course_post_type + self::POST_TYPE, + $excludes ) ); //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -749,7 +748,7 @@ public static function get_paid_courses( array $args = array() ) { ); // Check if the current user is an admin. - if ( ! current_user_can( 'administrator' ) ) { + if ( ! current_user_can( 'manage_options' ) ) { $args['author'] = $current_user->ID; } @@ -1186,7 +1185,7 @@ public static function get_course_dropdown_options() { ), ); - $courses = current_user_can( 'administrator' ) ? self::get_courses() : self::get_courses_by_instructor(); + $courses = current_user_can( 'manage_options' ) ? self::get_courses() : self::get_courses_by_instructor(); if ( ! empty( $courses ) ) { foreach ( $courses as $course ) { $course_options[] = array( diff --git a/models/EnrollmentModel.php b/models/EnrollmentModel.php index bcb89a8286..127b07e17d 100644 --- a/models/EnrollmentModel.php +++ b/models/EnrollmentModel.php @@ -10,6 +10,10 @@ namespace Tutor\Models; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use Tutor\Cache\TutorCache; use TUTOR\Course; use Tutor\Helpers\QueryHelper; @@ -281,9 +285,17 @@ public static function get_enrolment_by_enrol_id( $enrol_id = 0 ) { */ public static function get_enrolled_data( $user_id = 0, $course_id = 0, $status = self::STATUS_COMPLETED ) { global $wpdb; + $user_id = absint( $user_id ); + $course_id = absint( $course_id ); + + if ( ! in_array( $status, array( self::STATUS_COMPLETED, self::STATUS_CANCEL, self::STATUS_PENDING ), true ) ) { + return null; + } + $status_clause = $status ? $wpdb->prepare( 'AND post_status = %s ', $status ) : ''; + // If course ID provided, it will return single row data. - if ( $course_id > 0 ) { + if ( $course_id > 0 && tutor()->course_post_type === get_post_type( $course_id ) ) { return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->posts} diff --git a/models/OrderModel.php b/models/OrderModel.php index c617d7f149..a4ada20145 100644 --- a/models/OrderModel.php +++ b/models/OrderModel.php @@ -10,12 +10,13 @@ namespace Tutor\Models; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + use DateTime; use Exception; use Tutor\Cache\TutorCache; -use Tutor\Components\Badge; -use Tutor\Components\Button; -use Tutor\Components\Constants\Variant; use TUTOR\Earnings; use Tutor\Ecommerce\BillingController; use Tutor\Ecommerce\CheckoutController; @@ -25,7 +26,6 @@ use Tutor\Helpers\DateTimeHelper; use Tutor\Helpers\QueryHelper; use TUTOR\User; -use TutorPro\Ecommerce\Invoice; /** * OrderModel Class @@ -421,6 +421,7 @@ public function create_order( array $data ) { } } } catch ( \Throwable $th ) { + $wpdb->query( 'ROLLBACK' ); throw new \Exception( $th->getMessage() ); } } @@ -1393,9 +1394,9 @@ public function get_refunds_by_user( int $user_id, string $period = '', $start_d ); //phpcs:enable } else { - $earning_table = $wpdb->tutor_earnings; + $earning_table = QueryHelper::prepare_table_name( 'earnings' ); if ( $user_id ) { - $user_clause = "AND {$user_id} = (SELECT user_id FROM {$earning_table} WHERE user_id = {$user_id} LIMIT 1)"; + $user_clause = $wpdb->prepare( "AND %d = (SELECT user_id FROM {$earning_table} WHERE user_id = %d LIMIT 1)", $user_id, $user_id ); //phpcs:ignore } //phpcs:disable From 9e185bb70b89b0e8a046d4cd17c8cb1001b051dd Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Tue, 6 Oct 2026 15:30:17 +0600 Subject: [PATCH 05/15] updated abspath check to use shorthand syntax --- models/CourseModel.php | 4 +--- models/EnrollmentModel.php | 4 +--- models/OrderModel.php | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/models/CourseModel.php b/models/CourseModel.php index 9bd5a3b101..51733c3b0b 100644 --- a/models/CourseModel.php +++ b/models/CourseModel.php @@ -10,9 +10,7 @@ namespace Tutor\Models; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; use Tutor\Helpers\DateTimeHelper; use TUTOR\Icon; diff --git a/models/EnrollmentModel.php b/models/EnrollmentModel.php index 127b07e17d..0b1df2daf1 100644 --- a/models/EnrollmentModel.php +++ b/models/EnrollmentModel.php @@ -10,9 +10,7 @@ namespace Tutor\Models; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; use Tutor\Cache\TutorCache; use TUTOR\Course; diff --git a/models/OrderModel.php b/models/OrderModel.php index a4ada20145..800d0477d3 100644 --- a/models/OrderModel.php +++ b/models/OrderModel.php @@ -10,9 +10,7 @@ namespace Tutor\Models; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; use DateTime; use Exception; From a9c9e69ad6a2af2e6332ed1ddce2dd44a4b00f10 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Tue, 6 Oct 2026 15:50:05 +0600 Subject: [PATCH 06/15] updated abspath check to use shorthand syntax --- models/QuizModel.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/models/QuizModel.php b/models/QuizModel.php index 190db3882f..5185d01742 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -10,9 +10,7 @@ namespace Tutor\Models; -if ( ! defined( 'ABSPATH' ) ) { - exit; -} +defined( 'ABSPATH' ) || exit; use Tutor\Cache\TutorCache; use Tutor\Components\Badge; From 9796bf97a6b0a6e9ad58fe9fde61048f599d6271 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Thu, 8 Oct 2026 11:28:58 +0600 Subject: [PATCH 07/15] added given review changes --- models/CourseModel.php | 9 ++++++--- models/EnrollmentModel.php | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/models/CourseModel.php b/models/CourseModel.php index 51733c3b0b..3bb1b6c91c 100644 --- a/models/CourseModel.php +++ b/models/CourseModel.php @@ -170,8 +170,12 @@ public static function get_courses( $excludes = array(), $post_status = array( ' $exclude_query = ''; $post_status = array_intersect( $post_status, self::get_status_list() ); + if ( empty( $post_status ) ) { + $post_status = array( 'publish' ); + } + if ( count( $excludes ) ) { - $exclude_placeholders = implode( ', ', array_fill( 0, count( $excludes ), '%d' ) ); + $exclude_placeholders = QueryHelper::prepare_in_clause( $excludes ); $exclude_query = "AND ID NOT IN({$exclude_placeholders})"; } @@ -191,8 +195,7 @@ public static function get_courses( $excludes = array(), $post_status = array( ' {$exclude_query} AND post_type = %s; ", - self::POST_TYPE, - $excludes + self::POST_TYPE ) ); //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared diff --git a/models/EnrollmentModel.php b/models/EnrollmentModel.php index 0b1df2daf1..1ad27328db 100644 --- a/models/EnrollmentModel.php +++ b/models/EnrollmentModel.php @@ -286,7 +286,7 @@ public static function get_enrolled_data( $user_id = 0, $course_id = 0, $status $user_id = absint( $user_id ); $course_id = absint( $course_id ); - if ( ! in_array( $status, array( self::STATUS_COMPLETED, self::STATUS_CANCEL, self::STATUS_PENDING ), true ) ) { + if ( ! empty( $status ) && ! in_array( $status, array( self::STATUS_COMPLETED, self::STATUS_CANCEL, self::STATUS_PENDING ), true ) ) { return null; } From 38f791fa6aad69a511594bdc2c6b47dbb65584f4 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Wed, 14 Oct 2026 12:21:50 +0600 Subject: [PATCH 08/15] added requested changes --- models/QuizModel.php | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/models/QuizModel.php b/models/QuizModel.php index 7e8922fe8a..79ea5c4f7a 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -976,7 +976,7 @@ public static function get_answers_by_quiz_question( $question_id, $rand = false public static function get_quiz_answers_by_attempt_id( $attempt_id, $add_index = false ) { global $wpdb; - $ids = array_filter( array_map( 'absint', (array) $attempt_id ) ); + $ids = array_values( array_filter( array_map( 'absint', (array) $attempt_id ) ) ); if ( empty( $ids ) ) { // Prevent empty. @@ -1377,7 +1377,7 @@ public static function get_quiz_attempt_timing( $attempt_data ) { $attempt_duration = ''; $attempt_duration_taken = ''; $raw_info = $attempt_data->attempt_info ?? ''; - $attempt_info = is_string( $raw_info ) ? unserialize( $raw_info, array( 'allowed_classes' => false ) ) : array(); + $attempt_info = is_string( $raw_info ) && is_serialized( $raw_info ) ? unserialize( $raw_info, array( 'allowed_classes' => false ) ) : array(); if ( is_array( $attempt_info ) ) { // Allowed duration. if ( isset( $attempt_info['time_limit'] ) ) { @@ -1496,22 +1496,32 @@ public function get_first_or_last_attempt( int $quiz_id, int $user_id = 0, strin * * @since 2.2.0 * - * @param int $course_id Course id. + * @param int|array $course_id Course id or array of course ids. * * @return int */ public static function get_quiz_count_by_course( $course_id ) { - $topic_ids = tutor_utils()->get_topics( absint( $course_id ), array( 'fields' => 'ids' ) )->get_posts(); + global $wpdb; + $course_ids = array_values( array_filter( array_map( 'absint', (array) $course_id ) ) ); + if ( empty( $course_ids ) ) { + return 0; + } - $count = QueryHelper::get_count( - 'posts', - array( - 'post_parent' => array( 'IN', $topic_ids ), - 'post_type' => tutor()->quiz_post_type, - 'post_status' => 'publish', + $placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) ); + + //phpcs:disable + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(ID) FROM {$wpdb->posts} + WHERE post_parent IN ( + SELECT ID FROM {$wpdb->posts} + WHERE post_type = 'topics' AND post_parent IN ({$placeholders}) AND post_status = 'publish' + ) + AND post_type = %s AND post_status = 'publish'", + array( ...$course_ids, tutor()->quiz_post_type ) ) ); - return $count ? $count : 0; + //phpcs:enable } /** From 9b62dbdd0a02adde3f92845a503157e73158dd59 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Wed, 14 Oct 2026 15:19:06 +0600 Subject: [PATCH 09/15] added requested changes --- models/QuizModel.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/models/QuizModel.php b/models/QuizModel.php index 79ea5c4f7a..f1d3beb1a4 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -976,7 +976,7 @@ public static function get_answers_by_quiz_question( $question_id, $rand = false public static function get_quiz_answers_by_attempt_id( $attempt_id, $add_index = false ) { global $wpdb; - $ids = array_values( array_filter( array_map( 'absint', (array) $attempt_id ) ) ); + $ids = array_values( array_filter( array_map( 'intval', (array) $attempt_id ) ) ); if ( empty( $ids ) ) { // Prevent empty. @@ -1376,7 +1376,7 @@ function ( $id ) { public static function get_quiz_attempt_timing( $attempt_data ) { $attempt_duration = ''; $attempt_duration_taken = ''; - $raw_info = $attempt_data->attempt_info ?? ''; + $raw_info = trim( (string) $attempt_data->attempt_info ?? '' ); $attempt_info = is_string( $raw_info ) && is_serialized( $raw_info ) ? unserialize( $raw_info, array( 'allowed_classes' => false ) ) : array(); if ( is_array( $attempt_info ) ) { // Allowed duration. @@ -1502,7 +1502,7 @@ public function get_first_or_last_attempt( int $quiz_id, int $user_id = 0, strin */ public static function get_quiz_count_by_course( $course_id ) { global $wpdb; - $course_ids = array_values( array_filter( array_map( 'absint', (array) $course_id ) ) ); + $course_ids = array_values( array_filter( array_map( 'intval', (array) $course_id ) ) ); if ( empty( $course_ids ) ) { return 0; } From 1b0db453f335797d007a62aefdf78cef4d866670 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Wed, 14 Oct 2026 16:03:59 +0600 Subject: [PATCH 10/15] updated array_filter for negative values and string values --- models/CourseModel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/CourseModel.php b/models/CourseModel.php index 3bb1b6c91c..8cf6c9b9a7 100644 --- a/models/CourseModel.php +++ b/models/CourseModel.php @@ -166,7 +166,7 @@ public static function get_post_types( $post ) { public static function get_courses( $excludes = array(), $post_status = array( 'publish' ) ) { global $wpdb; - $excludes = array_filter( array_map( 'absint', (array) $excludes ) ); + $excludes = array_values( array_filter( array_map( 'intval', (array) $excludes ), fn( $id ) => $id > 0 ) ); $exclude_query = ''; $post_status = array_intersect( $post_status, self::get_status_list() ); From f10970f9f1438b9a61a340c616f2b97daf83c444 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Wed, 14 Oct 2026 16:06:11 +0600 Subject: [PATCH 11/15] added negative value check on filter --- models/QuizModel.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/QuizModel.php b/models/QuizModel.php index f1d3beb1a4..dc8668a5fa 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -976,7 +976,7 @@ public static function get_answers_by_quiz_question( $question_id, $rand = false public static function get_quiz_answers_by_attempt_id( $attempt_id, $add_index = false ) { global $wpdb; - $ids = array_values( array_filter( array_map( 'intval', (array) $attempt_id ) ) ); + $ids = array_values( array_filter( array_map( 'intval', (array) $attempt_id ), fn( $id ) => $id > 0 ) ); if ( empty( $ids ) ) { // Prevent empty. @@ -1502,7 +1502,7 @@ public function get_first_or_last_attempt( int $quiz_id, int $user_id = 0, strin */ public static function get_quiz_count_by_course( $course_id ) { global $wpdb; - $course_ids = array_values( array_filter( array_map( 'intval', (array) $course_id ) ) ); + $course_ids = array_values( array_filter( array_map( 'intval', (array) $course_id ), fn( $id ) => $id > 0 ) ); if ( empty( $course_ids ) ) { return 0; } From 75900836c1d2e5a4a2e1427c10842d52295c319f Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Fri, 23 Oct 2026 11:53:28 +0600 Subject: [PATCH 12/15] updated to use fluent query and added test cases --- models/CourseModel.php | 39 +++++++------------------------ tests/phpunit/CourseModelTest.php | 36 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/models/CourseModel.php b/models/CourseModel.php index 10fd52f15e..8670ee5bce 100644 --- a/models/CourseModel.php +++ b/models/CourseModel.php @@ -18,6 +18,7 @@ use TUTOR\Lesson; use Tutor\Ecommerce\Tax; use InvalidArgumentException; +use Tutor\Core\DB; use Tutor\Helpers\QueryHelper; use TUTOR\User; use TUTOR_ASSIGNMENTS\Assignments; @@ -164,43 +165,21 @@ public static function get_post_types( $post ) { * @return array|null|object */ public static function get_courses( $excludes = array(), $post_status = array( 'publish' ) ) { - global $wpdb; - $excludes = array_values( array_filter( array_map( 'intval', (array) $excludes ), fn( $id ) => $id > 0 ) ); - $exclude_query = ''; - $post_status = array_intersect( $post_status, self::get_status_list() ); + $excludes = array_values( array_filter( array_map( 'intval', (array) $excludes ), fn( $id ) => $id > 0 ) ); + $post_status = array_intersect( $post_status, self::get_status_list() ); if ( empty( $post_status ) ) { $post_status = array( 'publish' ); } - if ( count( $excludes ) ) { - $exclude_placeholders = QueryHelper::prepare_in_clause( $excludes ); - $exclude_query = "AND ID NOT IN({$exclude_placeholders})"; - } - - $post_status = QueryHelper::prepare_in_clause( $post_status ); - - //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $query = $wpdb->get_results( - $wpdb->prepare( - "SELECT ID, - post_author, - post_title, - post_name, - post_status, - menu_order - FROM {$wpdb->posts} - WHERE post_status IN ({$post_status}) - {$exclude_query} - AND post_type = %s; - ", - self::POST_TYPE - ) - ); - //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $query = DB::table( 'posts' ) + ->select( 'ID', 'post_author', 'post_title', 'post_name', 'post_status', 'menu_order' ) + ->where_in( 'post_status', $post_status ) + ->where( 'post_type', self::POST_TYPE ) + ->where_not_in( 'ID', $excludes ); - return $query; + return $query->get(); } /** diff --git a/tests/phpunit/CourseModelTest.php b/tests/phpunit/CourseModelTest.php index e48462e95a..4d5fdeb110 100644 --- a/tests/phpunit/CourseModelTest.php +++ b/tests/phpunit/CourseModelTest.php @@ -1,5 +1,6 @@ assertTrue( $res ); + } + + /** + * Get Courses test case added. + * + * @return void + */ + public function test_get_courses() { + + $course1 = self::factory()->post->create( + array( + 'post_type' => tutor()->course_post_type, + 'post_title' => 'Test Course', + 'post_status' => 'publish', + ) + ); + $course2 = self::factory()->post->create( + array( + 'post_type' => tutor()->course_post_type, + 'post_title' => 'Test Course 2', + 'post_status' => 'draft', + ) + ); + + $courses = CourseModel::get_courses(); + + $this->assertEquals( 1, count( $courses ) ); + + $courses = CourseModel::get_courses( array(), array( 'publish', 'draft' ) ); + + $this->assertEquals( 2, count( $courses ) ); + + $courses = CourseModel::get_courses( array( $course2 ) ); + $this->assertEquals( 1, count( $courses ) ); + $this->assertEquals( $course1, $courses[0]->ID ); } } From e8e0588dd0dc488dbd242a890c7c8c408d4ae829 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Fri, 23 Oct 2026 12:53:09 +0600 Subject: [PATCH 13/15] updated quiz model queries and added unit test --- models/QuizModel.php | 51 +++++++++++----------------- tests/phpunit/QuizModelTest.php | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 tests/phpunit/QuizModelTest.php diff --git a/models/QuizModel.php b/models/QuizModel.php index 758e32a333..3e976c9072 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -14,6 +14,7 @@ use Tutor\Cache\TutorCache; use Tutor\Components\Badge; +use Tutor\Core\DB; use TUTOR\Course_List; use Tutor\Helpers\DateTimeHelper; use Tutor\Helpers\QueryHelper; @@ -974,7 +975,6 @@ public static function get_answers_by_quiz_question( $question_id, $rand = false * @return array|null|object */ public static function get_quiz_answers_by_attempt_id( $attempt_id, $add_index = false ) { - global $wpdb; $ids = array_values( array_filter( array_map( 'intval', (array) $attempt_id ), fn( $id ) => $id > 0 ) ); @@ -983,22 +983,13 @@ public static function get_quiz_answers_by_attempt_id( $attempt_id, $add_index = return array(); } - $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) ); - - //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $results = $wpdb->get_results( - $wpdb->prepare( - "SELECT answers.*, - question.* - FROM {$wpdb->prefix}tutor_quiz_attempt_answers answers - LEFT JOIN {$wpdb->prefix}tutor_quiz_questions question - ON answers.question_id = question.question_id - WHERE answers.quiz_attempt_id IN ({$placeholders}) - ORDER BY attempt_answer_id ASC;", - $ids - ) - ); - //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $results = DB::table( 'tutor_quiz_attempt_answers' ) + ->as( 'answers' ) + ->select( 'answers.*', 'question.*' ) + ->join( 'tutor_quiz_questions as question', 'answers.question_id', '=', 'question.question_id' ) + ->where_in( 'answers.quiz_attempt_id', $ids ) + ->order_by( 'answers.attempt_answer_id' ) + ->get(); if ( $add_index ) { $new_array = array(); @@ -1501,27 +1492,23 @@ public function get_first_or_last_attempt( int $quiz_id, int $user_id = 0, strin * @return int */ public static function get_quiz_count_by_course( $course_id ) { - global $wpdb; $course_ids = array_values( array_filter( array_map( 'intval', (array) $course_id ), fn( $id ) => $id > 0 ) ); if ( empty( $course_ids ) ) { return 0; } - $placeholders = implode( ', ', array_fill( 0, count( $course_ids ), '%d' ) ); + $topic_subquery = DB::table( 'posts' ) + ->select( 'ID' ) + ->where( 'post_type', tutor()->topics_post_type ) + ->where_in( 'post_parent', $course_ids ) + ->where( 'post_status', 'publish' ) + ->to_sql(); - //phpcs:disable - return (int) $wpdb->get_var( - $wpdb->prepare( - "SELECT COUNT(ID) FROM {$wpdb->posts} - WHERE post_parent IN ( - SELECT ID FROM {$wpdb->posts} - WHERE post_type = 'topics' AND post_parent IN ({$placeholders}) AND post_status = 'publish' - ) - AND post_type = %s AND post_status = 'publish'", - array( ...$course_ids, tutor()->quiz_post_type ) - ) - ); - //phpcs:enable + return DB::table( 'posts' ) + ->where_raw( "post_parent IN ({$topic_subquery})" ) + ->where( 'post_type', tutor()->quiz_post_type ) + ->where( 'post_status', 'publish' ) + ->count( 'ID' ); } /** diff --git a/tests/phpunit/QuizModelTest.php b/tests/phpunit/QuizModelTest.php new file mode 100644 index 0000000000..b34a415ed9 --- /dev/null +++ b/tests/phpunit/QuizModelTest.php @@ -0,0 +1,59 @@ +post->create( + array( + 'post_type' => tutor()->course_post_type, + 'post_status' => 'publish', + 'post_title' => 'Test Course', + ) + ); + + $topic_id = self::factory()->post->create( + array( + 'post_type' => tutor()->topics_post_type, + 'post_status' => 'publish', + 'post_parent' => $course_id, + 'post_title' => 'Topic', + 'comment_status' => 'closed', + ) + ); + + $quiz_id = self::factory()->post->create( + array( + 'post_type' => tutor()->quiz_post_type, + 'post_status' => 'publish', + 'post_parent' => $topic_id, + 'post_title' => 'Quiz', + ) + ); + + $count = QuizModel::get_quiz_count_by_course( $course_id ); + + $this->assertEquals( 1, $count ); + } +} From 8991354c571419a3c4fa6bfaac0ab2ad1f22ec65 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Fri, 23 Oct 2026 13:38:06 +0600 Subject: [PATCH 14/15] added fluent query in --- models/EnrollmentModel.php | 40 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/models/EnrollmentModel.php b/models/EnrollmentModel.php index 1ad27328db..d8b44ab068 100644 --- a/models/EnrollmentModel.php +++ b/models/EnrollmentModel.php @@ -13,6 +13,7 @@ defined( 'ABSPATH' ) || exit; use Tutor\Cache\TutorCache; +use Tutor\Core\DB; use TUTOR\Course; use Tutor\Helpers\QueryHelper; use TUTOR\User; @@ -282,7 +283,6 @@ public static function get_enrolment_by_enrol_id( $enrol_id = 0 ) { * @return object|mixed */ public static function get_enrolled_data( $user_id = 0, $course_id = 0, $status = self::STATUS_COMPLETED ) { - global $wpdb; $user_id = absint( $user_id ); $course_id = absint( $course_id ); @@ -290,34 +290,20 @@ public static function get_enrolled_data( $user_id = 0, $course_id = 0, $status return null; } - $status_clause = $status ? $wpdb->prepare( 'AND post_status = %s ', $status ) : ''; - // If course ID provided, it will return single row data. - if ( $course_id > 0 && tutor()->course_post_type === get_post_type( $course_id ) ) { - return $wpdb->get_row( - $wpdb->prepare( - "SELECT * FROM {$wpdb->posts} - WHERE post_type = %s - AND post_parent = %d - {$status_clause} - AND post_author = %d;", - self::POST_TYPE, - $course_id, - $user_id - ) - ); + if ( $course_id > 0 && in_array( get_post_type( $course_id ), array( tutor()->course_post_type, tutor()->bundle_post_type ), true ) ) { + return DB::table( 'posts' ) + ->where( 'post_type', self::POST_TYPE ) + ->where( 'post_parent', $course_id ) + ->where( 'post_author', $user_id ) + ->where( 'post_status', $status ?? self::STATUS_COMPLETED ) + ->first(); } else { - // Return all enrolled data by user ID. - return $wpdb->get_results( - $wpdb->prepare( - "SELECT * FROM {$wpdb->posts} - WHERE post_type = %s - {$status_clause} - AND post_author = %d;", - self::POST_TYPE, - $user_id - ) - ); + return DB::table( 'posts' ) + ->where( 'post_type', self::POST_TYPE ) + ->where( 'post_author', $user_id ) + ->where( 'post_status', $status ?? self::STATUS_COMPLETED ) + ->get(); } } From 67c455e669a03f45d2e8d4c3e976d089edaef322 Mon Sep 17 00:00:00 2001 From: Anindra Das Bivas Date: Fri, 23 Oct 2026 13:40:50 +0600 Subject: [PATCH 15/15] removed unused import --- tests/phpunit/CourseModelTest.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/CourseModelTest.php b/tests/phpunit/CourseModelTest.php index 4d5fdeb110..36686ca5d6 100644 --- a/tests/phpunit/CourseModelTest.php +++ b/tests/phpunit/CourseModelTest.php @@ -1,8 +1,15 @@ -