prefix and fixed strings, and every // value goes through $wpdb->prepare() or $wpdb->insert/update/delete. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching class Progress { public static function init() { // Cleanup hooks are handled centrally by Enrollment. After Unit_Meta/Lesson_Meta save the parent (10). add_action( 'save_post_slms_unit', array( __CLASS__, 'default_sort_order' ), 20 ); add_action( 'save_post_slms_lesson', array( __CLASS__, 'default_sort_order' ), 20 ); } private static function resolve_enrollment_id( $user_id, $lesson_id, $enrollment_id ) { if ( $enrollment_id ) { return $enrollment_id; } $unit_id = get_post_meta( $lesson_id, '_slms_parent_unit', true ); $course_id = $unit_id ? get_post_meta( $unit_id, '_slms_parent_course', true ) : 0; if ( $course_id ) { $enrollment = Enrollment::get_enrollment( $user_id, $course_id ); if ( $enrollment ) { return $enrollment->id; } } return 0; } public static function mark_complete( $user_id, $lesson_id, $enrollment_id = 0 ) { global $wpdb; $table = $wpdb->prefix . 'slms_progress'; $unit_id = get_post_meta( $lesson_id, '_slms_parent_unit', true ); $course_id = $unit_id ? get_post_meta( $unit_id, '_slms_parent_course', true ) : 0; if ( ! $enrollment_id && $course_id ) { $enrollment = Enrollment::get_enrollment( $user_id, $course_id ); $enrollment_id = $enrollment ? $enrollment->id : 0; } // Compute content hash for change detection $content_hash = Content_Integrity::compute_content_hash( $lesson_id ); $existing = $wpdb->get_row( $wpdb->prepare( "SELECT id, status FROM {$wpdb->prefix}slms_progress WHERE enrollment_id = %d AND lesson_id = %d", $enrollment_id, $lesson_id ) ); if ( $existing ) { $result = $wpdb->update( $table, array( 'status' => 'complete', 'completed_at' => current_time( 'mysql', true ), 'content_hash' => $content_hash, ), array( 'id' => $existing->id ), array( '%s', '%s', '%s' ), array( '%d' ) ); } else { $result = $wpdb->insert( $table, array( 'user_id' => $user_id, 'course_id' => $course_id, 'lesson_id' => $lesson_id, 'enrollment_id' => $enrollment_id, 'status' => 'complete', 'completed_at' => current_time( 'mysql', true ), 'content_hash' => $content_hash, ), array( '%d', '%d', '%d', '%d', '%s', '%s', '%s' ) ); } if ( $result && ( ! $existing || 'complete' !== $existing->status ) ) { /** * A student completed a lesson. Fires when the lesson becomes complete, including when * a lesson invalidated by a content change is completed again; not when an already * complete lesson is marked again. * * The plugin's lesson points are a listener on this action. * * @since 1.0.0 * * @param int $user_id User ID. * @param int $lesson_id Lesson ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID. */ do_action( 'accessnow_slms_lesson_completed', (int) $user_id, (int) $lesson_id, (int) $course_id, (int) $enrollment_id ); } return $result; } public static function is_complete( $user_id, $lesson_id, $enrollment_id = 0 ) { global $wpdb; $enrollment_id = self::resolve_enrollment_id( $user_id, $lesson_id, $enrollment_id ); $status = $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->prefix}slms_progress WHERE enrollment_id = %d AND lesson_id = %d", $enrollment_id, $lesson_id ) ); return 'complete' === $status; } /** * Get the raw progress status for a lesson: 'complete', 'invalidated', 'incomplete', or false (no record). */ public static function get_lesson_status( $user_id, $lesson_id, $enrollment_id = 0 ) { global $wpdb; $enrollment_id = self::resolve_enrollment_id( $user_id, $lesson_id, $enrollment_id ); $status = $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->prefix}slms_progress WHERE enrollment_id = %d AND lesson_id = %d", $enrollment_id, $lesson_id ) ); return $status ?: 'incomplete'; } public static function get_course_progress( $user_id, $course_id, $enrollment_id = 0 ) { $total_lessons = self::count_course_lessons( $course_id ); $completed_lessons = self::count_completed_lessons( $user_id, $course_id, $enrollment_id ); if ( 0 === $total_lessons ) { return 0; } return round( ( $completed_lessons / $total_lessons ) * 100 ); } /** * Average progress (%) of a course's active students, from one grouped query. Each student's * percentage is rounded, then averaged, as get_course_progress() would give. * * @param int $course_id Course ID. * @return int Rounded average; 0 with no active students or no lessons. */ public static function get_average_progress( $course_id ) { global $wpdb; $total = self::count_course_lessons( $course_id ); if ( ! $total ) { return 0; } $counts = $wpdb->get_col( $wpdb->prepare( "SELECT COUNT(p.id) FROM {$wpdb->prefix}slms_enrollments e LEFT JOIN {$wpdb->prefix}slms_progress p ON p.enrollment_id = e.id AND p.course_id = e.course_id AND p.status = 'complete' WHERE e.course_id = %d AND e.status = 'active' GROUP BY e.id", $course_id ) ); if ( empty( $counts ) ) { return 0; } $sum = 0; foreach ( $counts as $done ) { $sum += round( ( (int) $done / $total ) * 100 ); } return (int) round( $sum / count( $counts ) ); } public static function count_course_lessons( $course_id ) { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT l.ID) FROM {$wpdb->posts} u INNER JOIN {$wpdb->postmeta} um ON u.ID = um.post_id AND um.meta_key = '_slms_parent_course' AND um.meta_value = %s INNER JOIN {$wpdb->posts} l ON l.post_type = 'slms_lesson' AND l.post_status = 'publish' INNER JOIN {$wpdb->postmeta} lm ON l.ID = lm.post_id AND lm.meta_key = '_slms_parent_unit' AND lm.meta_value = u.ID WHERE u.post_type = 'slms_unit' AND u.post_status = 'publish'", $course_id ) ); } public static function count_completed_lessons( $user_id, $course_id, $enrollment_id = 0 ) { global $wpdb; if ( ! $enrollment_id ) { $enrollment = Enrollment::get_enrollment( $user_id, $course_id ); $enrollment_id = $enrollment ? $enrollment->id : 0; } return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}slms_progress WHERE enrollment_id = %d AND course_id = %d AND status = 'complete'", $enrollment_id, $course_id ) ); } /** * Sort order used for a unit or lesson that has none (placed after the ones that do). */ const DEFAULT_SORT_ORDER = 999999; /** * The course's units in order. A unit with no sort order goes last. * * @param int $course_id Course ID. * @param string|array $status Post status (default published). * @return \WP_Post[] */ public static function get_course_units( $course_id, $status = 'publish' ) { return self::sort_by_order( get_posts( array( 'post_type' => 'slms_unit', 'post_status' => $status, 'posts_per_page' => -1, 'meta_key' => '_slms_parent_course', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- course structure is stored as post meta. 'meta_value' => $course_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- course structure is stored as post meta. ) ) ); } /** * A unit's lessons in order. A lesson with no sort order goes last. * * @param int $unit_id Unit ID. * @param string|array $status Post status (default published). * @return \WP_Post[] */ public static function get_unit_lessons( $unit_id, $status = 'publish' ) { return self::sort_by_order( get_posts( array( 'post_type' => 'slms_lesson', 'post_status' => $status, 'posts_per_page' => -1, 'meta_key' => '_slms_parent_unit', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- course structure is stored as post meta. 'meta_value' => $unit_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- course structure is stored as post meta. ) ) ); } /** * Order units or lessons by _slms_sort_order, then menu order, then ID. * * @param \WP_Post[] $posts Posts. * @return \WP_Post[] */ private static function sort_by_order( array $posts ) { usort( $posts, function ( $a, $b ) { return array( self::sort_order_of( $a->ID ), (int) $a->menu_order, (int) $a->ID ) <=> array( self::sort_order_of( $b->ID ), (int) $b->menu_order, (int) $b->ID ); } ); return $posts; } private static function sort_order_of( $post_id ) { $value = get_post_meta( $post_id, '_slms_sort_order', true ); return '' === $value ? self::DEFAULT_SORT_ORDER : (int) $value; } /** * Give a unit or lesson saved without a sort order the next free one in its parent, so * content made through REST, import or another plugin lands at the end, not nowhere. * * @param int $post_id Post ID. */ public static function default_sort_order( $post_id ) { if ( wp_is_post_revision( $post_id ) || '' !== get_post_meta( $post_id, '_slms_sort_order', true ) ) { return; } $is_unit = 'slms_unit' === get_post_type( $post_id ); $parent = (int) get_post_meta( $post_id, $is_unit ? '_slms_parent_course' : '_slms_parent_unit', true ); $siblings = $parent ? ( $is_unit ? self::get_course_units( $parent, 'any' ) : self::get_unit_lessons( $parent, 'any' ) ) : array(); $next = 0; foreach ( $siblings as $sibling ) { if ( (int) $sibling->ID !== (int) $post_id && '' !== get_post_meta( $sibling->ID, '_slms_sort_order', true ) ) { $next = max( $next, self::sort_order_of( $sibling->ID ) + 1 ); } } update_post_meta( $post_id, '_slms_sort_order', $next ); } public static function get_course_lesson_ids( $course_id ) { global $wpdb; // LEFT JOINs on the sort orders: a unit or lesson without one is still listed (last), so // the list always matches count_course_lessons(). $results = $wpdb->get_col( $wpdb->prepare( "SELECT l.ID FROM {$wpdb->posts} u INNER JOIN {$wpdb->postmeta} um ON u.ID = um.post_id AND um.meta_key = '_slms_parent_course' AND um.meta_value = %s LEFT JOIN {$wpdb->postmeta} uso ON u.ID = uso.post_id AND uso.meta_key = '_slms_sort_order' INNER JOIN {$wpdb->posts} l ON l.post_type = 'slms_lesson' AND l.post_status = 'publish' INNER JOIN {$wpdb->postmeta} lm ON l.ID = lm.post_id AND lm.meta_key = '_slms_parent_unit' AND lm.meta_value = u.ID LEFT JOIN {$wpdb->postmeta} lso ON l.ID = lso.post_id AND lso.meta_key = '_slms_sort_order' WHERE u.post_type = 'slms_unit' AND u.post_status = 'publish' GROUP BY l.ID ORDER BY MIN(CAST(COALESCE(NULLIF(uso.meta_value, ''), %d) AS UNSIGNED)) ASC, MIN(u.menu_order) ASC, MIN(u.ID) ASC, MIN(CAST(COALESCE(NULLIF(lso.meta_value, ''), %d) AS UNSIGNED)) ASC, MIN(l.menu_order) ASC, l.ID ASC", $course_id, self::DEFAULT_SORT_ORDER, self::DEFAULT_SORT_ORDER ) ); return array_map( 'intval', $results ); } public static function get_previous_lesson( $lesson_id, $course_id ) { $all_lessons = self::get_course_lesson_ids( $course_id ); $index = array_search( (int) $lesson_id, $all_lessons, true ); if ( false === $index || 0 === $index ) { return false; } return $all_lessons[ $index - 1 ]; } public static function get_next_lesson( $lesson_id, $course_id ) { $all_lessons = self::get_course_lesson_ids( $course_id ); $index = array_search( (int) $lesson_id, $all_lessons, true ); if ( false === $index || $index >= count( $all_lessons ) - 1 ) { return false; } return $all_lessons[ $index + 1 ]; } public static function get_next_incomplete_lesson( $user_id, $course_id, $enrollment_id = 0 ) { global $wpdb; $all_lessons = self::get_course_lesson_ids( $course_id ); if ( empty( $all_lessons ) ) { return false; } if ( ! $enrollment_id ) { $enrollment = Enrollment::get_enrollment( $user_id, $course_id ); $enrollment_id = $enrollment ? $enrollment->id : 0; } // Batch-fetch all completed lesson IDs for this enrollment $placeholders = implode( ',', array_fill( 0, count( $all_lessons ), '%d' ) ); $params = array_merge( array( $enrollment_id ), $all_lessons ); $completed = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders is a list of %d placeholders. "SELECT lesson_id FROM {$wpdb->prefix}slms_progress WHERE enrollment_id = %d AND lesson_id IN ({$placeholders}) AND status = 'complete'", $params ) ); $completed_set = array_flip( array_map( 'intval', $completed ) ); foreach ( $all_lessons as $lesson_id ) { if ( ! isset( $completed_set[ $lesson_id ] ) ) { return $lesson_id; } } return false; } /** * Whether a student may open a lesson: in a sequential course, every earlier lesson must be * complete. Filtered by accessnow_slms_can_access_lesson (drip content, prerequisites). * * @param int $user_id User ID. * @param int $lesson_id Lesson ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID (0 = look it up). * @return bool */ public static function can_access_lesson( $user_id, $lesson_id, $course_id, $enrollment_id = 0 ) { if ( ! $enrollment_id ) { $enrollment_id = self::resolve_enrollment_id( $user_id, $lesson_id, $enrollment_id ); } /** * Whether a student may open a lesson. Runs for every lesson check: the lesson page, the * course outline and "Mark complete". LMS managers bypass it on the lesson page. * * @since 1.0.0 * * @param bool $can_access Result of the plugin's own sequential check. * @param int $user_id User ID. * @param int $lesson_id Lesson ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID (0 when not enrolled). */ return (bool) apply_filters( 'accessnow_slms_can_access_lesson', self::sequential_access( $lesson_id, $course_id, $enrollment_id ), (int) $user_id, (int) $lesson_id, (int) $course_id, (int) $enrollment_id ); } /** * The plugin's own rule: in a sequential course, every earlier lesson must be complete. * * @param int $lesson_id Lesson ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID. * @return bool */ private static function sequential_access( $lesson_id, $course_id, $enrollment_id ) { $progression = get_post_meta( $course_id, '_slms_progression_mode', true ); if ( 'sequential' !== $progression ) { return true; } $all_lessons = self::get_course_lesson_ids( $course_id ); $index = array_search( (int) $lesson_id, $all_lessons, true ); if ( false === $index || 0 === $index ) { return true; // first lesson or not found } // Check ALL prior lessons are complete (not just the immediately previous one) $prior_lessons = array_slice( $all_lessons, 0, $index ); global $wpdb; $placeholders = implode( ',', array_fill( 0, count( $prior_lessons ), '%d' ) ); $params = array_merge( array( $enrollment_id ), $prior_lessons ); $completed = (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders is a list of %d placeholders. "SELECT COUNT(*) FROM {$wpdb->prefix}slms_progress WHERE enrollment_id = %d AND lesson_id IN ({$placeholders}) AND status = 'complete'", $params ) ); return $completed >= count( $prior_lessons ); } public static function cleanup_lesson_data( $post_id ) { if ( 'slms_lesson' !== get_post_type( $post_id ) ) { return; } global $wpdb; $wpdb->delete( $wpdb->prefix . 'slms_progress', array( 'lesson_id' => $post_id ), array( '%d' ) ); } public static function cleanup_user_data( $user_id ) { global $wpdb; $wpdb->delete( $wpdb->prefix . 'slms_progress', array( 'user_id' => $user_id ), array( '%d' ) ); } /** * Whether a student may mark a lesson complete now, beyond the plugin's own rules (enrolment, order, * passing the lesson's quiz), which are checked first. * * @param int $user_id User ID. * @param int $lesson_id Lesson ID. * @param int $course_id Course ID. * @param object $enrollment Enrolment row. * @return true|\WP_Error */ public static function can_mark_complete( $user_id, $lesson_id, $course_id, $enrollment ) { /** * Whether a student may mark a lesson complete, e.g. only after watching most of its video. * Return a WP_Error whose message tells the student what is left to do; the lesson page shows * it beside a disabled button, and the request to mark it complete is refused with it. * * @since 1.1.0 * * @param true|WP_Error $allowed True. * @param int $user_id User ID. * @param int $lesson_id Lesson ID. * @param int $course_id Course ID. * @param object $enrollment Enrolment row. */ $allowed = apply_filters( 'accessnow_slms_can_mark_complete', true, (int) $user_id, (int) $lesson_id, (int) $course_id, $enrollment ); if ( false === $allowed ) { return new \WP_Error( 'not_yet', __( 'You can\'t mark this lesson complete yet.', 'accessnow-lms' ) ); } return is_wp_error( $allowed ) ? $allowed : true; } }