gocodebox/lifterlms

View on GitHub
includes/models/model.llms.student.php

Summary

Maintainability
F
1 wk
Test Coverage
C
78%
<?php
/**
 * Student Model
 *
 * @package LifterLMS/Models/Classes
 *
 * @since 2.2.3
 * @version 7.5.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Student model class
 *
 * Manages data and interactions with a LifterLMS Student.
 *
 * @since 2.2.3
 * @since 3.33.0 Added the `delete_student_enrollment` public method that allows student's enrollment unrollment and deletion.
 * @since 3.33.0 Added the `delete_enrollment_postmeta` private method that allows student's enrollment postmeta deletion.
 * @since 3.34.0 Added new filters for differentiating between enrollment update and creation; Added the ability to check enrollment from a section.
 * @since 3.35.0 Prepare all variables when querying for enrollment date.
 * @since 3.36.2 Added logic to physically remove from the membership level and remove enrollments data on related products, when deleting a membership enrollment.
 * @since 3.37.9 Added filters `llms_user_enrollment_allowed_post_types` & `llms_user_enrollment_status_allowed_post_types` which allow 3rd parties to enroll users into additional post types via core enrollment methods.
 * @since 4.0.0 Remove previously deprecated methods.
 * @since 4.2.0 The `$enrollment_trigger` parameter was added to the `'llms_user_enrollment_deleted'` action hook.
 *              Added new filter to allow customization of object completion data.
 * @since 5.2.0 Changed the date to be relative to the local time zone in `get_registration_date`.
 * @since 6.0.0 Removed the deprecated `llms_user_removed_from_membership_level` action hook from the `LLMS_Student::unenroll()` method.
 * @since 7.5.0 Added the logic to add and remove lesson favorite.
 */
class LLMS_Student extends LLMS_Abstract_User_Data {

    use LLMS_Trait_Student_Awards;

    /**
     * Retrieve an instance of the LLMS_Instructor model for the current user
     *
     * @return   LLMS_Instructor|false
     * @since    3.14.0
     * @version  3.14.0
     */
    public function instructor() {
        if ( $this->is_instructor() ) {
            return llms_get_instructor( $this->get_id() );
        }
        return false;
    }

    /**
     * Retrieve an instance of the student quiz data model
     *
     * @return   LLMS_Student_Quizzes
     * @since    3.9.0
     * @version  3.9.0
     */
    public function quizzes() {
        return new LLMS_Student_Quizzes( $this->get_id() );
    }

    /**
     * Add the student to a LifterLMS Membership
     *
     * @param int $membership_id   WP Post ID of the membership
     * @return  void
     *
     * @since  2.2.3
     */
    private function add_membership_level( $membership_id ) {

        // Add the user to the membership level.
        $membership_levels = $this->get_membership_levels();
        array_push( $membership_levels, $membership_id );
        update_user_meta( $this->get_id(), '_llms_restricted_levels', $membership_levels );

        // If there's auto-enroll courses, enroll the user in those courses.
        $autoenroll_courses = get_post_meta( $membership_id, '_llms_auto_enroll', true );
        if ( $autoenroll_courses ) {

            foreach ( $autoenroll_courses as $course_id ) {

                $this->enroll( $course_id, 'membership_' . $membership_id );

            }
        }

    }

    /**
     * Enroll the student into a course or membership
     *
     * @since 2.2.3
     * @since 3.17.0 Unknown.
     * @since 3.34.0 Added new actions to differentiate between first-time enrollment and enrollment status updates.
     * @since 3.37.9 Added filter `llms_user_enrollment_allowed_post_types` to customize the post types a user can be enrolled into.
     * @since 4.4.1 Moved filter `llms_user_enrollment_allowed_post_types` to function `llms_get_enrollable_post_types()`.
     *
     * @see llms_enroll_student()
     *
     * @param  int    $product_id WP Post ID of the course or membership
     * @param  string $trigger    String describing the reason for enrollment
     * @return bool
     */
    public function enroll( $product_id, $trigger = 'unspecified' ) {

        /**
         * Fires before a user is enrolled into a course or membership.
         *
         * @param int $user_id WP User ID.
         * @param int $product_id WP Post ID of the course or membership.
         */
        do_action( 'before_llms_user_enrollment', $this->get_id(), $product_id );

        // Users can only be enrolled into the following post types.
        if ( ! in_array( get_post_type( $product_id ), llms_get_enrollable_post_types(), true ) ) {
            return false;
        }

        // Check enrollment before enrolling to prevent duplicates.
        if ( llms_is_user_enrolled( $this->get_id(), $product_id ) ) {
            return false;
        }

        // If the student has been previously enrolled, simply update don't run a full enrollment.
        if ( $this->get_enrollment_status( $product_id, false ) ) {
            $insert      = $this->insert_status_postmeta( $product_id, 'enrolled', $trigger );
            $action_type = 'updated';
        } else {
            $insert      = $this->insert_enrollment_postmeta( $product_id, $trigger );
            $action_type = 'created';
        }

        // Add the user postmeta for the enrollment.
        if ( ! empty( $insert ) ) {

            // Update the cache.
            $this->cache_set( sprintf( 'enrollment_status_%d', $product_id ), 'enrolled' );
            $this->cache_delete( sprintf( 'date_enrolled_%d', $product_id ) );
            $this->cache_delete( sprintf( 'date_updated_%d', $product_id ) );

            $post_type = str_replace( 'llms_', '', get_post_type( $product_id ) );

            if ( 'course' === $post_type ) {

                /**
                 * Fires after a user is enrolled in course
                 *
                 * @param int $user_id    WP User ID.
                 * @param int $product_id WP Post ID of the course or membership.
                 */
                do_action( 'llms_user_enrolled_in_course', $this->get_id(), $product_id );

            } elseif ( 'membership' === $post_type ) {

                $this->add_membership_level( $product_id );

                /**
                 * Fires after a user is enrolled in membership
                 *
                 * @param int $user_id    WP User ID.
                 * @param int $product_id WP Post ID of the course or membership.
                 */
                do_action( 'llms_user_added_to_membership_level', $this->get_id(), $product_id );

            }

            /**
             * Fires after a user's enrollment is created or updated.
             *
             * `$post_type` refers to the type of item the user is enrolled in, either 'course' or 'membership'
             * `$action_type` refers to the type of action taking place, either "created" or "updated".
             *
             * @param int $user_id WP User ID.
             * @param int $product_id WP Post ID of the course or membership.
             */
            do_action( "llms_user_{$post_type}_enrollment_{$action_type}", $this->get_id(), $product_id );

            return true;

        }

        return false;

    }

    public function get_avatar( $size = 96 ) {
        return '<span class="llms-student-avatar">' . get_avatar( $this->get_id(), $size, null, $this->get_name() ) . '</span>';
    }

    /**
     * Retrieve the order which enrolled a student in a given course or membership.
     *
     * Retrieves the most recently updated order for the given product.
     *
     * @since 3.0.0
     * @since 5.7.0 Replaced the call to the deprecated `LLMS_Lesson::get_parent_course()` method with `LLMS_Lesson::get( 'parent_course' )`.
     *
     * @param int $product_id WP Post ID of the LifterLMS Product (course, lesson, or membership)
     * @return LLMS_Order|false Instance of the LLMS_Order or false if none found
     */
    public function get_enrollment_order( $product_id ) {

        // If a lesson id was passed in, cascade up to the course for order retrieval.
        if ( 'lesson' === get_post_type( $product_id ) ) {
            $lesson     = new LLMS_Lesson( $product_id );
            $product_id = $lesson->get( 'parent_course' );
        }

        // Attempt to locate the order via the enrollment trigger.
        $trigger = $this->get_enrollment_trigger( $product_id );
        if ( strpos( $trigger, 'order_' ) !== false ) {

            $id = str_replace( array( 'order_', 'wc_' ), '', $trigger );
            if ( is_numeric( $id ) ) {
                if ( 'llms_order' === get_post_type( $id ) ) {
                    return new LLMS_Order( $id );
                } else {

                    return get_post( $id );
                }
            }
        }

        // Couldn't find via enrollment trigger, do a WP_Query.
        $q = new WP_Query(
            array(
                'order'          => 'DESC',
                'orderby'        => 'modified',
                'meta_query'     => array(
                    'relation' => 'AND',
                    array(
                        'key'   => '_llms_user_id',
                        'value' => $this->get_id(),
                    ),
                    array(
                        'key'   => '_llms_product_id',
                        'value' => $product_id,
                    ),
                ),
                'posts_per_page' => 1,
                'post_type'      => 'llms_order',
            )
        );

        if ( $q->have_posts() ) {
            return new LLMS_Order( $q->posts[0] );
        }

        // Couldn't find an order, return false.
        return false;

    }

    /**
     * Retrieve IDs of user's courses based on supplied criteria
     *
     * @param    array $args   see `get_enrollments`
     * @return   array
     * @since    3.0.0
     * @version  3.15.0
     */
    public function get_courses( $args = array() ) {

        return $this->get_enrollments( 'course', $args );

    }

    /**
     * Retrieve user's favorites based on supplied criteria.
     *
     * @since 7.5.0
     *
     * @param string $order_by Result set ordering field. Default "updated_date".
     * @param string $order    Result set order. Default "DESC". Accepts "DESC" or "ASC".
     * @param int    $limit    Number of favorites to return. Default is infinite.
     * @return bool|array
     */
    public function get_favorites( $order_by = 'updated_date', $order = 'DESC', $limit = -1 ) {

        global $wpdb;

        $limit_clause = $limit < 1 ? '' : "LIMIT 0, {$limit}";

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $res = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$wpdb->prefix}lifterlms_user_postmeta
                    WHERE meta_key = %s AND user_id = %d ORDER BY {$order_by} {$order} {$limit_clause};",
                '_favorite',
                get_current_user_id()
            )
        );

        return empty( $res ) ? false : $res;

    }

    /**
     * Retrieve IDs of courses a user has completed
     *
     * @param  array $args query arguments
     *                      @arg int    $limit    number of courses to return
     *                      @arg string $orderby  table reference and field to order results by
     *                      @arg string $order    result order (DESC, ASC)
     *                      @arg int    $skip     number of results to skip for pagination purposes
     * @return array        "courses" will contain an array of course ids
     *                      "more" will contain a boolean determining whether or not more courses are available beyond supplied limit/skip criteria
     * @since   ??
     * @version 3.24.0
     */
    public function get_completed_courses( $args = array() ) {

        global $wpdb;

        $args = array_merge(
            array(
                'limit'   => 20,
                'orderby' => 'upm.updated_date',
                'order'   => 'DESC',
                'skip'    => 0,
            ),
            $args
        );

        // Add one to the limit to see if there's pagination.
        $args['limit']++;

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $q = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT upm.post_id AS id
             FROM {$wpdb->prefix}lifterlms_user_postmeta AS upm
             JOIN {$wpdb->posts} AS p ON p.ID = upm.post_id
             WHERE p.post_type = 'course'
               AND upm.meta_key = '_is_complete'
               AND upm.meta_value = 'yes'
               AND upm.user_id = %d
             ORDER BY {$args['orderby']} {$args['order']}
             LIMIT %d, %d;
            ",
                array(
                    $this->get_id(),
                    $args['skip'],
                    $args['limit'],
                )
            ),
            'OBJECT_K'
        ); // db call ok; no-cache ok.
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $ids  = array_keys( $q );
        $more = false;

        // If we hit our limit we have too many results, pop the last one.
        if ( count( $ids ) === $args['limit'] ) {
            array_pop( $ids );
            $more = true;
        }

        // Reset args to pass back for pagination.
        $args['limit']--;

        $r = array(
            'limit'   => $args['limit'],
            'more'    => $more,
            'results' => $ids,
            'skip'    => $args['skip'],
        );

        return $r;

    }

    /**
     * Get the formatted date when a course or lesson was completed by the student
     *
     * @param    int    $object_id  WP Post ID of a course or lesson
     * @param    string $format     date format as accepted by php date()
     * @return   false|string            will return false if the user is not enrolled
     * @since    ??
     * @version  ??
     */
    public function get_completion_date( $object_id, $format = 'F d, Y' ) {

        global $wpdb;

        $q = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT updated_date FROM {$wpdb->prefix}lifterlms_user_postmeta WHERE meta_key = '_is_complete' AND meta_value = 'yes' AND user_id = %d AND post_id = %d ORDER BY updated_date DESC LIMIT 1",
                array( $this->get_id(), $object_id )
            )
        ); // db call ok; no-cache ok.

        return ( $q ) ? date_i18n( $format, strtotime( $q ) ) : false;

    }

    /**
     * Retrieve IDs of user's enrollments by post type (and additional criteria)
     *
     * @param  string $post_type  name of the post type (course|membership)
     * @param  array  $args query arguments
     *                      @arg int    $limit    number of courses to return
     *                      @arg string $orderby  table reference and field to order results by
     *                      @arg string $order    result order (DESC, ASC)
     *                      @arg int    $skip     number of results to skip for pagination purposes
     *                      @arg string $status   filter results by enrollment status, "any", "enrolled", "cancelled", or "expired"
     * @return array        "results" will contain an array of course ids
     *                      "more" will contain a boolean determining whether or not more courses are available beyond supplied limit/skip criteria
     *                      "found" will contain the total possible FOUND_ROWS() for the query
     * @since    3.0.0
     * @version  3.15.1
     */
    public function get_enrollments( $post_type = 'course', $args = array() ) {

        global $wpdb;

        $args = wp_parse_args(
            $args,
            array(
                'limit'   => 20,
                'orderby' => 'upm.updated_date',
                'order'   => 'DESC',
                'skip'    => 0,
                'status'  => 'any', // Any, enrolled, cancelled, expired.
            )
        );

        // Prefix membership.
        if ( 'membership' === $post_type ) {
            $post_type = 'llms_membership';
        }

        // Sanitize order & orderby.
        $args['orderby'] = preg_replace( '/[^a-zA-Z_.]/', '', $args['orderby'] );
        $args['order']   = preg_replace( '/[^a-zA-Z_.]/', '', $args['order'] );

        // Allow "short" orderby's to be passed in without a table reference.
        switch ( $args['orderby'] ) {
            case 'date':
                $args['orderby'] = 'upm.updated_date';
                break;
            case 'order':
                $args['orderby'] = 'p.menu_order';
                break;
            case 'title':
                $args['orderby'] = 'p.post_title';
                break;
        }

        // Prepare additional status AND clauses.
        if ( 'any' !== $args['status'] ) {
            $status = $wpdb->prepare(
                "
                AND upm.meta_value = %s
                AND upm.updated_date = (
                    SELECT MAX( upm2.updated_date )
                      FROM {$wpdb->prefix}lifterlms_user_postmeta AS upm2
                     WHERE upm2.meta_key = '_status'
                       AND upm2.user_id = %d
                       AND upm2.post_id = upm.post_id
                    )",
                $args['status'],
                $this->get_id()
            );
        } else {
            $status = '';
        }

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $query = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT SQL_CALC_FOUND_ROWS DISTINCT upm.post_id AS id
             FROM {$wpdb->prefix}lifterlms_user_postmeta AS upm
             JOIN {$wpdb->posts} AS p ON p.ID = upm.post_id
             WHERE p.post_type = %s
               AND p.post_status = 'publish'
               AND upm.meta_key = '_status'
               AND upm.user_id = %d
               {$status}
             ORDER BY {$args['orderby']} {$args['order']}
             LIMIT %d, %d;
            ",
                array(
                    $post_type,
                    $this->get_id(),
                    $args['skip'],
                    $args['limit'],
                )
            ),
            'OBJECT_K'
        ); // db call ok; no-cache ok.
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $found = absint( $wpdb->get_var( 'SELECT FOUND_ROWS()' ) ); // db call ok; no-cache ok.

        return array(
            'found'   => $found,
            'limit'   => $args['limit'],
            'more'    => ( $found > ( ( $args['skip'] / $args['limit'] + 1 ) * $args['limit'] ) ),
            'skip'    => $args['skip'],
            'results' => array_keys( $query ),
        );

    }

    /**
     * Get the formatted date when a user initially enrolled in a product or when they were last updated
     *
     * @since 3.0.0
     * @since 3.35.0 Prepare SQL properly.
     *
     * @param   int    $product_id  WP Post ID of a course or membership
     * @param   string $date        "enrolled" will get the most recent start date, "updated" will get the most recent status change date
     * @param   string $format      date format as accepted by php date(), if none supplied uses the WP core "date_format" option
     * @return  false|string        will return false if the user is not enrolled
     */
    public function get_enrollment_date( $product_id, $date = 'enrolled', $format = null ) {

        if ( ! $format ) {
            $format = get_option( 'date_format', 'M d, Y' );
        }

        $cache_key = sprintf( 'date_%1$s_%2$s', $date, $product_id );
        $res       = $this->cache_get( $cache_key );

        if ( false === $res ) {

            $key = ( 'enrolled' === $date ) ? '_start_date' : '_status';

            global $wpdb;

            // Get the oldest recorded Enrollment date.
            $res = $wpdb->get_var(
                $wpdb->prepare(
                    "SELECT updated_date FROM {$wpdb->prefix}lifterlms_user_postmeta WHERE meta_key = %s AND user_id = %d AND post_id = %d ORDER BY updated_date DESC LIMIT 1",
                    array( $key, $this->get_id(), $product_id )
                )
            );

            $this->cache_set( $cache_key, $res );

        }

        return ( $res ) ? date_i18n( $format, strtotime( $res ) ) : false;

    }

    /**
     * Get the current enrollment status of a student for a particular product
     *
     * @since 3.0.0
     * @since 3.17.0 Unknown.
     * @since 3.37.9 Added filter `llms_user_enrollment_status_allowed_post_types`.
     * @since 4.4.1 Moved filter `llms_user_enrollment_status_allowed_post_types` to function `llms_get_enrollable_status_check_post_types()`.
     * @since 4.18.0 Added a tie-breaker when there are multiple enrollment statuses with the same date & time.
     * @since 5.7.0 Replaced the call to the deprecated `LLMS_Lesson::get_parent_course()` method with `LLMS_Lesson::get( 'parent_course' )`.
     *
     * @param  int  $product_id  WP Post ID of a Course, Section, Lesson, or Membership
     * @param  bool $use_cache   If true, returns cached data if available, if false will run a db query
     * @return false|string      When no enrollment status exists, returns `false`. Otherwise returns the
     *                           enrollment status as a string.
     */
    public function get_enrollment_status( $product_id, $use_cache = true ) {

        $status       = false;
        $product_type = get_post_type( $product_id );

        if ( ! in_array( $product_type, llms_get_enrollable_status_check_post_types(), true ) ) {
            /* This filter is documented at the end of this method. */
            return apply_filters( 'llms_get_enrollment_status', $status, $this->get_id(), $product_id, $use_cache );
        }

        // Get course ID if we're looking at a lesson or section.
        if ( in_array( $product_type, array( 'section', 'lesson' ), true ) ) {

            $llms_post = llms_get_post( $product_id );
            if ( $llms_post ) {
                $product_id = $llms_post->get( 'parent_course' );
            }
        }

        if ( $use_cache ) {
            $status = $this->cache_get( sprintf( 'enrollment_status_%d', $product_id ) );
        }

        /**
         * After checking the cache, $status will be:
         *     + `false` if there was nothing in the cache or the function was instructed to not use the cache: Query the database to get the status.
         *     + a string if there was a status: No need to query the database.
         *     + `null` if there's no status: No need to query the database.
         */
        if ( false === $status ) {

            global $wpdb;

            // Get the most recent recorded status.
            $status = $wpdb->get_var(
                $wpdb->prepare(
                    "SELECT meta_value FROM {$wpdb->prefix}lifterlms_user_postmeta
                     WHERE meta_key = '_status' AND user_id = %d AND post_id = %d
                     ORDER BY updated_date DESC, meta_id DESC LIMIT 1;",
                    array( $this->get_id(), $product_id )
                )
            );

            // Cache the data: `null` will be stored if the student has no status.
            $this->cache_set( sprintf( 'enrollment_status_%d', $product_id ), $status );

        }

        // Don't return `null` values from the database.
        $status = $status ? $status : false;

        /**
         * Filter a user's enrollment status for a specific post.
         *
         * Note that if a value is modified by this filter the modified value is *not* cached. Therefore you should
         * consider implementing caching of your modified value which matches the caching implemented by this method
         * so that the modified value obeys the default caching behavior.
         *
         * @since Unknown
         *
         * @param false|string $status     When no enrollment status exists, returns `false`. Otherwise returns the
         *                                     enrollment status as a string.
         * @param int          $user_id    WP_User ID of the student
         * @param int          $product_id WP_Post ID of the post used to check the enrollment status.
         * @param boolean      $use_cache  Whether or not to use the local cache.
         */
        return apply_filters( 'llms_get_enrollment_status', $status, $this->get_id(), $product_id, $use_cache );

    }

    /**
     * Get the enrollment trigger for a the student's enrollment in a course
     *
     * @param    int $product_id   WP Post ID of the course or membership
     * @return   string|false
     * @since    ??
     * @version  3.21.0
     */
    public function get_enrollment_trigger( $product_id ) {

        $trigger = llms_get_user_postmeta( $this->get_id(), $product_id, '_enrollment_trigger', true );
        return $trigger ? $trigger : false;

    }

    /**
     * Get the enrollment trigger id for a the student's enrollment in a course
     *
     * @param    int $product_id  WP Post ID of the course or membership
     * @return   int|false
     * @since    3.0.0
     * @version  3.17.2
     */
    public function get_enrollment_trigger_id( $product_id ) {

        $trigger = $this->get_enrollment_trigger( $product_id );
        $id      = false;
        if ( $trigger && false !== strpos( $trigger, 'order_' ) ) {
            $trigger_obj = $this->get_enrollment_order( $product_id );
            if ( $trigger_obj instanceof LLMS_Order ) {
                $id = $trigger_obj->get( 'id' );
            } elseif ( $trigger_obj instanceof WP_Post ) {
                $id = $trigger_obj->ID;
            }
        } elseif ( $trigger && false !== strpos( $trigger, 'admin_' ) ) {
            $id = absint( str_replace( 'admin_', '', $trigger ) );
        }
        return $id;

    }

    /**
     * Retrieve postmeta events related to the student
     *
     * @param    array $args  default args, see LLMS_Query_User_Postmeta
     * @return   array
     * @since    3.15.0
     * @version  3.15.0
     */
    public function get_events( $args = array() ) {

        $query = new LLMS_Query_User_Postmeta(
            wp_parse_args(
                $args,
                array(
                    'types'    => 'all',
                    'per_page' => 10,
                    'user_id'  => $this->get_id(),
                )
            )
        );

        return $query->get_metas();

    }

    /**
     * Get the students grade for a lesson / course
     * All grades are based on quizzes assigned to lessons
     *
     * @param    int  $object_id  WP Post ID of a course or lesson
     * @param    bool $use_cache  If true, uses cached results
     * @return   mixed
     * @since    ??
     * @version  3.24.0
     */
    public function get_grade( $object_id, $use_cache = true ) {
        $grade = llms()->grades()->get_grade( $object_id, $this, $use_cache );
        if ( is_null( $grade ) ) {
            $grade = _x( 'N/A', 'Grade to display when no quizzes taken or available', 'lifterlms' );
        }
        return apply_filters( 'llms_student_get_grade', $grade, $this, $object_id, get_post_type( $object_id ) );
    }

    /**
     * Retrieve IDs of user's memberships based on supplied criteria
     *
     * @param    array $args   see `get_enrollments`
     * @return   array
     * @since    3.15.0
     * @version  3.15.0
     */
    public function get_memberships( $args = array() ) {

        return $this->get_enrollments( 'membership', $args );

    }

    /**
     * Retrieve a user's notification subscription preferences for a given type & trigger
     *
     * @param    string $type     notification type: email, basic, etc...
     * @param    string $trigger  notification trigger: eg purchase_receipt, lesson_complete, etc...
     * @param    string $default  value to return if no setting is saved in the db
     * @return   string             yes or no
     * @since    3.10.0
     * @version  3.10.0
     */
    public function get_notification_subscription( $type, $trigger, $default = 'no' ) {

        $prefs = $this->get( 'notification_subscriptions' );
        if ( ! $prefs ) {
            $prefs = array();
        }

        if ( isset( $prefs[ $type ] ) && isset( $prefs[ $type ][ $trigger ] ) ) {
            return $prefs[ $type ][ $trigger ];
        }

        return $default;

    }

    /**
     * Retrieve the student's overall grade
     *
     * Grade = sum of grades for all courses divided by number of enrolled courses
     * if a course has no quizzes in it, it cannot be graded and is therefore excluded from the calculation.
     *
     * Cached data is automatically cleared when a student completes a quiz.
     *
     * @since 3.2.0
     *
     * @param boolean $use_cache If `false`, calculates the grade, otherwise utilizes cached data (if available)
     * @return float|string Grade as float or "N/A"
     */
    public function get_overall_grade( $use_cache = true ) {

        $grade = null;

        // Attempt to pull from the cache first.
        if ( $use_cache ) {

            $grade = $this->get( $this->meta_prefix . 'overall_grade' );

            if ( is_numeric( $grade ) ) {
                $grade = floatval( $grade );
            }
        }

        // Cache disabled or no cached data available.
        if ( ! $use_cache || null === $grade || '' === $grade ) {

            $grades = array();

            // Get courses.
            $courses = $this->get_courses(
                array(
                    'limit' => 9999,
                )
            );

            // Loop through courses.
            foreach ( $courses['results'] as $course_id ) {

                // Get course grade.
                $g = $this->get_grade( $course_id );

                // If an actual grade (not N/A) is returned.
                if ( is_numeric( $g ) ) {
                    array_push( $grades, $g );
                }
            }

            // If we have at least one grade.
            $count = count( $grades );
            if ( $count ) {

                $grade = round( array_sum( $grades ) / $count, 2 );

            } else {

                $grade = _x( 'N/A', 'overall grade when no quizzes', 'lifterlms' );

            }

            // Cache the grade.
            $this->set( 'overall_grade', $grade );

        }

        return apply_filters( 'llms_student_get_overall_grade', $grade, $this );

    }

    /**
     * Retrieve a student's overall progress
     * Overall progress is the total percentage completed based on all courses the student is enrolled in
     * Cached data is cleared every time the student completes a lesson
     *
     * @param    boolean $use_cache  if false, calculates the progress, otherwise utilizes cached data (if available)
     * @return   float
     * @since    3.2.0
     * @version  3.2.0
     */
    public function get_overall_progress( $use_cache = true ) {

        $progress = null;

        // Attempt to pull from the cache first.
        if ( $use_cache ) {

            $progress = $this->get( $this->meta_prefix . 'overall_progress' );

            if ( is_numeric( $progress ) ) {
                $progress = floatval( $progress );
            }
        }

        // Cache disabled or no cached data available.
        if ( ! $use_cache || null === $progress || '' === $progress ) {

            $progresses = array();

            // Get courses.
            $courses = $this->get_courses(
                array(
                    'limit' => 9999,
                )
            );

            // Loop through courses.
            foreach ( $courses['results'] as $course_id ) {
                array_push( $progresses, $this->get_progress( $course_id, 'course' ) );
            }

            $count = count( $progresses );
            if ( $count ) {

                $progress = round( array_sum( $progresses ) / $count, 2 );

            } else {

                $progress = 0;

            }

            // Cache the grade.
            $this->set( 'overall_progress', $progress );

        }

        return apply_filters( 'llms_student_get_overall_progress', $progress, $this );

    }

    /**
     * Get the students last completed lesson in a course
     *
     * @param    int $course_id    WP_Post ID of the course
     * @return   int                   WP_Post ID of the lesson or false if no progress has been made
     * @since    3.0.0
     * @version  3.0.0
     */
    public function get_last_completed_lesson( $course_id ) {

        $course  = new LLMS_Course( $course_id );
        $lessons = array_reverse( $course->get_lessons( 'ids' ) );

        foreach ( $lessons as $lesson ) {
            if ( $this->is_complete( $lesson, 'lesson' ) ) {
                return $lesson;
            }
        }

        return false;

    }

    /**
     * Retrieve an array of Membership Levels for a user
     *
     * @return array
     * @since   2.2.3
     * @version 2.2.3
     */
    public function get_membership_levels() {

        $levels = get_user_meta( $this->get_id(), '_llms_restricted_levels', true );

        if ( empty( $levels ) ) {

            $levels = array();

        }

        return $levels;

    }

    /**
     * Get the full name of a student
     *
     * @return   string
     * @since    3.0.4
     * @version  3.5.1
     */
    public function get_name() {

        $name = trim( $this->get( 'first_name' ) . ' ' . $this->get( 'last_name' ) );

        if ( ! $name ) {
            $name = $this->display_name;
        }

        return apply_filters( 'llms_student_get_name', $name, $this->get_id(), $this );

    }

    /**
     * Get the next lesson a student needs to complete in a course
     *
     * @param    int $course_id    WP_Post ID of the course
     * @return   int                   WP_Post ID of the lesson or false if all courses are complete
     * @since    3.0.1
     * @version  3.0.1
     */
    public function get_next_lesson( $course_id ) {

        $course  = new LLMS_Course( $course_id );
        $lessons = $course->get_lessons( 'ids' );

        foreach ( $lessons as $lesson ) {
            if ( ! $this->is_complete( $lesson, 'lesson' ) ) {
                return $lesson;
            }
        }

        return false;

    }

    public function get_orders( $params = array() ) {

        $params = wp_parse_args(
            $params,
            array(

                'count'    => 25,
                'page'     => 1,
                'statuses' => array_keys( llms_get_order_statuses() ),

            )
        );

        extract( $params );

        $q = new WP_Query(
            array(
                'order'          => 'DESC',
                'orderby'        => 'date',
                'meta_query'     => array(
                    array(
                        'key'   => '_llms_user_id',
                        'value' => $this->get_id(),
                    ),
                ),
                'paged'          => $page,
                'posts_per_page' => $count,
                'post_status'    => $statuses,
                'post_type'      => 'llms_order',
            )
        );

        $orders = array();

        if ( $q->have_posts() ) {

            foreach ( $q->posts as $post ) {

                $orders[ $post->ID ] = new LLMS_Order( $post );

            }
        }

        return array(
            'count'  => count( $q->posts ),
            'page'   => $page,
            'pages'  => $q->max_num_pages,
            'orders' => $orders,
        );

    }

    /**
     * Get students progress through a course or track
     *
     * @param    int     $object_id  course or track id
     * @param    string  $type       object type [course|course_track|section]
     * @param    boolean $use_cache  if true, will use cached data from the usermeta table (if available)
     *                               if false, will bypass cached data and recalculate the progress from scratch
     * @return   float
     * @since    3.0.0
     * @version  3.24.0
     */
    public function get_progress( $object_id, $type = 'course', $use_cache = true ) {

        $ret       = 0;
        $cache_key = sprintf( '%1$s_%2$d_progress', $type, $object_id );
        $cached    = $use_cache ? $this->get( $cache_key ) : '';

        if ( '' === $cached ) {

            $total     = 0;
            $completed = 0;

            if ( 'course' === $type ) {

                $course  = new LLMS_Course( $object_id );
                $lessons = $course->get_lessons( 'ids' );
                $total   = count( $lessons );
                foreach ( $lessons as $lesson ) {
                    if ( $this->is_complete( $lesson, 'lesson' ) ) {
                        $completed++;
                    }
                }
            } elseif ( 'course_track' === $type ) {

                $track   = new LLMS_Track( $object_id );
                $courses = $track->get_courses();
                $total   = count( $courses );
                foreach ( $courses as $course ) {
                    if ( $this->is_complete( $course->ID, 'course' ) ) {
                        $completed++;
                    }
                }
            } elseif ( 'section' === $type ) {

                $section = new LLMS_Section( $object_id );
                $lessons = $section->get_lessons( 'ids' );
                $total   = count( $lessons );
                foreach ( $lessons as $lesson ) {
                    if ( $this->is_complete( $lesson, 'lesson' ) ) {
                        $completed++;
                    }
                }
            }

            $ret = ( ! $completed || ! $total ) ? 0 : round( 100 / ( $total / $completed ), 2 );
            $this->set( $cache_key, $ret );

        } else {
            $ret = $cached;
        }// End if().

        /**
         * @filter llms_student_get_progress
         * Filters the return of get_progress method
         * @param    float   $ret        student's progress
         * @param    int     $object_id  WP_Post ID of the object
         * @param    string  $type       object post type [course|course_track|section]
         * @param    int     $user_id    WP_User ID of the student
         * @since    unknown
         * @version  3.24.0
         */
        return apply_filters( 'llms_student_get_progress', $ret, $object_id, $type, $this->get_id() );

    }

    /**
     * Retrieve the student's original registration date in the chosen format.
     *
     * @since Unknown
     * @since 5.2.0 Changed the date to be relative to the local time zone.
     *
     * @param string $format Any date format that can be passed to date().
     * @return string
     */
    public function get_registration_date( $format = '' ) {

        if ( ! $format ) {
            $format = get_option( 'date_format' );
        }

        return wp_date( $format, strtotime( $this->get( 'user_registered' ) ) );

    }

    /**
     * Determine if the student is active in at least one course or membership
     *
     * @since 3.14.0
     *
     * @return bool
     */
    public function is_active() {

        // Check memberships first, it's a faster query.
        if ( $this->get_membership_levels() ) {
            return true;
        }

        // Check for at least one enrolled course.
        $courses = $this->get_courses(
            array(
                'limit'  => 1,
                'status' => 'enrolled',
            )
        );

        if ( $courses['results'] ) {
            return true;
        }

        // Not active.
        return false;

    }

    /**
     * Determine if the student has completed a course, track, or lesson
     *
     * @param    int    $object_id  WP Post ID of a course or lesson or section or the term id of the track
     * @param    string $type    Object type (course, lesson, section, or track)
     * @return   boolean
     * @since    3.0.0
     * @version  3.24.0
     */
    public function is_complete( $object_id, $type = 'course' ) {

        // check tracks by progress
        // this is done because tracks can have the same id as another object...
        // @todo tracks should have a different table or format since the post_id col won't guarantee uniqueness...
        if ( 'course_track' === $type ) {

            $ret = ( 100 == $this->get_progress( $object_id, $type ) );

            // Everything else can be checked on the postmeta table.
        } else {

            $query = new LLMS_Query_User_Postmeta(
                array(
                    'types'                 => 'completion',
                    'include_post_children' => false,
                    'user_id'               => $this->get_id(),
                    'post_id'               => $object_id,
                    'per_page'              => 1,
                )
            );

            $ret = $query->has_results();

        }

        return apply_filters( 'llms_is_' . $type . '_complete', $ret, $object_id, $type, $this );

    }

    /**
     * Determine if the student is a LifterLMS Instructor (of any kind)
     *
     * Can be admin, manager, instructor, assistant.
     *
     * @return   boolean
     * @since    3.14.0
     * @version  3.14.0
     */
    public function is_instructor() {
        return $this->user->has_cap( 'lifterlms_instructor' );
    }

    /**
     * Add student postmeta data for completion of a lesson, section, course or track
     *
     * @param  int    $object_id    WP Post ID of the lesson, section, course or track
     * @param  string $trigger      String describing the reason for mark completion
     * @return bool
     * @since    3.3.1
     * @version  3.21.0
     */
    private function insert_completion_postmeta( $object_id, $trigger = 'unspecified' ) {

        // Add info to the user postmeta table.
        $user_metadatas = array(
            '_is_complete'        => 'yes',
            '_completion_trigger' => $trigger,
        );

        $update = llms_bulk_update_user_postmeta( $this->get_id(), $object_id, $user_metadatas, false );

        // Returns an array with errored keys or true on success.
        return is_array( $update ) ? false : true;

    }

    /**
     * Add student postmeta data for incompletion of a lesson, section, course or track
     * An "_is_complete" value of "no" is inserted into postmeta
     *
     * @param    int    $object_id    WP Post ID of the lesson, section, course or track
     * @param    string $trigger      String describing the reason for mark incompletion
     * @return   boolean
     * @since    3.5.0
     * @version  3.24.0
     */
    private function insert_incompletion_postmeta( $object_id, $trigger = 'unspecified' ) {

        global $wpdb;

        // Add '_is_complete' to the user postmeta table for object.
        $user_metadatas = array(
            '_is_complete'        => 'no',
            '_completion_trigger' => $trigger,
        );

        foreach ( $user_metadatas as $key => $value ) {

            /**
             * It's too difficult to keep track of multiple postmetas for each lesson incomplete
             * Instead, I'm just replacing the old '_is_complete' value with 'no'
             *
             * Lessons that have never been complete will not have an '_is_complete' record,
             * Lessons that were completed will have an '_is_complete' record of 'yes',
             * Lessons that have been completed once but were marked incomplete will have an '_is_complete' record of 'no'
             */
            $update = $wpdb->update(
                $wpdb->prefix . 'lifterlms_user_postmeta',
                array(
                    'user_id'      => $this->get_id(),
                    'post_id'      => $object_id,
                    'meta_key'     => $key,
                    'meta_value'   => $value,
                    'updated_date' => current_time( 'mysql' ),
                ),
                array(
                    'user_id'  => $this->get_id(),
                    'post_id'  => $object_id,
                    'meta_key' => $key,
                ),
                array( '%d', '%d', '%s', '%s', '%s' )
            ); // db call ok; no-cache ok.

            if ( false === $update ) {

                return false;

            }
        }

        return true;

    }

    /**
     * Add student postmeta data when lesson is favorited.
     *
     * @since 7.5.0
     *
     * @see LLMS_Student->mark_favorite()
     *
     * @param int $object_id WP Post ID of the object to mark/unmark as favorite.
     * @return bool
     */
    private function insert_favorite_postmeta( $object_id ) {

        $update = llms_update_user_postmeta( $this->get_id(), $object_id, '_favorite', true );

        // Returns boolean if postmeta update is successful.
        return is_array( $update ) ? false : true;

    }

    /**
     * Remove student postmeta data when lesson is unfavorited.
     *
     * @since 7.5.0
     *
     * @param int $object_id WP Post ID of the object to mark/unmark as favorite.
     * @return bool
     */
    private function remove_favorite_postmeta( $object_id ) {

        $update = llms_delete_user_postmeta( $this->get_id(), $object_id, '_favorite', true );

        // Returns boolean if postmeta update is successful.
        return is_array( $update ) ? false : true;

    }

    /**
     * Add student postmeta data for enrollment into a course or membership
     *
     * @param    int    $product_id   WP Post ID of the course or membership
     * @param    string $trigger      String describing the reason for enrollment
     * @return   boolean
     * @since    2.2.3
     * @version  3.21.0
     */
    private function insert_enrollment_postmeta( $product_id, $trigger = 'unspecified' ) {

        // Add info to the user postmeta table.
        $user_metadatas = array(
            '_enrollment_trigger' => $trigger,
            '_start_date'         => 'yes',
            '_status'             => 'enrolled',
        );

        $update = llms_bulk_update_user_postmeta( $this->get_id(), $product_id, $user_metadatas, false );

        // Returns an array with errored keys or true on success.
        return is_array( $update ) ? false : true;

    }

    /**
     * Remove student enrollment postmeta for a given product.
     *
     * @since 3.33.0
     *
     * @param int    $product_id WP Post ID of the course or membership.
     * @param string $trigger    Optional. String the reason for enrollment. Default `null`
     * @return bool Whether or not the enrollment records have been succesfully removed.
     */
    private function delete_enrollment_postmeta( $product_id, $trigger = null ) {

        // Delete info from the user postmeta table.
        $user_metadatas = array(
            '_enrollment_trigger' => $trigger,
            '_start_date'         => null,
            '_status'             => null,
        );

        $delete = llms_bulk_delete_user_postmeta( $this->get_id(), $product_id, $user_metadatas );

        return is_array( $delete ) ? false : true;
    }

    /**
     * Add a new status record to the user postmeta table for a specific product
     *
     * @param    int    $product_id   WP Post ID of the course or membership
     * @param    string $status       string describing the new status
     * @param    string $trigger  String describing the reason for enrollment (optional)
     * @return   boolean
     * @since    3.0.0
     * @version  3.21.0
     */
    private function insert_status_postmeta( $product_id, $status = '', $trigger = null ) {

        $update = llms_update_user_postmeta( $this->get_id(), $product_id, '_status', $status, false );

        if ( $update && $trigger ) {
            $update = llms_update_user_postmeta( $this->get_id(), $product_id, '_enrollment_trigger', $trigger, false );
        }

        return $update;

    }

    /**
     * Determine if a student is enrolled in a Course or Membership.
     *
     * @see     llms_is_user_enrolled()
     *
     * @param   int|array $product_ids WP Post ID of a Course, Section, Lesson, or Membership or array of multiple IDs.
     * @param   string    $relation    Comparator for enrollment check.
     *                                     All = user must be enrolled in all $product_ids.
     *                                     Any = user must be enrolled in at least one of the $product_ids.
     * @param   bool      $use_cache  If true, returns cached data if available, if false will run a db query.
     *
     * @return  boolean
     *
     * @since   3.0.0
     * @version 3.25.0
     */
    public function is_enrolled( $product_ids = null, $relation = 'all', $use_cache = true ) {

        // Assume enrollment unless we find otherwise.
        $ret = true;

        // Allow a single product ID to be submitted (backwards compat).
        $product_ids = ! is_array( $product_ids ) ? array( $product_ids ) : $product_ids;

        foreach ( $product_ids as $id ) {

            $enrolled = ( 'enrolled' === strtolower( $this->get_enrollment_status( $id, $use_cache ) ) );

            // If use must be enrolled in all products and one is not enrolled: quit the loop & return false.
            if ( 'all' === $relation && ! $enrolled ) {
                $ret = false;
                break;

                // If user must be enrolled in any.
            } elseif ( 'any' === $relation ) {

                // If we find an enrollment: return true and quit the loop.
                if ( $enrolled ) {
                    $ret = true;
                    break;

                    // If not switch return to false but keep looking.
                } else {
                    $ret = false;
                }
            }
        }

        return apply_filters( 'llms_is_user_enrolled', $ret, $this, $product_ids, $relation, $use_cache );

    }

    /**
     * Mark a lesson, section, course, or track complete for the given user.
     *
     * @param  int    $object_id    WP Post ID of the lesson, section, course, or track
     * @param  string $object_type  object type [lesson|section|course|track]
     * @param  string $trigger      String describing the reason for marking complete
     * @return bool
     *
     * @see    llms_mark_complete() calls this function without having to instantiate the LLMS_Student class first
     *
     * @since    3.3.1
     * @version  3.17.1
     */
    public function mark_complete( $object_id, $object_type, $trigger = 'unspecified' ) {

        // Short circuit if it's already completed.
        if ( $this->is_complete( $object_id, $object_type ) ) {
            return true;
        }

        return $this->update_completion_status( 'complete', $object_id, $object_type, $trigger );

    }

    /**
     * Mark a lesson, section, course, or track incomplete for the given user
     * Gives an "_is_complete" value of "no" for the given object
     *
     * @param  int    $object_id    WP Post ID of the lesson, section, course, or track
     * @param  string $object_type  object type [lesson|section|course|track]
     * @param  string $trigger      String describing the reason for marking incomplete
     * @return bool
     *
     * @see    llms_mark_incomplete() calls this function without having to instantiate the LLMS_Student class first
     *
     * @since    3.5.0
     * @version  3.17.0
     */
    public function mark_incomplete( $object_id, $object_type, $trigger = 'unspecified' ) {

        return $this->update_completion_status( 'incomplete', $object_id, $object_type, $trigger );

    }

    /**
     * Remove a student from a membership level.
     *
     * @since 2.7
     * @since 3.7.5 Unknown.
     * @since 3.36.2 Added the $delete parameter, that will allow related courses enrollments data deletion.
     *
     * @param  int     $membership_id WP Post ID of the membership.
     * @param  string  $status        Optional. Status to update the removal to. Default is `expired`.
     * @param  boolean $delete        Optional. Status to update the removal to. Default is `false`.
     * @return void
     */
    private function remove_membership_level( $membership_id, $status = 'expired', $delete = false ) {

        // Remove the user from the membership level.
        $membership_levels = $this->get_membership_levels();
        $key               = array_search( $membership_id, $membership_levels );
        if ( false !== $key ) {
            unset( $membership_levels[ $key ] );
        }
        update_user_meta( $this->get_id(), '_llms_restricted_levels', $membership_levels );

        global $wpdb;
        // Locate all enrollments triggered by this membership level.
        $q = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT post_id FROM {$wpdb->prefix}lifterlms_user_postmeta WHERE user_id = %d AND meta_key = '_enrollment_trigger' AND meta_value = %s",
                array( $this->get_id(), 'membership_' . $membership_id )
            ),
            'OBJECT_K'
        ); // db call ok; no-cache ok.

        $courses = array_keys( $q );

        if ( $courses ) {

            // Loop through all the courses and update the enrollment status.
            foreach ( $courses  as $course_id ) {
                if ( ! $delete ) {
                    $this->unenroll( $course_id, 'membership_' . $membership_id, $status );
                } else {
                    $this->delete_enrollment( $course_id, 'membership_' . $membership_id );
                }
            }
        }

    }

    /**
     * Remove a student from a LifterLMS course or membership
     *
     * @since 3.0.0
     * @since 3.26.0 Unknown.
     * @since 3.37.9 Update to accommodate custom post type enrollments added through new filters.
     *               Marked action `llms_user_removed_from_membership_level` as deprecated, use `llms_user_removed_from_membership` instead.
     * @since 6.0.0 Removed the deprecated `llms_user_removed_from_membership_level` action hook
     *              and moved the call to `LLMS_Student::remove_membership_level()` to be before triggering the
     *              `llms_user_removed_from_{$post_type}` action hook.
     *
     * @see llms_unenroll_student()
     *
     * @param  int    $product_id WordPress Post ID of the course or membership.
     * @param  string $trigger    Only remove the student if the original enrollment trigger matches the submitted value.
     *                            Passing `any` will remove regardless of enrollment trigger.
     * @param  string $new_status the value to update the new status with after removal is complete.
     * @return bool
     */
    public function unenroll( $product_id, $trigger = 'any', $new_status = 'expired' ) {

        // Can only unenroll those that are a currently enrolled.
        if ( ! $this->is_enrolled( $product_id, 'all', false ) ) {
            return false;
        }

        // Assume we can't unenroll.
        $update = false;

        // If trigger is "any" we'll unenroll regardless of the trigger.
        if ( 'any' === $trigger ) {

            $update = true;

        } else {

            $enrollment_trigger = $this->get_enrollment_trigger( $product_id );

            // No enrollment trigger exists b/c pre 3.0.0 enrollment, unenroll the user as if it was an 'any' trigger.
            if ( ! $enrollment_trigger ) {

                /**
                 * This filter allows customization of enrollments created prior to version 3.0.0
                 *
                 * Prior to 3.0.0 enrollments did not track an enrollment trigger so any unenrollments
                 * performed on an enrollment in this state will automatically be unenrolled.
                 *
                 * Returning `false` will prevent unenrollments against enrollments which don't have
                 * an enrollment trigger.
                 *
                 * @since 3.0.0
                 *
                 * @param bool $allow_unenrollment If true, allows unenrollment, otherwise prevents unenrollment.
                 */
                $update = apply_filters( 'lifterlms_legacy_unenrollment_action', true );

            } elseif ( $enrollment_trigger === $trigger ) {

                $update = true;

            }
        }

        // Update if we can.
        if ( $update ) {

            // Update enrollment for the product.
            if ( $this->insert_status_postmeta( $product_id, $new_status ) ) {

                // Update the cache.
                $this->cache_set( sprintf( 'enrollment_status_%d', $product_id ), $new_status );
                $this->cache_delete( sprintf( 'date_enrolled_%d', $product_id ) );
                $this->cache_delete( sprintf( 'date_updated_%d', $product_id ) );

                $post_type = str_replace( 'llms_', '', get_post_type( $product_id ) );

                // Run legacy action and trigger cascading unenrollments for membership relationships.
                if ( 'membership' === $post_type ) {

                    // Users should be unenrolled from all courses they accessed through this membership.
                    $this->remove_membership_level( $product_id, $new_status );
                }

                /**
                 * Trigger an action immediately following user unenrollment
                 *
                 * The dynamic portion of this hook, `{$post_type}` corresponds to the post type of the
                 * `$product_id`. Note that any post type prefixed with `llms_` is stripped. For example
                 * when triggered by a membership (`llms_membership`) the hook will be `llms_user_removed_from_membership`.
                 *
                 * @since 3.37.9
                 *
                 * @param int    $user_id    WP_User ID of the student
                 * @param int    $product_id WP_Post ID of the product.
                 * @param string $trigger    Enrollment trigger.
                 * @param string $new_status New enrollment status of the student after the unenrollment has taken place.
                 */
                do_action( "llms_user_removed_from_{$post_type}", $this->get_id(), $product_id, $trigger, $new_status );

                return true;

            }
        }

        // Update was prevented.
        return false;

    }

    /**
     * Delete a student enrollment.
     *
     * @since 3.33.0
     * @since 3.36.2 Added logic to physically remove from the membership level and remove enrollments data on related products.
     * @since 4.2.0 The `$enrollment_trigger` parameter was added to the `llms_user_enrollment_deleted` action hook.
     *
     * @see `llms_delete_student_enrollment()` calls this function without having to instantiate the LLMS_Student class first.
     *
     * @param int    $product_id WP Post ID of the course or membership.
     * @param string $trigger    Optional. Only delete the student's enrollment if the original enrollment trigger matches the submitted value.
     *                           "any" will remove regardless of enrollment trigger. Default "any".
     * @return bool Whether or not the enrollment records have been successfully removed.
     */
    public function delete_enrollment( $product_id, $trigger = 'any' ) {

        // Assume we can't delete the enrollment.
        $delete = false;

        // Get the stored trigger.
        $enrollment_trigger = $this->get_enrollment_trigger( $product_id );

        // Okay to delete if trigger is "any" or if it matches the stored enrollment trigger.
        if ( 'any' === $trigger || $enrollment_trigger === $trigger ) {

            $delete = true;

        } elseif ( ! $enrollment_trigger ) {

            /**
             * Customize the behavior of enrollment deletion for "legacy" orders.
             *
             * These orders were created before version 3.0.0 when there was no stored
             * enrollment trigger.
             *
             * By default, we'll automatically delete these enrollments regardless of trigger.
             *
             * @since 3.33.0
             *
             * @param boolean $delete Whether or not to delete the enrollment.
             */
            $delete = apply_filters( 'lifterlms_legacy_delete_enrollment_action', true );

            // Ensure we have an `$enrollment_trigger` when firing the `llms_user_enrollment_deleted` hook.
            $enrollment_trigger = $trigger;

        }

        // Delete the enrollment.
        if ( $delete && $this->delete_enrollment_postmeta( $product_id ) ) {

            // Clean the cache.
            $this->cache_delete( sprintf( 'enrollment_status_%d', $product_id ) );
            $this->cache_delete( sprintf( 'date_enrolled_%d', $product_id ) );
            $this->cache_delete( sprintf( 'date_updated_%d', $product_id ) );

            if ( 'llms_membership' === get_post_type( $product_id ) ) {
                // Physically remove from the membership level & remove enrollments data on related products.
                $this->remove_membership_level( $product_id, '', true );
            }

            /**
             * Fires after an user enrollment has been deleted.
             *
             * @since 3.33.0
             * @since 4.2.0 The `$enrollment_trigger` parameter was added.
             *
             * @param int    $user_id            WP User ID.
             * @param int    $product_id         WP Post ID of the course or membership.
             * @param string $enrollment_trigger The enrollment trigger.
             */
            do_action( 'llms_user_enrollment_deleted', $this->get_id(), $product_id, $enrollment_trigger );

            // Success.
            return true;

        }

        // Nothing was deleted.
        return false;

    }

    /**
     * Update the completion status of a track, course, section, or lesson for the current student
     *
     * Cascades up to parents and clears progress caches for parents.
     *
     * Triggers actions for completion/incompletion.
     *
     * Inserts / updates necessary user postmeta data.
     *
     * @since 3.17.0
     * @since 4.2.0 Use filterable functions to determine if the object is completable.
     *              Added filter to allow customization of object parent data.
     *
     * @param string $status      New status to update to, either "complete" or "incomplete".
     * @param int    $object_id   WP_Post ID of the object.
     * @param string $object_type The type of object. A lesson, section, course, or course_track.
     * @param string $trigger     String describing the reason for the status change.
     * @return bool
     */
    private function update_completion_status( $status, $object_id, $object_type, $trigger = 'unspecified' ) {

        $student_id = $this->get_id();

        /**
         * Fires before a student's object completion status is updated.
         *
         * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
         * either "complete" or "incomplete"
         *
         * @since Unknown
         *
         * @param int    $student_id  WP_User ID of the student.
         * @param int    $object_id   WP_Post ID of the object.
         * @param string $object_type The type of object. A lesson, section, course, or course_track.
         * @param string $trigger     String describing the reason for the status change.
         */
        do_action( "before_llms_mark_{$status}", $student_id, $object_id, $object_type, $trigger );

        // Retrieve an instance of the objec we're acting on.
        if ( in_array( $object_type, llms_get_completable_post_types(), true ) ) {
            $object = llms_get_post( $object_id );
        } elseif ( in_array( $object_type, llms_get_completable_taxonomies(), true ) ) {
            $object = get_term( $object_id, $object_type );
        } else {
            return false;
        }

        /**
         * Lessons have binary completion (complete or incomplete).
         *
         * Other objects are dependent on their children's statuses. These other object types
         * must check the combined progress of their children to see if it's complete / incomplete.
         */
        $complete = ( 'lesson' === $object_type ) ? ( 'complete' === $status ) : ( 100 == $this->get_progress( $object_id, $object_type, false ) );

        // Get parent information.
        $parent_data = array(
            'ids'  => array(),
            'type' => false,
        );

        // Get the immediate parent so we can cascade up and maybe update the parent's status.
        switch ( $object_type ) {

            case 'lesson':
                $parent_data['ids']  = array( $object->get( 'parent_section' ) );
                $parent_data['type'] = 'section';
                break;

            case 'section':
                $parent_data['ids']  = array( $object->get( 'parent_course' ) );
                $parent_data['type'] = 'course';
                break;

            case 'course':
                $parent_data['ids']  = wp_list_pluck( $object->get_tracks(), 'term_id' );
                $parent_data['type'] = 'course_track';
                break;

        }

        /**
         * Filter the parent data used to cascade object completion up to an object's parent(s).
         *
         * @since 4.2.0
         *
         * @param array  $parent_data {
         *     Array of the object's parent information.
         *
         *     @type int[]  $ids  Object ids for the parent object(s).
         *     @type string $type Object type (course, course_track, etc...).
         * }
         * @param object $object      The object. An `LLMS_Course`, for example.
         * @param int    $ojbect_id   The object's ID.
         * @param string $object_type The object's type.
         */
        $parent_data = apply_filters( 'llms_mark_complete_parent_data', $parent_data, $object, $object_id, $object_type );

        // Reset the cached progress for any objects with children.
        if ( 'lesson' !== $object_type ) {
            $this->set( sprintf( '%1$s_%2$d_progress', $object_type, $object_id ), '' );
        }

        // Reset cache for all parents.
        if ( $parent_data['ids'] && $parent_data['type'] ) {
            foreach ( $parent_data['ids'] as $pid ) {
                $this->set( sprintf( '%1$s_%2$d_progress', $parent_data['type'], $pid ), '' );
            }
        }

        // Determine if an update should be made.
        $update = ( 'complete' === $status && $complete ) || ( 'incomplete' === $status && ! $complete );

        if ( $update ) {

            // Insert meta data.
            if ( 'complete' === $status ) {
                $this->insert_completion_postmeta( $object_id, $trigger );
            } elseif ( 'incomplete' === $status ) {
                $this->insert_incompletion_postmeta( $object_id, $trigger );
            }

            /**
             * Hook that fires when a student's completion status is updated for any object.
             *
             * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
             * either "complete" or "incomplete"
             *
             * @since Unknown
             *
             * @param int    $student_id  WP_User ID of the student.
             * @param int    $object_id   WP_Post ID of the object.
             * @param string $object_type The type of object. A lesson, section, course, or course_track.
             * @param string $trigger     String describing the reason for the status change.
             */
            do_action( "llms_mark_{$status}", $student_id, $object_id, $object_type, $trigger );

            /**
             * Hook that fires when a student's completion status is updated for a specific object type.
             *
             * The dynamic portion of this hook, `$object_type` refers to the WP_Post post_type of the object
             * which the student's completion status is being updated for.
             *
             * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
             * either "complete" or "incomplete"
             *
             * @since Unknown
             *
             * @param int $student_id WP_User ID of the student.
             * @param int $object_id  WP_Post ID of the object.
             */
            do_action( "lifterlms_{$object_type}_{$status}d", $student_id, $object_id );

            // Cascade up for parents.
            if ( $parent_data['ids'] && $parent_data['type'] ) {
                foreach ( $parent_data['ids'] as $pid ) {
                    $this->update_completion_status( $status, $pid, $parent_data['type'], $trigger );
                }
            }

            /**
             * Hook that fires after a student's completion status for an object and it's parents have
             * been updated.
             *
             * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
             * either "complete" or "incomplete"
             *
             * @since Unknown
             *
             * @param int    $student_id  WP_User ID of the student.
             * @param int    $object_id   WP_Post ID of the object.
             * @param string $object_type The type of object. A lesson, section, course, or course_track.
             * @param string $trigger     String describing the reason for the status change.
             */
            do_action( "after_llms_mark_{$status}", $student_id, $object_id, $object_type, $trigger );

        }

        return $update;

    }

    /**
     * Determine if the student has favorited a lesson.
     *
     * @since 7.5.0
     *
     * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
     * @param string $object_type The object type, currently only 'lesson'.
     * @return bool
     */
    public function is_favorite( $object_id, $object_type = 'lesson' ) {

        $query = new LLMS_Query_User_Postmeta(
            array(
                'types'                 => 'favorites',
                'include_post_children' => false,
                'user_id'               => $this->get_id(),
                'post_id'               => $object_id,
                'per_page'              => 1,
            )
        );

        $ret = $query->has_results();

        /**
         * Filter object favorite boolean value prior to returning.
         *
         * The dynamic portion of this filter, `{$object_type}`, refers to the Lesson.
         *
         * @since 7.5.0
         *
         * @param array|false  $ret         Array of favorite data or `false` if no favorite is found.
         * @param int          $object_id   WP Post ID of the object to mark/unmark as favorite.
         * @param string       $object_type The object type, currently only 'lesson'.
         * @param LLMS_Student $instance    The Student Instance
         */
        return apply_filters( 'llms_is_' . $object_type . '_favorite', $ret, $object_id, $object_type, $this );

    }

    /**
     * Mark a lesson favorite for the given user.
     *
     * @since 7.5.0
     *
     * @see llms_mark_favorite() calls this function without having to instantiate the LLMS_Student class first.
     *
     * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
     * @param string $object_type The object type, currently only 'lesson'.
     * @return bool
     */
    public function mark_favorite( $object_id, $object_type ) {

        // Short circuit if it's already favorited.
        if ( $this->is_favorite( $object_id, $object_type ) ) {
            return true;
        }

        return $this->update_favorite_status( 'favorite', $object_id, $object_type );

    }

    /**
     * Mark a lesson unfavorite for the given user.
     *
     * @since 7.5.0
     *
     * @see llms_mark_unfavorite() calls this function without having to instantiate the LLMS_Student class first.
     *
     * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
     * @param string $object_type The object type, currently only 'lesson'.
     * @return bool
     */
    public function mark_unfavorite( $object_id, $object_type ) {

        // Short circuit if it's not favorited.
        if ( ! $this->is_favorite( $object_id, $object_type ) ) {
            return true;
        }

        return $this->update_favorite_status( 'unfavorite', $object_id, $object_type );

    }

    /**
     * Triggers actions for favorite/unfavorite.
     *
     * Update the favorite status of a lesson for the current student.
     * Inserts / updates necessary user postmeta data.
     *
     * @since 7.5.0 Use filterable functions to determine if the object can be marked favorite.
     *
     * @param string $status      New status to update to, either "favorite" or "unfavorite".
     * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
     * @param string $object_type The object type, currently only 'lesson'.
     * @return bool
     */
    private function update_favorite_status( $status, $object_id, $object_type ) {

        $student_id = $this->get_id();

        /**
         * Fires before a student's object favorite status is updated.
         *
         * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
         * either "favorite" or "unfavorite".
         *
         * @since 7.5.0
         *
         * @param int    $student_id  WP_User ID of the student.
         * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
         * @param string $object_type The object type, currently only 'lesson'.
         */
        do_action( "before_llms_mark_{$status}", $student_id, $object_id, $object_type );

        // Insert / Remove meta data.
        if ( 'favorite' === $status ) {
            $this->insert_favorite_postmeta( $object_id );
        } elseif ( 'unfavorite' === $status ) {
            $this->remove_favorite_postmeta( $object_id );
        }

        /**
         * Hook that fires when a student's favorite status is updated for any object.
         *
         * The dynamic portion of this hook, `$status`, refers to the new favorite status of the object,
         * either "favorite" or "unfavorite".
         *
         * @since 7.5.0
         *
         * @param int    $student_id  WP_User ID of the student.
         * @param int    $object_id   WP Post ID of the object to mark/unmark as favorite.
         * @param string $object_type The object type, currently only 'lesson'.
         */
        do_action( "llms_mark_{$status}", $student_id, $object_id, $object_type );

        /**
         * Hook that fires when a student's favorite status is updated for a specific object type.
         *
         * The dynamic portion of this hook, `$object_type` refers to the WP_Post post_type of the object
         * which the student's completion status is being updated for.
         *
         * The dynamic portion of this hook, `$status`, refers to the new completion status of the object,
         * either "favorite" or "unfavorite".
         *
         * @since 7.5.0
         *
         * @param int $student_id WP_User ID of the student.
         * @param int $object_id  WP_Post ID of the object.
         */
        do_action( "lifterlms_{$object_type}_{$status}d", $student_id, $object_id );

        return true;

    }

}