<?php
/**
* File containing the class Sensei_Course_Enrolment_Manager.
*
* @package sensei
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Singleton handling the management of enrolment for all courses.
*/
class Sensei_Course_Enrolment_Manager {
const COURSE_ENROLMENT_SITE_SALT_OPTION = 'sensei_course_enrolment_site_salt';
const LEARNER_CALCULATION_META_NAME = 'sensei_learner_calculated_version';
/**
* Update this when releasing a version that requires user enrolment to get checked.
* Calculated and stored enrolments will only get updated if enrolment provider
* versions have changed.
*/
const ENROLMENT_VERSION = '3.8.1';
/**
* Instance of singleton.
*
* @var self
*/
private static $instance;
/**
* All course enrolment providers.
*
* @var Sensei_Course_Enrolment_Provider_Interface[]
*/
private $enrolment_providers;
/**
* Hash of all the enrolment provider versions.
*
* @var string
*/
private $enrolment_providers_versions_hash;
/**
* Deferred enrolment checks.
*
* @var array
*/
private $deferred_enrolment_checks = [];
/**
* Fetches an instance of the class.
*
* @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.
*/
public function init() {
add_action( 'plugins_loaded', [ $this, 'detect_wcpc_1' ], 100 );
add_action( 'init', [ $this, 'collect_enrolment_providers' ], 100 );
add_action( 'shutdown', [ $this, 'run_deferred_course_enrolment_checks' ] );
add_action( 'sensei_enrolment_results_calculated', [ $this, 'remove_deferred_enrolment_check' ], 10, 3 );
add_filter( 'sensei_can_user_manually_enrol', [ $this, 'maybe_prevent_frontend_manual_enrol' ], 10, 2 );
add_action( 'sensei_before_learners_enrolled_courses_query', [ $this, 'recalculate_enrolments' ] );
add_action( 'transition_post_status', [ $this, 'recalculate_on_course_post_status_change' ], 10, 3 );
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
add_action( 'shutdown', [ Sensei_Enrolment_Provider_State_Store::class, 'persist_all' ] );
add_action( 'shutdown', [ Sensei_Enrolment_Provider_Journal_Store::class, 'persist_all' ] );
}
}
/**
* Collects and initializes enrolment providers. Hooked late into `init`.
*
* Do not call outside of this class.
*
* @access private
*/
public function collect_enrolment_providers() {
if ( isset( $this->enrolment_providers ) ) {
return;
}
$this->enrolment_providers = [];
// Manual enrolment is Sensei's core enrolment provider.
$providers = [
Sensei_Course_Manual_Enrolment_Provider::instance(),
];
/**
* Fetch all registered course enrolment providers.
*
* @since 3.0.0
*
* @hook sensei_course_enrolment_providers
*
* @param {Sensei_Course_Enrolment_Provider_Interface[]} $providers List of enrolment providers instances.
* @return {Sensei_Course_Enrolment_Provider_Interface[]} Filtered list of enrolment providers instances.
*/
$providers = apply_filters( 'sensei_course_enrolment_providers', $providers );
foreach ( $providers as $provider ) {
if ( ! ( $provider instanceof Sensei_Course_Enrolment_Provider_Interface ) ) {
continue;
}
$this->enrolment_providers[ $provider->get_id() ] = $provider;
}
}
/**
* Generates a hash of all the enrolment provider versions.
*
* @return string
*/
public function get_enrolment_provider_versions_hash() {
if ( ! isset( $this->enrolment_providers_versions_hash ) ) {
$versions = [];
foreach ( $this->get_all_enrolment_providers() as $enrolment_provider ) {
if ( ! ( $enrolment_provider instanceof Sensei_Course_Enrolment_Provider_Interface ) ) {
continue;
}
$enrolment_provider_class = get_class( $enrolment_provider );
$versions[ $enrolment_provider_class ] = $enrolment_provider->get_version();
}
ksort( $versions );
$this->enrolment_providers_versions_hash = md5( wp_json_encode( $versions ) );
}
return $this->enrolment_providers_versions_hash;
}
/**
* Gets the descriptive name of the provider by ID.
*
* @param string $provider_id Unique identifier of the enrolment provider.
*
* @return string|false
* @throws Exception When there was an attempt to access enrolment providers before they are collected in init:100.
*/
public function get_enrolment_provider_name_by_id( $provider_id ) {
$provider = $this->get_enrolment_provider_by_id( $provider_id );
if ( ! $provider ) {
return false;
}
return $provider->get_name();
}
/**
* Gets the enrolment provider object by its ID.
*
* @param string $provider_id Unique identifier of the enrolment provider.
*
* @return Sensei_Course_Enrolment_Provider_Interface|false
* @throws Exception When there was an attempt to access enrolment providers before they are collected in init:100.
*/
public function get_enrolment_provider_by_id( $provider_id ) {
$all_providers = $this->get_all_enrolment_providers();
if ( ! isset( $all_providers[ $provider_id ] ) ) {
return false;
}
return $all_providers[ $provider_id ];
}
/**
* Get an array of all the instantiated course enrolment providers.
*
* @return Sensei_Course_Enrolment_Provider_Interface[]
* @throws Exception When there was an attempt to access enrolment providers before they are collected in init:100.
*/
public function get_all_enrolment_providers() {
if ( ! isset( $this->enrolment_providers ) ) {
throw new Exception( 'Enrolment providers were asked for before they were collected late in `init`' );
}
return $this->enrolment_providers;
}
/**
* Get the manual enrolment provider.
*
* @return false|Sensei_Course_Manual_Enrolment_Provider
* @throws Exception When there was an attempt to access the manual enrolment providers before providers are collected in init:100.
*/
public function get_manual_enrolment_provider() {
return $this->get_enrolment_provider_by_id( Sensei_Course_Manual_Enrolment_Provider::instance()->get_id() );
}
/**
* Check if currently logged in user can manually enrol themselves. Block enrolment when a non-manual
* provider handles enrolment.
*
* @param bool $can_user_manually_enrol True if they can manually enrol themselves, false if not.
* @param int $course_id Course post ID.
*
* @return bool
*/
public function maybe_prevent_frontend_manual_enrol( $can_user_manually_enrol, $course_id ) {
$all_providers = $this->get_all_enrolment_providers();
// If the manual provider has been filtered out, do not allow frontend enrolment.
if ( ! isset( $all_providers[ Sensei_Course_Manual_Enrolment_Provider::instance()->get_id() ] ) ) {
return false;
}
unset( $all_providers[ Sensei_Course_Manual_Enrolment_Provider::instance()->get_id() ] );
/**
* Filter providers that can handle frontend enrolment.
*
* It is used to filter providers that can affect enrolment on the frontend.
*
* @since 4.5.2
*
* @hook sensei_course_enrolment_providers_prevent_manual_enrol
*
* @param {Sensei_Course_Enrolment_Provider_Interface[]} $providers List of enrolment providers instances.
* @return {Sensei_Course_Enrolment_Provider_Interface[]} Filtered list of enrolment providers instances.
*/
$all_providers = apply_filters( 'sensei_course_enrolment_providers_prevent_manual_enrol', $all_providers );
foreach ( $all_providers as $provider ) {
if ( $provider->handles_enrolment( $course_id ) ) {
// One of the other providers handles enrolment. Prevent enrolment on the frontend form.
return false;
}
}
return $can_user_manually_enrol;
}
/**
* Run the deferred enrolment checks.
*
* @access private
*/
public function run_deferred_course_enrolment_checks() {
foreach ( $this->deferred_enrolment_checks as $user_id => $course_ids ) {
foreach ( array_keys( $course_ids ) as $course_id ) {
$this->do_course_enrolment_check( $user_id, $course_id );
}
}
}
/**
* When enrolment calculation happens, remove it from deferred calculation.
*
* @param Sensei_Course_Enrolment_Provider_Results $enrolment_results Enrolment results object.
* @param int $course_id Course post ID.
* @param int $user_id User ID.
*/
public function remove_deferred_enrolment_check( Sensei_Course_Enrolment_Provider_Results $enrolment_results, $course_id, $user_id ) {
if ( isset( $this->deferred_enrolment_checks[ $user_id ] ) ) {
unset( $this->deferred_enrolment_checks[ $user_id ][ $course_id ] );
}
}
/**
* Defer course enrolment check to the end of request.
*
* @param int $user_id User ID.
* @param int $course_id Course post ID.
*/
private function defer_course_enrolment_check( $user_id, $course_id ) {
if ( ! isset( $this->deferred_enrolment_checks[ $user_id ] ) ) {
$this->deferred_enrolment_checks[ $user_id ] = [];
}
// Check if the enrolment check is already deferred.
if ( isset( $this->deferred_enrolment_checks[ $user_id ][ $course_id ] ) ) {
return;
}
// Usually the user will be back calculated by the end of the request, but mark them
// as needing a recalculation just in case the request fails early.
$this->mark_user_as_needing_recalculation( $user_id );
$this->invalidate_learner_result( $user_id, $course_id );
$this->deferred_enrolment_checks[ $user_id ][ $course_id ] = true;
}
/**
* Trigger course enrolment check when enrolment might have changed.
*
* @param int $user_id User ID.
* @param int $course_id Course post ID.
*/
private function do_course_enrolment_check( $user_id, $course_id ) {
$course_enrolment = Sensei_Course_Enrolment::get_course_instance( $course_id );
if ( $course_enrolment ) {
$course_enrolment->is_enrolled( $user_id, false );
}
}
/**
* Invalidate an enrolment result so that it gets recalculated next time it is requested.
*
* @param int $user_id User ID.
* @param int $course_id Course post ID.
*/
private function invalidate_learner_result( $user_id, $course_id ) {
$course_enrolment = Sensei_Course_Enrolment::get_course_instance( $course_id );
if ( $course_enrolment ) {
$course_enrolment->invalidate_learner_result( $user_id );
}
}
/**
* Gets the site course enrolment salt that can be used to invalidate all enrolments.
*
* @return string
*/
public function get_site_salt() {
$enrolment_salt = get_option( self::COURSE_ENROLMENT_SITE_SALT_OPTION );
if ( ! $enrolment_salt ) {
return $this->reset_site_salt();
}
return $enrolment_salt;
}
/**
* Resets the site course enrolment salt. If already set, this will invalidate all current course enrolment results.
*
* @return string
*/
public function reset_site_salt() {
$new_salt = md5( uniqid() );
update_option( self::COURSE_ENROLMENT_SITE_SALT_OPTION, $new_salt, true );
return $new_salt;
}
/**
* Trigger course enrolment check when enrolment might have changed.
*
* @param int $user_id User ID.
* @param int $course_id Course post ID.
*/
public static function trigger_course_enrolment_check( $user_id, $course_id ) {
$instance = self::instance();
if ( self::should_defer_enrolment_check() ) {
$instance->defer_course_enrolment_check( $user_id, $course_id );
return;
}
$instance->do_course_enrolment_check( $user_id, $course_id );
}
/**
* Check if we should defer enrolment checks.
*
* @return bool
*/
private static function should_defer_enrolment_check() {
$should_defer_enrolment_check = true;
// If this is called during a cron job, do not defer the enrolment check.
if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
$should_defer_enrolment_check = false;
}
// If the `shutdown` action has already been fired, do not defer the enrolment check.
if ( did_action( 'shutdown' ) ) {
$should_defer_enrolment_check = false;
}
/**
* Override deferment handling for enrolment checking.
*
* @since 3.0.0
*
* @hook sensei_should_defer_enrolment_check
*
* @param {bool} $should_defer_enrolment_check True if enrolment checks should be deferred to the end of the request.
* @return {bool} Filtered value.
*/
return apply_filters( 'sensei_should_defer_enrolment_check', $should_defer_enrolment_check );
}
/**
* Recalculate all enrolments for a specific user. This method will return the cached enrolments if they already
* exist. To enforce a calculation after a possible change, use
* Sensei_Course_Enrolment_Manager::trigger_course_enrolment_check instead.
*
* @param int $user_id User ID.
*
* @see Sensei_Course_Enrolment_Manager::trigger_course_enrolment_check
*/
public function recalculate_enrolments( $user_id ) {
$learner_calculated_version = get_user_meta( $user_id, self::get_learner_calculated_version_meta_key(), true );
if ( $this->get_enrolment_calculation_version() === $learner_calculated_version ) {
return;
}
$course_args = [
'post_type' => 'course',
'post_status' => 'publish',
'posts_per_page' => - 1,
'fields' => 'ids',
];
$courses = get_posts( $course_args );
if ( empty( $courses ) ) {
return;
}
foreach ( $courses as $course ) {
Sensei_Course_Enrolment::get_course_instance( $course )->is_enrolled( $user_id );
}
update_user_meta(
$user_id,
self::get_learner_calculated_version_meta_key(),
$this->get_enrolment_calculation_version()
);
}
/**
* Trigger course enrolment recalculation when post status changes.
*
* @param string $new_status New post status.
* @param string $old_status Old post status.
* @param WP_Post $post Post object.
*/
public function recalculate_on_course_post_status_change( $new_status, $old_status, $post ) {
if (
$new_status === $old_status // No change in status.
|| 'course' !== $post->post_type // Not a course.
|| 'new' === $old_status // This is initial creation.
|| (
'publish' !== $new_status // Not changing between published and not published.
&& 'publish' !== $old_status
)
) {
return;
}
$course_enrolment = Sensei_Course_Enrolment::get_course_instance( $post->ID );
$course_enrolment->recalculate_enrolment();
}
/**
* Mark a user as needing recalculation.
*
* @param int $user_id User ID.
*/
public function mark_user_as_needing_recalculation( $user_id ) {
delete_user_meta( $user_id, self::get_learner_calculated_version_meta_key() );
}
/**
* Returns the enrolment calculation version string.
*
* @return string The calculation version.
*/
public function get_enrolment_calculation_version() {
$hash_components = [];
$hash_components[] = $this->get_site_salt();
$hash_components[] = $this->get_enrolment_provider_versions_hash();
return md5( implode( '-', $hash_components ) ) . '-' . self::ENROLMENT_VERSION;
}
/**
* Get the learner calculated meta key.
*
* @return string
*/
public static function get_learner_calculated_version_meta_key() {
global $wpdb;
return $wpdb->get_blog_prefix() . self::LEARNER_CALCULATION_META_NAME;
}
/**
* Detect WooCommerce Paid Courses v1 and disable the enrolment functionality while it is installed.
*
* @access private
*/
public function detect_wcpc_1() {
if (
! defined( 'SENSEI_WC_PAID_COURSES_VERSION' )
|| '1.' !== substr( SENSEI_WC_PAID_COURSES_VERSION, 0, 2 )
) {
return;
}
// Disable enrolment system until WCPC is deactivated or upgraded.
add_filter( 'sensei_is_enrolled', '__return_false' );
add_filter( 'sensei_course_enrolment_providers', '__return_empty_array', 100 );
// Show admin notice.
add_action( 'admin_notices', [ $this, 'add_wcpc_1_notice' ] );
}
/**
* Adds notice in WP Admin when we detect WooCommerce Paid Courses v1 is installed.
*
* @access private
*/
public function add_wcpc_1_notice() {
$screen = get_current_screen();
$valid_screens = [ 'dashboard', 'plugins', 'plugins-network', 'sensei-lms_page_sensei_learners' ];
if ( ! current_user_can( 'activate_plugins' ) || ! in_array( $screen->id, $valid_screens, true ) ) {
return;
}
$message = __( '<strong>Sensei LMS</strong> has detected an incompatible version of WooCommerce Paid Courses. Students will not be able to access their courses until it is upgraded to version 2.0.0 or greater.', 'sensei-lms' );
echo '<div class="error"><p>';
echo wp_kses( $message, [ 'strong' => [] ] );
echo '</p></div>';
}
}