Source: includes/class-sensei-learner.php

<?php
/**
 * File containing the class Sensei_Learner.
 *
 * @package sensei
 */

/**
 * Responsible for all student specific functionality and helper functions
 *
 * @package Users
 * @author Automattic
 *
 * @since 1.9.0
 */
class Sensei_Learner {
	const LEARNER_TERM_PREFIX = 'user-';

	/**
	 * Instance of singleton.
	 *
	 * @since 3.0.0
	 *
	 * @var self
	 */
	private static $instance;

	/**
	 * Cache of the learner terms.
	 *
	 * @var WP_Term[]
	 */
	private static $learner_terms = [];

	/**
	 * Fetches an instance of the class.
	 *
	 * @since 3.0.0
	 *
	 * @return self
	 */
	public static function instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Sensei_Course_Enrolment_Manager constructor. Private so it can only be initialized internally.
	 */
	private function __construct() {}

	/**
	 * Sets the actions.
	 *
	 * @since 3.0.0
	 */
	public function init() {
		add_filter( 'rest_course_query', array( $this, 'filter_rest_course_query' ), 10, 2 );
		add_action( 'wp_ajax_get_course_list', array( $this, 'get_course_list' ) );

		// Delete user activity and enrolment terms when user is deleted.
		add_action( 'deleted_user', array( $this, 'delete_all_user_activity' ) );

		// Try to remove duplicate progress comments to mitigate duplicate enrollment issue.
		add_action( 'sensei_log_activity_after', [ $this, 'remove_duplicate_progress' ] );

		// Add custom columns.
		add_filter( 'manage_course_posts_columns', array( $this, 'add_course_column_heading' ), 10 );
		add_action( 'manage_course_posts_custom_column', array( $this, 'add_course_column_data' ), 10, 2 );
	}

	/**
	 * Add columns to courses list table.
	 *
	 * @since 4.0.0
	 *
	 * @param  array $columns Course columns.
	 * @return array
	 */
	public function add_course_column_heading( $columns ) {
		$columns['students'] = _x( 'Students', 'column name', 'sensei-lms' );

		return $columns;
	}

	/**
	 * Output the content of the course columns.
	 *
	 * @since  4.0.0
	 * @access private
	 *
	 * @param  string $column    Current column name.
	 * @param  int    $course_id The course ID.
	 */
	public function add_course_column_data( $column, $course_id ) {
		if ( 'students' === $column ) {
			$this->output_students_column( $course_id );
		}
	}

	/**
	 * Output the students' column HTML.
	 *
	 * @since  4.0.0
	 * @access private
	 *
	 * @param  int $course_id The course ID.
	 * @return void
	 */
	private function output_students_column( int $course_id ) {
		$students_count = Sensei_Utils::sensei_check_for_activity(
			[
				'post_id' => $course_id,
				'type'    => 'sensei_course_status',
				'status'  => 'any',
			]
		);

		echo esc_html(
			sprintf(
				// translators: Placeholder is the number of students enrolled in a course.
				_n( '%d student', '%d students', $students_count, 'sensei-lms' ),
				$students_count
			)
		);

		$manage_url = add_query_arg(
			[
				'page'      => 'sensei_learners',
				'view'      => 'learners',
				'course_id' => $course_id,
			],
			admin_url( 'admin.php' )
		);

		$grade_url = add_query_arg(
			[
				'page'      => 'sensei_grading',
				'view'      => 'all',
				'course_id' => $course_id,
			],
			admin_url( 'admin.php' )
		);

		?>
		<div class="sensei-wp-list-table-actions">
			<p>
				<a class="button-secondary" href="<?php echo esc_url( $manage_url ); ?>">
					<?php esc_html_e( 'Manage', 'sensei-lms' ); ?>
				</a>
			</p>
			<?php if ( $students_count ) : ?>
				<p>
					<a class="button-secondary" href="<?php echo esc_url( $grade_url ); ?>">
						<?php esc_html_e( 'Grade', 'sensei-lms' ); ?>
					</a>
				</p>
			<?php endif ?>
		</div>
		<?php
	}

	/**
	 * Delete all activity for specified user.
	 *
	 * @since  3.0.0
	 *
	 * @param  integer $user_id User ID.
	 * @return boolean
	 */
	public function delete_all_user_activity( $user_id = 0 ) {
		$dataset_changes = false;

		if ( ! $user_id ) {
			return $dataset_changes;
		}

		// Remove enrolment terms.
		$learner_term = self::get_learner_term( $user_id );
		wp_delete_term( $learner_term->term_id, Sensei_PostTypes::LEARNER_TAXONOMY_NAME );

		$activities = Sensei_Utils::sensei_check_for_activity( array( 'user_id' => $user_id ), true );

		if ( ! $activities ) {
			return $dataset_changes;
		}

		// Need to always return an array, even with only 1 item.
		if ( ! is_array( $activities ) ) {
			$activities = array( $activities );
		}

		$post_ids = [];
		foreach ( $activities as $activity ) {
			if ( empty( $activity->comment_type ) ) {
				continue;
			}
			if ( strpos( $activity->comment_type, 'sensei_' ) !== 0 ) {
				continue;
			}

			$post_ids[]      = $activity->comment_post_ID;
			$dataset_changes = wp_delete_comment( intval( $activity->comment_ID ), true );
		}

		foreach ( array_unique( $post_ids ) as $post_id ) {
			Sensei()->flush_comment_counts_cache( $post_id );
		}

		return $dataset_changes;
	}

	/**
	 * Filter the courses returned by the REST API to just ones that can be managed.
	 *
	 * @param array           $args    Array of arguments for WP_Query.
	 * @param WP_REST_Request $request The REST API request.
	 *
	 * @return array
	 */
	public function filter_rest_course_query( $args, $request ) {
		$filter = $request->get_param( 'filter' );
		if (
			'teacher' === $filter
			&& ! current_user_can( 'manage_sensei' )
			&& current_user_can( 'manage_sensei_grades' )
		) {
			$args['context'] = 'teacher-filter';
			$args['author']  = get_current_user_id();
		}

		return $args;
	}

	/**
	 * Remove duplicate progress comments to mitigate duplicate enrollment issue.
	 *
	 * Hooked into sensei_log_activity_after
	 *
	 * @since 3.7.0
	 * @access private
	 *
	 * @param array $args
	 */
	public function remove_duplicate_progress( $args ) {
		if ( empty( $args['post_id'] ) || empty( $args['user_id'] ) || empty( $args['type'] ) ) {
			return;
		}

		add_action(
			'shutdown',
			function() use ( $args ) {
				global $wpdb;

				// Get progress comments, but the first one, which match the context conditions (they should not exist).
				// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
				$comment_ids = $wpdb->get_col(
					$wpdb->prepare(
						"SELECT comment_ID
							FROM $wpdb->comments
							WHERE comment_post_ID = %d
								AND user_id = %d
								AND comment_type = %s
							ORDER BY comment_ID
							LIMIT 1, 99",
						$args['post_id'],
						$args['user_id'],
						$args['type']
					),
					0
				);

				if ( ! empty( $comment_ids ) ) {
					$serialized_comment_ids = implode( ',', $comment_ids );

					sensei_log_event(
						'remove_duplicate_progress_comments',
						[
							'post_id'     => $args['post_id'],
							'user_id'     => $args['user_id'],
							'type'        => $args['type'],
							'comment_ids' => $serialized_comment_ids,
						]
					);

					$format_comment_ids = implode( ', ', array_fill( 0, count( $comment_ids ), '%s' ) );

					$sql = "DELETE FROM $wpdb->comments WHERE comment_ID IN ( $format_comment_ids )";
					$wpdb->query( call_user_func_array( [ $wpdb, 'prepare' ], array_merge( [ $sql ], $comment_ids ) ) );

					$sql = "DELETE FROM $wpdb->commentmeta WHERE comment_id IN ( $format_comment_ids )";
					$wpdb->query( call_user_func_array( [ $wpdb, 'prepare' ], array_merge( [ $sql ], $comment_ids ) ) );

					clean_comment_cache( $comment_ids );
				}
				// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
			}
		);
	}

	/**
	 * Returns the count of courses enrolled for a user.
	 *
	 * @param int   $user_id The User ID.
	 * @param array $base_query_args The base query arguments - default is empty.
	 *
	 * @return int Post count.
	 */
	public function get_enrolled_courses_count_query( $user_id, array $base_query_args = [] ): int {
		$base_query_args = [
			'posts_per_page' => -1,
			'fields'         => 'ids',
		];
		$this->before_enrolled_courses_query( $user_id );
		$query_args = $this->get_enrolled_courses_query_args( $user_id, $base_query_args );
		$posts      = new WP_Query( $query_args );
		if ( $posts->post_count > 0 ) {
			return $posts->post_count;
		}
		return 0;
	}

	/**
	 * Query the courses a user is enrolled in.
	 *
	 * @param int   $user_id         User ID.
	 * @param array $base_query_args Base query arguments.
	 *
	 * @return WP_Query
	 */
	public function get_enrolled_courses_query( $user_id, $base_query_args = [] ) {
		$this->before_enrolled_courses_query( $user_id );

		$query_args = $this->get_enrolled_courses_query_args( $user_id, $base_query_args );

		return new WP_Query( $query_args );
	}

	/**
	 * Query the courses a user is enrolled in and hasn't completed.
	 *
	 * @param int   $user_id         User ID.
	 * @param array $base_query_args Base query arguments.
	 *
	 * @return WP_Query
	 */
	public function get_enrolled_active_courses_query( $user_id, $base_query_args = [] ) {
		return $this->get_enrolled_courses_query_by_progress_status( $user_id, $base_query_args, 'active' );
	}

	/**
	 * Query the courses a user is enrolled in and has completed.
	 *
	 * @param int   $user_id         User ID.
	 * @param array $base_query_args Base query arguments.
	 *
	 * @return WP_Query
	 */
	public function get_enrolled_completed_courses_query( $user_id, $base_query_args = [] ) {
		return $this->get_enrolled_courses_query_by_progress_status( $user_id, $base_query_args, 'completed' );
	}

	/**
	 * Query the courses a user is enrolled in by progress status.
	 *
	 * @param int    $user_id         User ID.
	 * @param array  $base_query_args Base query arguments.
	 * @param string $type            Type of query to run (`active` or `completed`).
	 *
	 * @return WP_Query
	 */
	private function get_enrolled_courses_query_by_progress_status( $user_id, $base_query_args, $type ) {
		$this->before_enrolled_courses_query( $user_id );

		$query_args = $this->get_enrolled_courses_query_args( $user_id, $base_query_args );

		if ( 'active' === $type ) {
			$course_ids = $this->get_course_ids_by_progress_status( $user_id, 'in-progress' );
		} else {
			$course_ids = $this->get_course_ids_by_progress_status( $user_id, 'complete' );
		}

		if ( ! empty( $query_args['post__in'] ) ) {
			$existing_post_ids = (array) $query_args['post__in'];
			$existing_post_ids = array_map( 'intval', $existing_post_ids );

			$course_ids = array_intersect( $course_ids, $existing_post_ids );
		}

		if ( empty( $course_ids ) ) {
			$course_ids = [ -1 ];
		}

		$query_args['post__in'] = $course_ids;

		return new WP_Query( $query_args );
	}

	/**
	 * Get the arguments to pass to WP_Query to fetch a learner's enrolled courses.
	 *
	 * @param int   $user_id         User ID.
	 * @param array $base_query_args Base query arguments.
	 *
	 * @return array
	 */
	public function get_enrolled_courses_query_args( $user_id, $base_query_args = [] ) {
		$order                = 'DESC';
		$orderby              = 'date';
		$has_set_course_order = '' !== get_option( 'sensei_course_order', '' );

		// If a fixed course order has been set, trust menu_order.
		if ( $has_set_course_order ) {
			$order   = 'ASC';
			$orderby = 'menu_order';
		}

		$default_args = [
			'post_status'         => 'publish',
			'order'               => $order,
			'orderby'             => $orderby,
			'tax_query'           => [], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Just empty to set array.
			'lazy_load_term_meta' => false,
			'cache_results'       => false,
		];

		$query_args   = array_merge( $default_args, $base_query_args );
		$learner_term = self::get_learner_term( $user_id );

		/**
		 * Filters the term ID used in the query to fetch a learner's enrolled courses.
		 *
		 * @hook sensei_learner_get_enrolled_courses_query_args_term_id
		 *
		 * @since 4.23.1
		 *
		 * @param {int} $term_id The term ID.
		 * @return {int} The term ID.
		 */
		$term_id = apply_filters( 'sensei_learner_get_enrolled_courses_query_args_term_id', $learner_term->term_id );

		$query_args['post_type']   = 'course';
		$query_args['tax_query'][] = [
			'taxonomy'         => Sensei_PostTypes::LEARNER_TAXONOMY_NAME,
			'terms'            => $term_id,
			'include_children' => false,
		];

		/**
		 * Filters the arguments of the query which fetches a learner's enrolled courses.
		 *
		 * @since 3.3.0
		 *
		 * @hook sensei_learner_enrolled_courses_args
		 *
		 * @param {array} $query_args  The query args.
		 * @param {int}   $user_id     The user id.
		 * @return {array} Query arguments.
		 */
		return apply_filters( 'sensei_learner_enrolled_courses_args', $query_args, $user_id );
	}

	/**
	 * Notify that a user's enrolled courses are about to be queried.
	 *
	 * @param int $user_id  The user id.
	 */
	private function before_enrolled_courses_query( $user_id ) {
		/**
		 * Fire before we query a user's enrolled courses. This needs to be called before
		 * building the query arguments because `active` courses might be incomplete if we
		 * haven't verified a user's enrolment is up-to-date.
		 *
		 * @since 3.0.0
		 *
		 * @param int $user_id User ID.
		 */
		do_action( 'sensei_before_learners_enrolled_courses_query', $user_id );
	}

	/**
	 * Get the course IDs by progress status.
	 *
	 * @param int    $user_id User ID.
	 * @param string $status  Course progress status. Either `completed` or `in-progress`.
	 *
	 * @return int[]
	 */
	private function get_course_ids_by_progress_status( $user_id, $status ) {
		$course_ids = array();

		$course_statuses = Sensei_Utils::sensei_check_for_activity(
			[
				'user_id' => $user_id,
				'type'    => 'sensei_course_status',
				'status'  => $status,
			],
			true
		);

		// Check for activity returns single if only one. We always want an array.
		if ( ! is_array( $course_statuses ) ) {
			$course_statuses = [ $course_statuses ];
		}

		foreach ( $course_statuses as $status ) {
			$course_ids[] = intval( $status->comment_post_ID );
		}

		/**
		 * Filters the course IDs when getting them for a user by progress status.
		 *
		 * @hook sensei_learner_get_course_ids_by_progress_status_course_ids
		 *
		 * @param {int[]}  $course_ids Course IDs.
		 * @param {int}    $user_id    User ID.
		 * @param {string} $status     Progress status.
		 * @return {int[]} Course IDs.
		 */
		$course_ids = apply_filters( 'sensei_learner_get_course_ids_by_progress_status_course_ids', $course_ids, $user_id, $status );

		return $course_ids;
	}

	/**
	 * Get the learner term for the user.
	 *
	 * @param int $user_id User ID.
	 * @return WP_Term
	 * @throws Exception When learner term could not be created.
	 */
	public static function get_learner_term( $user_id ) {
		$user_term_slug = self::get_learner_term_slug( $user_id );
		if ( ! isset( self::$learner_terms[ $user_id ] ) ) {
			self::$learner_terms[ $user_id ] = get_term_by( 'slug', $user_term_slug, Sensei_PostTypes::LEARNER_TAXONOMY_NAME );

			if ( empty( self::$learner_terms[ $user_id ] ) ) {
				$term = wp_insert_term( $user_term_slug, Sensei_PostTypes::LEARNER_TAXONOMY_NAME );
				if ( is_array( $term ) && ! empty( $term['term_id'] ) ) {
					self::$learner_terms[ $user_id ] = get_term( $term['term_id'] );
				}
			}

			if ( empty( self::$learner_terms[ $user_id ] ) || self::$learner_terms[ $user_id ] instanceof WP_Error ) {
				unset( self::$learner_terms[ $user_id ] );
				throw new Exception( esc_html__( 'Student term could not be created for user.', 'sensei-lms' ) );
			}
		}

		return self::$learner_terms[ $user_id ];
	}

	/**
	 * Get the learner user ID from a term slug.
	 *
	 * @param string $term_name Term slug to parse the user ID from.
	 * @return int
	 */
	public static function get_learner_id( $term_name ) {
		// Cut off the `user-` prefix.
		return intval( substr( $term_name, strlen( self::LEARNER_TERM_PREFIX ) ) );
	}

	/**
	 * Gets the user term slug.
	 *
	 * @param int $user_id User ID.
	 * @return string
	 */
	public static function get_learner_term_slug( $user_id ) {
		return self::LEARNER_TERM_PREFIX . $user_id;
	}

	/**
	 * Get the students full name
	 *
	 * This function replaces Sensei_Learner_Managment->get_learner_full_name
	 *
	 * @since 1.9.0
	 *
	 * @param int $user_id User ID.
	 *
	 * @return string
	 */
	public static function get_full_name( $user_id ) {

		$full_name = '';

		if ( empty( $user_id ) || ! ( 0 < intval( $user_id ) )
			|| ! ( get_userdata( $user_id ) ) ) {
			return '';
		}

		// Get the user details.
		$user = get_user_by( 'id', $user_id );

		if ( ! empty( $user->first_name ) && ! empty( $user->last_name ) ) {

			$full_name = trim( $user->first_name ) . ' ' . trim( $user->last_name );

		} else {

			$full_name = $user->display_name;

		}

		/**
		 * Filter the user full name from the get_learner_full_name function.
		 *
		 * @since 1.8.0
		 *
		 * @hook sensei_learner_full_name
		 *
		 * @param {string} $full_name The full name of the user.
		 * @param {int}]   $user_id   The user ID.
		 * @return {string} The full name of the user.
		 */
		return apply_filters( 'sensei_learner_full_name', $full_name, $user_id );

	}

	/**
	 * Get all active learner ids for a course.
	 *
	 * @param int $course_id Course ID.
	 *
	 * @deprecated 3.0.0
	 *
	 * @return array
	 */
	public static function get_all_active_learner_ids_for_course( $course_id ) {
		_deprecated_function( __METHOD__, '3.0.0' );

		$post_id = absint( $course_id );

		if ( ! $post_id ) {
			return array();
		}

		$activity_args = array(
			'post_id' => $post_id,
			'type'    => 'sensei_course_status',
			'status'  => 'any',
		);

		$learners = Sensei_Utils::sensei_check_for_activity( $activity_args, true );

		if ( ! is_array( $learners ) ) {
			$learners = array( $learners );
		}

		$learner_ids = wp_list_pluck( $learners, 'user_id' );

		return $learner_ids;
	}

	/**
	 * Get all users.
	 *
	 * @param array $args Arguments.
	 *
	 * @deprecated 3.0.0
	 *
	 * @return mixed | int
	 */
	public static function get_all( $args ) {
		_deprecated_function( __METHOD__, '3.0.0' );

		$post_id  = 0;
		$activity = '';

		if ( isset( $args['lesson_id'] ) ) {
			$post_id  = intval( $args['lesson_id'] );
			$activity = 'sensei_lesson_status';
		} elseif ( isset( $args['course_id'] ) ) {
			$post_id  = intval( $args['course_id'] );
			$activity = 'sensei_course_status';
		}

		if ( ! $post_id || ! $activity ) {
			return array();
		}

		$activity_args = array(
			'post_id' => $post_id,
			'type'    => $activity,
			'status'  => 'any',
			'number'  => $args['per_page'],
			'offset'  => $args['offset'],
			'orderby' => $args['orderby'],
			'order'   => $args['order'],
		);

		// Searching users on statuses requires sub-selecting the statuses by user_ids.
		if ( $args['search'] ) {
			$user_args = array(
				'search' => '*' . $args['search'] . '*',
				'fields' => 'ID',
			);
			/**
			 * Filter the user arguments used to search for learners.
			 *
			 * @hook sensei_learners_search_users
			 *
			 * @param {array} $user_args The user arguments.
			 * @return {array} The filtered user arguments.
			 */
			$user_args = apply_filters( 'sensei_learners_search_users', $user_args );
			if ( ! empty( $user_args ) ) {
				$learners_search          = new WP_User_Query( $user_args );
				$activity_args['user_id'] = $learners_search->get_results();
			}
		}

		/**
		 * Filter the arguments used to search for learners activity.
		 *
		 * @hook sensei_learners_filter_users
		 *
		 * @param {array} $activity_args The activity arguments.
		 * @return {array} The filtered activity arguments.
		 */
		$activity_args = apply_filters( 'sensei_learners_filter_users', $activity_args );

		// WP_Comment_Query doesn't support SQL_CALC_FOUND_ROWS, so instead do this twice.
		$total_learners = 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_learners < $activity_args['offset'] ) {
			$new_paged               = floor( $total_learners / $activity_args['number'] );
			$activity_args['offset'] = $new_paged * $activity_args['number'];
		}
		$learners = Sensei_Utils::sensei_check_for_activity( $activity_args, true );
		// Need to always return an array, even with only 1 item.
		if ( ! is_array( $learners ) ) {
			$learners = array( $learners );
		}

		return $learners;
	}

	/**
	 * Get learner from Query Var
	 *
	 * @param string $query_var The Query var.
	 * @return WP_User|false
	 */
	public static function find_by_query_var( $query_var ) {
		if ( empty( $query_var ) ) {
			return false;
		}
		if ( false !== filter_var( $query_var, FILTER_VALIDATE_INT ) ) {
			return get_user_by( 'id', $query_var ); // Get requested learner object by id.
		}

		if ( false !== filter_var( $query_var, FILTER_VALIDATE_EMAIL ) ) {
			return get_user_by( 'email', $query_var ); // Get requested learner object by email.
		}

		$by_slug = get_user_by( 'slug', $query_var );
		if ( false !== $by_slug ) {
			return $by_slug;
		}

		return get_user_by( 'login', $query_var );
	}
	/**
	 * Returns course list for AJAX "more" call on Students page.
	 *
	 * @return void
	 */
	public function get_course_list() {
		if ( empty( $_POST['nonce'] ) ) {
			wp_send_json_error( array( 'error' => esc_html__( 'Insufficient Permissions.', 'sensei-lms' ) ) );
		}
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Don't modify the nonce.
		if ( ! isset( $_POST['user_id'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'get_course_list' ) ) {
			wp_send_json_error( array( 'error' => esc_html__( 'Insufficient Permissions.', 'sensei-lms' ) ) );
		}

		$user_id         = isset( $_POST['user_id'] ) ? sanitize_text_field( wp_unslash( $_POST['user_id'] ) ) : '';
		$learner_manager = self::instance();
		$controller      = new Sensei_Learners_Admin_Bulk_Actions_Controller( new Sensei_Learner_Management( '' ), $learner_manager );
		$base_query_args = [ 'posts_per_page' => -1 ];
		$courses_query   = $learner_manager->get_enrolled_courses_query( $user_id, $base_query_args );

		// We only want to show courses after the third one in the UI.
		$courses = array_slice( $courses_query->posts, 3 );

		$html_items = [];
		foreach ( $courses as $course ) {
			$html_items[] = '<a href="' . esc_url( $controller->get_learner_management_course_url( $course->ID ) ) .
							'" class="sensei-students__enrolled-course" data-course-id="' . intval( $course->ID ) . '">' .
							esc_html( $course->post_title ) .
							'</a>';
		}
		wp_send_json_success( $html_items );
		exit();
	}
}