<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
/**
* Sensei Course Class
*
* All functionality pertaining to the Courses Post Type in Sensei.
*
* @package Content
* @author Automattic
* @since 1.0.0
*/
class Sensei_Course {
/**
* The showcase courses upsell page slug.
*/
const SHOWCASE_COURSES_SLUG = 'sensei_showcase_courses';
/**
* @var $token
*/
public $token;
/**
* @var array $meta_fields
*/
public $meta_fields;
/**
* @var string|bool $my_courses_page reference to the sites
* my courses page, false if none was set
*/
public $my_courses_page;
/**
* Course ID being saved, if no resave is needed.
* The resave will be needed when updating a course with
* outline block which needs id sync.
*
* @since 3.6.0
*
* @var int
*/
private $course_id_updating;
/**
* @var array The HTML allowed for message boxes.
*/
public static $allowed_html;
/**
* Constructor.
*
* @since 1.0.0
*/
public function __construct() {
$this->token = 'course';
add_action( 'init', [ $this, 'set_up_meta_fields' ] );
add_action( 'rest_api_init', [ $this, 'add_author_support' ] );
// Admin actions
if ( is_admin() ) {
// Metabox functions
add_action( 'add_meta_boxes', [ $this, 'meta_box_setup' ], 20 );
add_action( 'save_post', [ $this, 'meta_box_save' ] );
// Custom Write Panel Columns
add_filter( 'manage_course_posts_columns', [ $this, 'add_column_headings' ], 20, 1 );
add_action( 'manage_course_posts_custom_column', [ $this, 'add_column_data' ], 10, 2 );
// Enqueue scripts.
add_action( 'admin_enqueue_scripts', [ $this, 'register_admin_scripts' ] );
} else {
$this->my_courses_page = false;
}
self::$allowed_html = [
'embed' => [],
'iframe' => [
'width' => [],
'height' => [],
'src' => [],
'frameborder' => [],
'allowfullscreen' => [],
],
'video' => Sensei_Wp_Kses::get_video_html_tag_allowed_attributes(),
'source' => Sensei_Wp_Kses::get_source_html_tag_allowed_attributes(),
];
// Update course completion upon completion of a lesson
add_action( 'sensei_user_lesson_end', [ $this, 'update_status_after_lesson_change' ], 10, 2 );
// Update course completion upon reset of a lesson
add_action( 'sensei_user_lesson_reset', [ $this, 'update_status_after_lesson_change' ], 10, 2 );
// Update course completion upon grading of a quiz
add_action( 'sensei_user_quiz_grade', [ $this, 'update_status_after_quiz_submission' ], 10, 2 );
// provide an option to block all emails related to a selected course
add_filter( 'sensei_send_emails', [ $this, 'block_notification_emails' ] );
add_action( 'save_post', [ $this, 'save_course_notification_meta_box' ] );
// Log course content counter.
add_action( 'save_post_course', [ $this, 'mark_updating_course_id' ], 10, 2 );
add_action( 'shutdown', [ $this, 'log_course_update' ] );
add_action( 'rest_api_init', [ $this, 'disable_log_course_update' ] );
// preview lessons on the course content
add_action( 'sensei_course_content_inside_after', [ $this, 'the_course_free_lesson_preview' ] );
// the course meta
add_action( 'sensei_course_content_inside_before', [ $this, 'the_course_meta' ] );
// The course enrolment actions.
add_action( 'sensei_output_course_enrolment_actions', [ __CLASS__, 'output_course_enrolment_actions' ] );
// add the user status on the course to the markup as a class
add_filter( 'post_class', [ __CLASS__, 'add_course_user_status_class' ], 20, 3 );
// filter the course query in Sensei specific instances
add_filter( 'pre_get_posts', [ __CLASS__, 'course_query_filter' ] );
// attache the sorting to the course archive
add_action( 'sensei_archive_before_course_loop', [ 'Sensei_Course', 'course_archive_sorting' ] );
// attach the filter links to the course archive
add_action( 'sensei_archive_before_course_loop', [ 'Sensei_Course', 'course_archive_filters' ] );
// Filter the course query when featured filter is applied.
add_filter( 'pre_get_posts', [ __CLASS__, 'course_archive_featured_filter' ] );
// Filter by course category when category filter is applied.
add_filter( 'pre_get_posts', [ __CLASS__, 'course_archive_category_filter' ] );
// Filter by student course state when student course filter is applied.
add_filter( 'pre_get_posts', [ __CLASS__, 'course_archive_student_course_state_filter' ] );
// Handle the ordering for the courses archive page.
add_filter( 'pre_get_posts', [ __CLASS__, 'course_archive_set_order_by' ], 10, 1 );
// ensure the course category page respects the manual order set for courses
add_filter( 'pre_get_posts', [ __CLASS__, 'alter_course_category_order' ], 10, 1 );
// Filter the redirect url after enrolment.
add_filter( 'sensei_start_course_redirect_url', [ __CLASS__, 'alter_redirect_url_after_enrolment' ], 10, 2 );
// Allow course archive to be setup as the home page
if ( (int) get_option( 'page_on_front' ) > 0 ) {
add_action( 'pre_get_posts', [ $this, 'allow_course_archive_on_front_page' ], 9, 1 );
}
// Log event on the initial publish for a course.
add_action( 'sensei_course_initial_publish', [ $this, 'log_initial_publish_event' ] );
add_action( 'template_redirect', [ $this, 'setup_single_course_page' ] );
add_action( 'sensei_loaded', [ $this, 'add_legacy_course_hooks' ] );
// Showcase courses upsell.
add_action( 'admin_menu', [ $this, 'add_showcase_courses_upsell' ] );
add_filter( 'admin_title', [ $this, 'showcase_courses_upsell_title' ] );
// Add custom navigation to edit pages.
add_action( 'in_admin_header', [ $this, 'add_custom_navigation' ] );
add_action( 'template_redirect', array( $this, 'maybe_redirect_to_login_from_course_completion' ) );
}
/**
* Check user permission for editing a course.
*
* @param int $course_id Course post ID.
*
* @return bool Whether the user can edit the course.
*/
public static function can_current_user_edit_course( $course_id ) {
return current_user_can( get_post_type_object( 'course' )->cap->edit_post, $course_id );
}
/**
* Highlight the menu item for the course pages.
*
* @deprecated 4.8.0
*
* @since 4.0.0
* @access private
*
* @param string $submenu_file The submenu file points to the certain item of the submenu.
*
* @return string
*/
public function highlight_menu_item( $submenu_file ) {
_deprecated_function( __METHOD__, '4.8.0' );
$screen = get_current_screen();
if ( $screen && in_array( $screen->id, [ 'edit-course', 'edit-course-category', 'course_page_course-order' ], true ) ) {
$submenu_file = 'edit.php?post_type=course';
}
return $submenu_file;
}
/**
* Add showcase courses upsell page.
*
* @since 4.12.0
* @internal
*/
public function add_showcase_courses_upsell() {
add_submenu_page(
'',
__( 'Showcase Courses', 'sensei-lms' ),
__( 'Showcase Courses', 'sensei-lms' ),
'edit_courses',
self::SHOWCASE_COURSES_SLUG,
[ $this, 'showcase_courses_screen' ]
);
}
/**
* Add showcase courses upsell screen.
*
* @since 4.12.0
* @internal
*/
public function showcase_courses_screen() {
// Get the price of Pro. Return if it's not available.
$sensei_pro_product = Sensei_Extensions::instance()->get_extension( Sensei_Extensions::PRODUCT_SENSEI_PRO_SLUG );
$sensei_pro_price = $sensei_pro_product ? str_replace( '.00', '', $sensei_pro_product->price ) : '-';
// Enqueue styles.
Sensei()->assets->enqueue( 'sensei-showcase-upsell', 'css/showcase-upsell.css' );
$illustration = Sensei()->assets->get_image( 'showcase-courses-upsell-illustration.png' );
?>
<div class="wrap">
<?php
$screen = get_current_screen();
$tabs = $this->get_course_custom_navigation_tabs();
$this->display_courses_navigation( $screen, $tabs );
?>
<div class="sensei-showcase-upsell">
<div class="sensei-showcase-upsell__content">
<h1 class="sensei-showcase-upsell__title"><?php esc_html_e( 'Showcase your course with Sensei Pro!', 'sensei-lms' ); ?></h1>
<p class="sensei-showcase-upsell__description"><?php esc_html_e( 'Gain visibility by promoting your post on Sensei Showcase Gallery page.', 'sensei-lms' ); ?></p>
<ul class="sensei-showcase-upsell__list">
<li class="sensei-showcase-upsell__list-item dashicons-before"><?php esc_html_e( 'Free traffic redirected to your site', 'sensei-lms' ); ?></li>
<li class="sensei-showcase-upsell__list-item dashicons-before"><?php esc_html_e( 'WooCommerce integration', 'sensei-lms' ); ?></li>
<li class="sensei-showcase-upsell__list-item dashicons-before"><?php esc_html_e( 'Schedule ‘drip’ content', 'sensei-lms' ); ?></li>
<li class="sensei-showcase-upsell__list-item dashicons-before"><?php esc_html_e( 'Set expiration date of courses', 'sensei-lms' ); ?></li>
<li class="sensei-showcase-upsell__list-item dashicons-before"><?php esc_html_e( 'Interactive Blocks', 'sensei-lms' ); ?></li>
</ul>
<div class="sensei-showcase-upsell__price-wrapper">
<span class="sensei-showcase-upsell__price">
<?php
// translators: Placeholder is the price of Sensei Pro.
echo esc_html( sprintf( __( '%s USD', 'sensei-lms' ), $sensei_pro_price ) );
?>
</span>
<span class="sensei-showcase-upsell__price-period"><?php esc_html_e( 'per year, 1 site', 'sensei-lms' ); ?></span>
</div>
<ul class="sensei-showcase-upsell__buttons">
<li><a href="https://senseilms.com/sensei-pro/?utm_source=plugin_sensei&utm_medium=upsell&utm_campaign=showcase" class="sensei-showcase-upsell__button sensei-showcase-upsell__button--primary" target="_blank" rel="noreferrer"><?php esc_html_e( 'Get Sensei Pro', 'sensei-lms' ); ?></a></li>
<li><a href="https://senseilms.com/documentation/showcase/?utm_source=plugin_sensei&utm_medium=upsell&utm_campaign=showcase" class="sensei-showcase-upsell__button sensei-showcase-upsell__button--secondary" target="_blank" rel="noreferrer"><?php esc_html_e( 'Learn more', 'sensei-lms' ); ?></a></li>
</ul>
</div>
<div class="sensei-showcase-upsell__illustration-wrapper">
<img class="sensei-showcase-upsell__illustration" src="<?php echo esc_url( $illustration ); ?>" alt="<?php echo esc_attr( __( 'Illustration for Showcase Courses', 'sensei-lms' ) ); ?>" />
</div>
</div>
</div>
<?php
}
/**
* Filter the showcase courses page title. It's needed because submenu pages
* without a parent menu item don't have a title.
*
* @since 4.12.0
* @internal
*
* @param string $admin_title The page title, with extra context added.
*
* @return string The page title.
*/
public function showcase_courses_upsell_title( $admin_title ) {
$screen = get_current_screen();
if ( ! $screen ) {
return $admin_title;
}
if ( 'admin_page_' . self::SHOWCASE_COURSES_SLUG === $screen->id ) {
return __( 'Showcase Courses', 'sensei-lms' ) . $admin_title;
}
return $admin_title;
}
/**
* Add custom navigation to the edit pages.
* It ignores admin pages (admin.php) because it has a different structure.
*
* @since 4.0.0
*
* @internal
*/
public function add_custom_navigation() {
$screen = get_current_screen();
if ( ! $screen ) {
return;
}
$tabs = $this->get_course_custom_navigation_tabs();
$screen_ids = wp_list_pluck( $tabs, 'screen_id' );
// Exclude admin pages (admin.php) because `in_admin_header` hook
// doesn't work properly for this case.
$screen_ids = array_filter( $screen_ids, [ $this, 'not_admin_page' ] );
if ( in_array( $screen->id, $screen_ids, true ) && 'term' !== $screen->base ) {
$this->display_courses_navigation( $screen, $tabs );
}
}
/**
* Get course custom navigation tabs.
*
* @return array Array of tabs.
*/
private function get_course_custom_navigation_tabs() {
$tabs = [
'all-courses' => [
'label' => __( 'All Courses', 'sensei-lms' ),
'url' => admin_url( 'edit.php?post_type=course' ),
'screen_id' => 'edit-course',
],
'course-categories' => [
'label' => __( 'Course Categories', 'sensei-lms' ),
'url' => admin_url( 'edit-tags.php?taxonomy=course-category&post_type=course' ),
'screen_id' => 'edit-course-category',
],
'showcase-courses' => [
'label' => __( 'Showcase Courses', 'sensei-lms' ),
'url' => admin_url( 'admin.php?page=' . self::SHOWCASE_COURSES_SLUG ),
'screen_id' => 'admin_page_' . self::SHOWCASE_COURSES_SLUG,
'badge' => __( 'Pro', 'sensei-lms' ),
],
];
/**
* Filters the tabs that the user can see on the Courses page.
*
* @since 4.12.0
*
* @hook sensei_course_custom_navigation_tabs
*
* @param {Array} $tabs The list of tabs that the user should see on the Courses page.
* @return {Array} The list of tabs to render for the user on the Courses page.
*/
$tabs = apply_filters( 'sensei_course_custom_navigation_tabs', $tabs );
return $tabs;
}
/**
* Check if the screen ID is not an admin page.
* Used to filter array, excluding admin pages.
*
* @param string $screen_id The screen ID.
*
* @return boolean Whether the screen ID should show.
*/
private function not_admin_page( $screen_id ) {
return 0 !== strpos( $screen_id, 'admin_page_' );
}
/**
* Display the courses' navigation.
*
* @param WP_Screen $screen WordPress current screen object.
* @param array $tabs List of tabs to show.
*/
private function display_courses_navigation( WP_Screen $screen, array $tabs ) {
/**
* Filter courses navigation sidebar content.
*
* @since 4.12.0
*
* @hook sensei_courses_navigation_sidebar
*
* @param {string} $content The content to be displayed in the sidebar.
* @param {WP_Screen} $screen The current screen.
* @return {string} The content to be displayed in the sidebar.
*/
$navigation_sidebar = apply_filters( 'sensei_courses_navigation_sidebar', '', $screen );
?>
<div id="sensei-custom-navigation" class="sensei-custom-navigation">
<div class="sensei-custom-navigation__heading">
<div class="sensei-custom-navigation__title">
<h1><?php esc_html_e( 'Courses', 'sensei-lms' ); ?></h1>
</div>
<div class="sensei-custom-navigation__links">
<a class="page-title-action" href="<?php echo esc_url( admin_url( 'post-new.php?post_type=course' ) ); ?>"><?php esc_html_e( 'New Course', 'sensei-lms' ); ?></a>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=course-order' ) ); ?>"><?php esc_html_e( 'Order Courses', 'sensei-lms' ); ?></a>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=sensei-settings&tab=course-settings' ) ); ?>"><?php esc_html_e( 'Course Settings', 'sensei-lms' ); ?></a>
</div>
</div>
<div class="sensei-custom-navigation__tabbar">
<?php
foreach ( $tabs as $tab ) {
?>
<a class="sensei-custom-navigation__tab <?php echo $screen->id === $tab['screen_id'] ? 'active' : ''; ?>" href="<?php echo esc_url( $tab['url'] ); ?>">
<?php echo esc_html( $tab['label'] ); ?>
<?php
if ( isset( $tab['badge'] ) ) {
?>
<span class="sensei-custom-navigation__badge"><?php echo esc_html( $tab['badge'] ); ?></span>
<?php
}
?>
</a>
<?php
}
?>
<?php
if ( ! empty( $navigation_sidebar ) ) {
?>
<div class="sensei-custom-navigation__separator"></div>
<?php
echo $navigation_sidebar; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Content escaped in filter.
}
?>
</div>
</div>
<?php
}
/**
* Register and enqueue scripts and styles that are needed in the backend.
*
* @access private
* @since 2.1.0
*/
public function register_admin_scripts() {
$screen = get_current_screen();
if ( ! $screen ) {
return;
}
if ( 'course' === $screen->id ) {
Sensei()->assets->enqueue( 'sensei-admin-course-edit', 'js/admin/course-edit.js', [ 'jquery', 'sensei-core-select2' ], true );
Sensei()->assets->enqueue( 'sensei-admin-course-edit-styles', 'css/course-editor.css' );
$settings_json = wp_json_encode( \Sensei()->settings->get_settings() );
wp_add_inline_script(
'sensei-admin-course-edit',
sprintf( 'window.sensei = window.sensei || {}; window.sensei.senseiSettings = %s;', $settings_json ),
'before'
);
$settings_sidebar = wp_json_encode( self::get_course_settings_sidebar_vars() );
wp_add_inline_script(
'sensei-admin-course-edit',
sprintf( 'window.sensei = window.sensei || {}; window.sensei.courseSettingsSidebar = %s;', $settings_sidebar ),
'before'
);
}
if ( 'edit-course' === $screen->id ) {
Sensei()->assets->enqueue( 'sensei-admin-course-index', 'js/admin/course-index.js', [ 'jquery' ], true );
}
}
/**
* Get Course Settings Sidebar Variables.
*
* @return array
*/
public static function get_course_settings_sidebar_vars() {
$course_id = get_the_ID();
return [
'nonce_value' => wp_create_nonce( Sensei()->teacher::NONCE_ACTION_NAME ),
'nonce_name' => Sensei()->teacher::NONCE_FIELD_NAME,
'teachers' => Sensei()->teacher->get_teachers_and_authors_with_fields( [ 'ID', 'display_name' ] ),
'features' => [
/**
* Filters whether the course feature to allow students to preview lessons is enabled.
*
* @hook sensei_feature_course_preview_lessons
*
* @param {bool} $enabled Whether the feature is enabled.
* @return {bool} Whether the feature is enabled.
*/
'open_access' => apply_filters( 'sensei_feature_open_access_courses', true ),
],
'courses' => get_posts(
[
'post_type' => 'course',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'DESC',
'exclude' => $course_id,
'suppress_filters' => 0,
'post_status' => 'any',
]
),
'author' => absint( get_post( $course_id )->post_author ),
];
}
/**
* Check if a user is enrolled in a course.
*
* @since 3.0.0
*
* @param int $course_id Course post ID.
* @param int|null $user_id User ID.
* @return bool
*/
public static function is_user_enrolled( $course_id, $user_id = null ) {
/**
* Filters the course ID for the `Sensei_Course::is_user_enrolled` method.
*
* @hook sensei_block_course_progress_course_id
*
* @since 4.23.1
*
* @param {int} $course_id The course ID.
* @return {int} The course ID.
*/
$course_id = (int) apply_filters( 'sensei_course_is_user_enrolled_course_id', $course_id );
if ( empty( $course_id ) ) {
return false;
}
if ( 'course' !== get_post_type( $course_id ) ) {
return false;
}
if ( ! $user_id ) {
$user_id = get_current_user_id();
}
$course_enrolment = Sensei_Course_Enrolment::get_course_instance( $course_id );
return $course_enrolment->is_enrolled( $user_id );
}
/**
* Check if a visitor can access course content.
*
* This is just part of the check for lessons and quizzes. To include checks for prerequisites and preview lessons,
* use the global template function `sensei_can_user_view_lesson()`.
*
* @param int $course_id Course post ID.
* @param int $user_id User ID.
* @param string $context Context that we're checking for course content access (`lesson`, `quiz`, or `module`).
*/
public function can_access_course_content( $course_id, $user_id = null, $context = 'lesson' ) {
if ( null === $user_id ) {
$user_id = get_current_user_id();
}
$can_view_course_content = false;
$is_user_enrolled = false;
if ( ! empty( $user_id ) ) {
$is_user_enrolled = self::is_user_enrolled( $course_id, $user_id );
}
if (
! sensei_is_login_required()
|| sensei_all_access( $user_id )
|| $is_user_enrolled
) {
$can_view_course_content = true;
}
/**
* Filters if a visitor can view course content.
*
* @since 3.0.0
*
* @hook sensei_can_access_course_content
*
* @param {bool} $can_view_course_content True if they can view the course content.
* @param {int} $course_id Course post ID.
* @param {int} $user_id User ID if user is logged in.
* @param {string} $context Context that we're checking for course content
* access (`lesson`, `quiz`, or `module`).
* @return {bool} Whether the visitor can view course content.
*/
return apply_filters( 'sensei_can_access_course_content', $can_view_course_content, $course_id, $user_id, $context );
}
/**
* @param $message
*/
private static function add_course_access_permission_message( $message ) {
global $post;
if ( Sensei()->settings->get( 'access_permission' ) ) {
$message = apply_filters_deprecated(
'sensei_couse_access_permission_message',
[ $message, $post->ID ],
'3.0.0',
null
);
if ( ! empty( $message ) ) {
Sensei()->notices->add_notice( $message, 'info' );
}
}
}
/**
* Fires when a quiz has been graded to check if the Course status needs changing
*
* @param type $user_id
* @param type $quiz_id
*/
public function update_status_after_quiz_submission( $user_id, $quiz_id ) {
if ( intval( $user_id ) > 0 && intval( $quiz_id ) > 0 ) {
$lesson_id = get_post_meta( $quiz_id, '_quiz_lesson', true );
$this->update_status_after_lesson_change( $user_id, $lesson_id );
}
}
/**
* Fires when a lesson has changed to check if the Course status needs changing
*
* @param int $user_id
* @param int $lesson_id
*/
public function update_status_after_lesson_change( $user_id, $lesson_id ) {
if ( intval( $user_id ) > 0 && intval( $lesson_id ) > 0 ) {
$course_id = get_post_meta( $lesson_id, '_lesson_course', true );
if ( intval( $course_id ) > 0 ) {
// Updates the Course status and it's meta data
Sensei_Utils::user_complete_course( $course_id, $user_id );
}
}
}
/**
* Sets up the meta fields used for courses.
*/
public function set_up_meta_fields() {
register_post_meta(
'course',
'_course_prerequisite',
[
'show_in_rest' => true,
'single' => true,
'type' => 'integer',
'auth_callback' => [ $this, 'post_meta_auth_callback' ],
]
);
register_post_meta(
'course',
'_course_featured',
[
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => [ $this, 'post_meta_auth_callback' ],
]
);
register_post_meta(
'course',
'_sensei_self_enrollment_not_allowed',
[
'show_in_rest' => true,
'single' => true,
'type' => 'boolean',
'auth_callback' => [ $this, 'post_meta_auth_callback' ],
]
);
register_post_meta(
'course',
'disable_notification',
[
'show_in_rest' => true,
'single' => true,
'type' => 'boolean',
'auth_callback' => [ $this, 'post_meta_auth_callback' ],
]
);
register_post_meta(
'course',
Sensei_Guest_User::COURSE_OPEN_ACCESS_META,
[
'show_in_rest' => true,
'single' => true,
'type' => 'boolean',
'auth_callback' => [ $this, 'post_meta_auth_callback' ],
]
);
register_post_meta(
'course',
'sensei_course_publish_lessons',
[
'show_in_rest' => true,
'single' => true,
'type' => 'boolean',
'default' => true,
'auth_callback' => [ $this, 'post_meta_auth_callback' ],
]
);
/**
* Sets up the meta fields saved on course save in WP admin.
*
* @since 2.0.0
*
* @hook sensei_course_meta_fields
*
* @param {string[]} $course_meta_fields Array of meta field key names to save on course save.
* @return {string[]} Filtered meta field key names.
*/
$this->meta_fields = apply_filters( 'sensei_course_meta_fields', [ 'course_prerequisite', 'course_featured', 'course_video_embed' ] );
}
/**
* Add the course meta boxes.
*
* @internal
*
* @param bool $allowed Whether the user can add the meta.
* @param string $meta_key The meta key.
* @param int $post_id The post ID where the meta key is being edited.
*
* @return boolean Whether the user can edit the meta key.
*/
public function post_meta_auth_callback( $allowed, $meta_key, $post_id ) {
return current_user_can( 'edit_post', $post_id );
}
/**
* meta_box_setup function.
*
* @access public
* @return void
*/
public function meta_box_setup() {
// Add Meta Box for Prerequisite Course
add_meta_box(
'course-prerequisite',
__( 'Course Prerequisite', 'sensei-lms' ),
[ $this, 'course_prerequisite_meta_box_content' ],
$this->token,
'side',
'default',
[
'__block_editor_compatible_meta_box' => true,
'__back_compat_meta_box' => true,
]
);
// Add Meta Box for Featured Course
add_meta_box(
'course-featured',
__( 'Featured Course', 'sensei-lms' ),
[ $this, 'course_featured_meta_box_content' ],
$this->token,
'side',
'default',
[
'__block_editor_compatible_meta_box' => true,
'__back_compat_meta_box' => true,
]
);
// Add Meta Box for Course Meta
add_meta_box( 'course-video', __( 'Course Video', 'sensei-lms' ), [ $this, 'course_video_meta_box_content' ], $this->token, 'normal', 'default' );
// Add Meta Box for Course Lessons
add_meta_box( 'course-lessons', __( 'Course Lessons', 'sensei-lms' ), [ $this, 'course_lessons_meta_box_content' ], $this->token, 'normal', 'default' );
// Add Meta Box to link to Manage Learners
add_meta_box(
'course-manage',
__( 'Course Management', 'sensei-lms' ),
[ $this, 'course_manage_meta_box_content' ],
$this->token,
'side',
'default',
[
'__block_editor_compatible_meta_box' => true,
'__back_compat_meta_box' => true,
]
);
// Remove "Custom Settings" meta box.
remove_meta_box( 'woothemes-settings', $this->token, 'normal' );
// add Disable email notification box
add_meta_box(
'course-notifications',
__( 'Course Notifications', 'sensei-lms' ),
[ $this, 'course_notification_meta_box_content' ],
'course',
'normal',
'default',
[
'__block_editor_compatible_meta_box' => true,
'__back_compat_meta_box' => true,
]
);
}
/**
* Add author support when it's a REST request to allow save teacher via the Rest API.
*
* Hooked into `rest_api_init`.
*
* @since 4.9.0
* @access private
*/
public function add_author_support() {
add_post_type_support( 'course', 'author' );
}
/**
* course_prerequisite_meta_box_content function.
*
* @access public
* @return void
*/
public function course_prerequisite_meta_box_content() {
global $post;
$select_course_prerequisite = get_post_meta( $post->ID, '_course_prerequisite', true );
$post_args = [
'post_type' => 'course',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'DESC',
'exclude' => $post->ID,
'suppress_filters' => 0,
'post_status' => 'any',
];
$posts_array = get_posts( $post_args );
$html = '';
$html .= '<input type="hidden" name="' . esc_attr( 'woo_' . $this->token . '_noonce' ) . '" id="' . esc_attr( 'woo_' . $this->token . '_noonce' ) . '" value="' . esc_attr( wp_create_nonce( plugin_basename( __FILE__ ) ) ) . '" />';
if ( count( $posts_array ) > 0 ) {
$html .= '<select id="course-prerequisite-options" name="course_prerequisite" class="chosen_select widefat">' . "\n";
$html .= '<option value="">' . esc_html__( 'None', 'sensei-lms' ) . '</option>';
foreach ( $posts_array as $post_item ) {
$html .= '<option value="' . esc_attr( absint( $post_item->ID ) ) . '"' . selected( $post_item->ID, $select_course_prerequisite, false ) . '>' . esc_html( $post_item->post_title ) . '</option>' . "\n";
}
$html .= '</select>' . "\n";
} else {
$html .= '<p>' . esc_html__( 'No courses exist yet. Please add some first.', 'sensei-lms' ) . '</p>';
}
echo wp_kses(
$html,
array_merge(
wp_kses_allowed_html( 'post' ),
[
'input' => [
'id' => [],
'name' => [],
'type' => [],
'value' => [],
],
'option' => [
'selected' => [],
'value' => [],
],
'select' => [
'class' => [],
'id' => [],
'name' => [],
],
]
)
);
}
/**
* course_featured_meta_box_content function.
*
* @access public
* @return void
*/
public function course_featured_meta_box_content() {
global $post;
$course_featured = get_post_meta( $post->ID, '_course_featured', true );
$html = '';
$html .= '<input type="hidden" name="' . esc_attr( 'woo_' . $this->token . '_noonce' ) . '" id="' . esc_attr( 'woo_' . $this->token . '_noonce' ) . '" value="' . esc_attr( wp_create_nonce( plugin_basename( __FILE__ ) ) ) . '" />';
$checked = '';
if ( isset( $course_featured ) && ( '' != $course_featured ) ) {
$checked = checked( 'featured', $course_featured, false );
}
$html .= '<input type="checkbox" name="course_featured" value="featured" ' . $checked . '> ' . esc_html__( 'Feature this course', 'sensei-lms' ) . '<br>';
echo wp_kses(
$html,
array_merge(
wp_kses_allowed_html( 'post' ),
[
'input' => [
'checked' => [],
'id' => [],
'name' => [],
'type' => [],
'value' => [],
],
]
)
);
}
/**
* course_video_meta_box_content function.
*
* @access public
* @return void
*/
public function course_video_meta_box_content() {
global $post;
$course_video_embed = get_post_meta( $post->ID, '_course_video_embed', true );
$course_video_embed = Sensei_Wp_Kses::maybe_sanitize( $course_video_embed, self::$allowed_html );
$html = '';
$html .= '<label class="screen-reader-text" for="course_video_embed">' . esc_html__( 'Video Embed Code', 'sensei-lms' ) . '</label>';
$html .= '<textarea rows="5" cols="50" name="course_video_embed" tabindex="6" id="course-video-embed">';
$html .= $course_video_embed . '</textarea><p>';
$html .= esc_html__( 'Paste the embed code for your video (e.g. YouTube, Vimeo etc.) in the box above.', 'sensei-lms' ) . '</p>';
echo wp_kses(
$html,
array_merge(
wp_kses_allowed_html( 'post' ),
self::$allowed_html,
[
// Explicitly allow label tag for WP.com.
'label' => [
'class' => [],
'for' => [],
],
'textarea' => [
'cols' => [],
'id' => [],
'name' => [],
'rows' => [],
'tabindex' => [],
],
]
)
);
}
/**
* meta_box_save function.
*
* Handles saving the meta data
*
* @access public
* @param int $post_id
* @return int
*/
public function meta_box_save( $post_id ) {
global $post;
/* Verify the nonce before proceeding. */
if ( ( get_post_type() != $this->token ) || ! isset( $_POST[ 'woo_' . $this->token . '_noonce' ] ) || ! wp_verify_nonce( $_POST[ 'woo_' . $this->token . '_noonce' ], plugin_basename( __FILE__ ) ) ) {
return;
}
/* Get the post type object. */
$post_type = get_post_type_object( $post->post_type );
/* Check if the current user has permission to edit the post. */
if ( ! current_user_can( $post_type->cap->edit_post, $post_id ) ) {
return $post_id;
}
if ( 'page' == $_POST['post_type'] ) {
if ( ! current_user_can( 'edit_page', $post_id ) ) {
return $post_id;
}
} elseif ( ! current_user_can( 'edit_post', $post_id ) ) {
return $post_id;
}
// Save the post meta data fields
if ( isset( $this->meta_fields ) && is_array( $this->meta_fields ) ) {
foreach ( $this->meta_fields as $meta_key ) {
$this->save_post_meta( $meta_key, $post_id );
}
}
}
/**
* save_post_meta function.
*
* Does the save
*
* @access private
* @param string $post_key (default: '')
* @param int $post_id (default: 0)
* @return int new meta id | bool meta value saved status
*/
private function save_post_meta( $post_key = '', $post_id = 0 ) {
/*
* This function is called from `meta_box_save` where the nonce is
* verified. We can ignore nonce verification here.
*/
// Get the meta key.
$meta_key = '_' . $post_key;
// Get the posted data and sanitize it for use as an HTML class.
if ( 'course_video_embed' == $post_key ) {
// phpcs:ignore WordPress.Security.NonceVerification
$new_meta_value = ( isset( $_POST[ $post_key ] ) ) ? $_POST[ $post_key ] : '';
$new_meta_value = Sensei_Wp_Kses::maybe_sanitize( $new_meta_value, self::$allowed_html );
} else {
// phpcs:ignore WordPress.Security.NonceVerification
if ( ! isset( $_POST[ $post_key ] ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification
$new_meta_value = sanitize_html_class( $_POST[ $post_key ] );
}
/**
* Action before saving the meta value.
*
* @since 2.2.0
*
* @hook sensei_course_meta_before_save
*
* @param {int} $post_id The course ID.
* @param {string} $meta_key The meta to be saved.
* @param {mixed} $new_meta_value The meta value to be saved.
*/
do_action( 'sensei_course_meta_before_save', $post_id, $meta_key, $new_meta_value );
/**
* Filter whether or not to run the default save functionality for the
* meta. This may be used with the
* "sensei_course_meta_before_save" action to create custom
* save functionality for specific meta.
*
* @since 2.2.0
*
* @hook sensei_course_meta_default_save
*
* @param {bool} $do_save Whether or not to do the default save.
* @param {int} $post_id The course ID.
* @param {string} $meta_key The meta to be saved.
* @param {mixed} $new_meta_value The meta value to be saved.
* @return {bool} Whether or not to do the default save.
*/
if ( apply_filters( 'sensei_course_meta_default_save', true, $post_id, $meta_key, $new_meta_value ) ) {
// Update meta field with the new value
return update_post_meta( $post_id, $meta_key, $new_meta_value );
}
}
/**
* course_lessons_meta_box_content function.
*
* @access public
* @return void
*/
public function course_lessons_meta_box_content() {
global $post;
// Setup Lesson Query
$posts_array = [];
if ( 0 < $post->ID ) {
$posts_array = $this->course_lessons( $post->ID, 'any' );
}
$html = '';
$html .= '<input type="hidden" name="' . esc_attr( 'woo_' . $this->token . '_noonce' ) . '" id="'
. esc_attr( 'woo_' . $this->token . '_noonce' )
. '" value="' . esc_attr( wp_create_nonce( plugin_basename( __FILE__ ) ) ) . '" />';
$course_id = ( 0 < $post->ID ) ? '&course_id=' . $post->ID : '';
$add_lesson_admin_url = admin_url( 'post-new.php?post_type=lesson' . $course_id );
if ( count( $posts_array ) > 0 ) {
foreach ( $posts_array as $post_item ) {
$html .= '<p>' . "\n";
$html .= esc_html( $post_item->post_title ) . "\n";
$html .= '<a href="'
. esc_url( get_edit_post_link( $post_item->ID ) )
. '" title="'
// translators: Placeholder is the item title/name.
. esc_attr( sprintf( __( 'Edit %s', 'sensei-lms' ), $post_item->post_title ) )
. '" data-course-status="' . esc_attr( $post->post_status )
. '" class="edit-lesson-action">'
. esc_html__( 'Edit this lesson', 'sensei-lms' )
. '</a>';
$html .= '</p>' . "\n";
}
}
$html .= '<p>';
if ( 0 === count( $posts_array ) ) {
$html .= esc_html__( 'No lessons exist yet for this course.', 'sensei-lms' ) . "\n";
} else {
$html .= '<hr />';
}
$html .= '<a class="add-course-lesson" href="' . esc_url( $add_lesson_admin_url )
. '" data-course-status="' . esc_attr( $post->post_status )
. '" title="' . esc_attr__( 'Add a Lesson', 'sensei-lms' ) . '">';
if ( count( $posts_array ) < 1 ) {
$html .= esc_html__( 'Please add some.', 'sensei-lms' );
} else {
$html .= esc_html__( '+ Add Another Lesson', 'sensei-lms' );
}
$html .= '</a></p>';
echo wp_kses(
$html,
array_merge(
wp_kses_allowed_html( 'post' ),
[
'input' => [
'id' => [],
'name' => [],
'type' => [],
'value' => [],
],
]
)
);
}
/**
* course_manage_meta_box_content function.
*
* @since 1.9.0
* @access public
* @return void
*/
public function course_manage_meta_box_content() {
global $post;
$manage_url = add_query_arg(
[
'page' => 'sensei_learners',
'course_id' => $post->ID,
'view' => 'learners',
],
admin_url( 'admin.php' )
);
$grading_url = add_query_arg(
[
'page' => 'sensei_grading',
'course_id' => $post->ID,
'view' => 'learners',
],
admin_url( 'admin.php' )
);
echo '<ul><li><a href=' . esc_url( $manage_url ) . '>' . esc_html__( 'Manage Students', 'sensei-lms' ) . '</a></li>';
echo '<li><a href=' . esc_url( $grading_url ) . '>' . esc_html__( 'Manage Grading', 'sensei-lms' ) . '</a></li></ul>';
}
/**
* Add column headings to the "course" post list screen,
* while moving the existing ones to the end.
*
* @access private
* @since 1.0.0
* @param array $defaults Array of column header labels keyed by column ID.
* @return array Updated array of column header labels keyed by column ID.
*/
public function add_column_headings( $defaults ) {
$new_columns = [];
$new_columns['cb'] = '<input type="checkbox" />';
$new_columns['title'] = _x( 'Course Title', 'column name', 'sensei-lms' );
$new_columns['course-prerequisite'] = _x( 'Pre-requisite Course', 'column name', 'sensei-lms' );
$new_columns['course-category'] = _x( 'Category', 'column name', 'sensei-lms' );
if ( isset( $defaults['modules'] ) ) {
$new_columns['modules'] = $defaults['modules'];
}
if ( isset( $defaults['date'] ) ) {
$new_columns['date'] = $defaults['date'];
}
// Make sure other sensei columns stay directly behind the new columns.
$other_sensei_columns = [
'teacher',
'students',
];
foreach ( $other_sensei_columns as $column_key ) {
if ( isset( $defaults[ $column_key ] ) ) {
$new_columns[ $column_key ] = $defaults[ $column_key ];
}
}
// Add all remaining columns at the end.
foreach ( $defaults as $column_key => $column_value ) {
if ( ! isset( $new_columns[ $column_key ] ) ) {
$new_columns[ $column_key ] = $column_value;
}
}
return $new_columns;
}
/**
* Add data for our newly-added custom columns.
*
* @access public
* @since 1.0.0
* @param string $column_name
* @param int $id
* @return void
*/
public function add_column_data( $column_name, $id ) {
switch ( $column_name ) {
case 'id':
echo esc_html( $id );
break;
case 'course-prerequisite':
$course_prerequisite_id = get_post_meta( $id, '_course_prerequisite', true );
if ( 0 < absint( $course_prerequisite_id ) ) {
echo '<a href="'
. esc_url( get_edit_post_link( absint( $course_prerequisite_id ) ) )
. '" title="'
// translators: Placeholder is the item title/name.
. esc_attr( sprintf( __( 'Edit %s', 'sensei-lms' ), get_the_title( absint( $course_prerequisite_id ) ) ) )
. '">'
. esc_html( get_the_title( absint( $course_prerequisite_id ) ) )
. '</a>';
}
break;
case 'course-category':
$output = get_the_term_list( $id, 'course-category', '', ', ', '' );
if ( '' == $output ) {
echo esc_html__( 'None', 'sensei-lms' );
} else {
echo wp_kses_post( $output );
}
break;
default:
break;
}
}
/**
* Query courses.
*
* @since 1.0.0
* @since 2.0.0 For `$type` argument, `paidcourses` is no longer supported.
* @since 2.0.0 For `$type` argument, `freecourses` is no longer supported.
*
* @deprecated 3.0.0
*
* @param int $amount (default: 0)
* @param string $type (default: 'default')
* @param array $includes (default: array())
* @return array
*/
public function course_query( $amount = 0, $type = 'default', $includes = [], $excludes = [] ) {
_deprecated_function( __METHOD__, '3.0.0' );
if ( 'usercourses' === $type ) {
$base_query = [
'posts_per_page' => $amount,
];
if ( ! empty( $includes ) ) {
$base_query['post__in'] = $includes;
}
if ( ! empty( $excludes ) ) {
$base_query['post__not_in'] = $excludes;
}
$learner_manager = Sensei_Learner::instance();
return $learner_manager->get_enrolled_courses_query( get_current_user_id(), $base_query )->posts;
}
$results_array = [];
$post_args = $this->get_archive_query_args( $type, $amount, $includes, $excludes );
// get the posts
if ( empty( $post_args ) ) {
return $results_array;
} else {
// reset the pagination as this widgets do not need it
$post_args['paged'] = 1;
$results_array = get_posts( $post_args );
}
return $results_array;
}
/**
* Get the query arguments for fetching courses in different contexts.
*
* @since 1.0.0
* @since 2.0.0 For `$type` argument, `paidcourses` is no longer supported.
* @since 2.0.0 For `$type` argument, `freecourses` is no longer supported.
*
* @deprecated 3.0.0
*
* @param string $type (default: '')
* @param int $amount (default: 0)
* @param array $includes (default: array())
* @return array
*/
public function get_archive_query_args( $type = '', $amount = 0, $includes = [], $excludes = [] ) {
_deprecated_function( __METHOD__, '3.0.0' );
global $wp_query;
if ( 0 == $amount && ( isset( Sensei()->settings->settings['course_archive_amount'] ) && 'usercourses' != $type && ( 0 < absint( Sensei()->settings->settings['course_archive_amount'] ) ) ) ) {
$amount = absint( Sensei()->settings->settings['course_archive_amount'] );
} elseif ( 0 == $amount ) {
$amount = $wp_query->get( 'posts_per_page' );
}
$stored_order = get_option( 'sensei_course_order', '' );
$order = 'ASC';
$orderby = 'menu_order';
if ( empty( $stored_order ) ) {
$order = 'DESC';
$orderby = 'date';
}
switch ( $type ) {
case 'usercourses':
$learner_manager = Sensei_Learner::instance();
$post_args = [
'orderby' => $orderby,
'order' => $order,
'post__in' => $includes,
'post__not_in' => $excludes,
'suppress_filters' => 0,
];
$post_args = $learner_manager->get_enrolled_courses_query_args( get_current_user_id(), $post_args );
break;
case 'featuredcourses':
$post_args = [
'post_type' => 'course',
'orderby' => $orderby,
'order' => $order,
'post_status' => 'publish',
'meta_value' => 'featured',
'meta_key' => '_course_featured',
'meta_compare' => '=',
'exclude' => $excludes,
'suppress_filters' => 0,
];
break;
default:
$post_args = [
'post_type' => 'course',
'orderby' => $orderby,
'order' => $order,
'post_status' => 'publish',
'exclude' => $excludes,
'suppress_filters' => 0,
];
break;
}
$post_args['posts_per_page'] = $amount;
$paged = $wp_query->get( 'paged' );
$post_args['paged'] = empty( $paged ) ? 1 : $paged;
if ( 'newcourses' == $type ) {
$post_args['orderby'] = 'date';
$post_args['order'] = 'DESC';
}
return $post_args;
}
/**
* course_image function.
*
* Outputs the courses image, or first image from a lesson within a course
*
* Will echo the image unless return true is specified.
*
* @access public
* @param int | WP_Post $course_id (default: 0)
* @param string $width (default: '100')
* @param string $height (default: '100')
* @param bool $return default false
*
* @return string | void
*/
public function course_image( $course_id = 0, $width = '100', $height = '100', $return = false ) {
global $sensei_is_block;
if ( is_a( $course_id, 'WP_Post' ) ) {
$course_id = $course_id->ID;
}
if ( 'course' !== get_post_type( $course_id ) ) {
return;
}
$html = '';
// Get Width and Height settings
if ( ( $width == '100' ) && ( $height == '100' ) ) {
if ( is_singular( 'course' ) ) {
if ( ! Sensei()->settings->settings['course_single_image_enable'] ) {
return '';
}
$image_thumb_size = 'course_single_image';
$dimensions = Sensei()->get_image_size( $image_thumb_size );
$width = $dimensions['width'];
$height = $dimensions['height'];
} else {
if ( ! Sensei()->settings->settings['course_archive_image_enable'] && ! $sensei_is_block ) {
return '';
}
$image_thumb_size = 'course_archive_image';
$dimensions = Sensei()->get_image_size( $image_thumb_size );
$width = $dimensions['width'];
$height = $dimensions['height'];
}
}
$img_html = '';
$used_placeholder = false;
$classes = '';
if ( ! $sensei_is_block ) {
$classes = 'woo-image thumbnail alignleft';
}
if ( has_post_thumbnail( $course_id ) ) {
// Get Featured Image
if ( $sensei_is_block ) {
$img_html = get_the_post_thumbnail( $course_id, 'medium', [ 'class' => $classes ] );
} else {
$img_html = get_the_post_thumbnail( $course_id, [ $width, $height ], [ 'class' => $classes ] );
}
} else {
// Check for a Lesson Image
$course_lessons = $this->course_lessons( $course_id );
foreach ( $course_lessons as $lesson_item ) {
if ( has_post_thumbnail( $lesson_item->ID ) ) {
// Get Featured Image
if ( $sensei_is_block ) {
$img_html = get_the_post_thumbnail( $lesson_item->ID, 'medium', [ 'class' => $classes ] );
} else {
$img_html = get_the_post_thumbnail( $lesson_item->ID, [ $width, $height ], [ 'class' => $classes ] );
}
if ( '' !== $img_html ) {
break;
}
}
}
if ( '' === $img_html ) {
// Display Image Placeholder if none
if ( Sensei()->settings->get( 'placeholder_images_enable' ) ) {
/**
* Filter the image HTML when no course image exists.
*
* @since 1.1.0
* @since 1.12.0 Added $course_id, $width, and $height.
*
* @hook sensei_course_placeholder_image_url
*
* @param {string} $img_html Course image HTML.
* @param {int} $course_id Course ID.
* @param {int} $width Requested image width.
* @param {int} $height Requested image height.
* @return {string} Course image HTML.
*/
$img_html = apply_filters( 'sensei_course_placeholder_image_url', '<img src="//via.placeholder.com/' . $width . 'x' . $height . '" class="' . esc_attr( $classes ) . '" />', $course_id, $width, $height );
$used_placeholder = true;
}
}
}
/**
* Filter the HTML for the course image. If not blank, this will be surrounded with an anchor tag linking to the course.
*
* @since 1.12.0
*
* @hook sensei_course_image_html
*
* @param {string} $img_html Course image HTML.
* @param {int} $course_id Course ID.
* @param {int} $width Requested image width.
* @param {int} $height Requested image height.
* @param {bool} $used_placeholder True if placeholder was used in the generation of the image HTML.
* @return {string} Course image HTML.
*/
$img_html = apply_filters( 'sensei_course_image_html', $img_html, $course_id, $width, $height, $used_placeholder );
if ( '' != $img_html ) {
$html .= '<a href="' . esc_url( get_permalink( $course_id ) ) . '" title="' . esc_attr( get_post_field( 'post_title', $course_id ) ) . '">' . wp_kses_post( $img_html ) . '</a>';
}
if ( $return ) {
return $html;
} else {
echo wp_kses_post( $html );
}
}
/**
* course_count function.
*
* @access public
* @param array $exclude (default: array())
* @param string $post_status (default: 'publish')
* @return int
*/
public function course_count( $post_status = 'publish' ) {
$post_args = [
'post_type' => 'course',
'posts_per_page' => -1,
'post_status' => $post_status,
'suppress_filters' => 0,
'fields' => 'ids',
];
// Allow WP to generate the complex final query, just shortcut to only do an overall count
/**
* Filter course count query arguments.
*
* @hook sensei_course_count
*
* @param {array} $post_args Query arguments.
* @return {array} Filtered query arguments.
*/
$courses_query = new WP_Query( apply_filters( 'sensei_course_count', $post_args ) );
return count( $courses_query->posts );
}
/**
* Get course lessons.
*
* @access public
*
* @param int|WP_Post $course_id (default: 0). The course id.
* @param string|array $post_status (default: 'publish'). The post status.
* @param string $fields (default: 'all'). WP only allows 3 types, but we will limit it to only 'ids' or 'all'.
* @param array $query_args Base arguments for the WP query.
*
* @return WP_Post[]|int[] Array of lesson objects or lesson IDs.
*/
public function course_lessons( $course_id = 0, $post_status = 'publish', $fields = 'all', $query_args = [] ) {
if ( is_a( $course_id, 'WP_Post' ) ) {
$course_id = $course_id->ID;
}
$query_args = array_merge(
$query_args,
[
'post_type' => 'lesson',
'posts_per_page' => -1,
'orderby' => 'date',
'order' => 'ASC',
'post_status' => $post_status,
'suppress_filters' => 0,
]
);
if ( ! isset( $query_args['meta_query'] ) ) {
$query_args['meta_query'] = [];
}
$query_args['meta_query'][] = [
'key' => '_lesson_course',
'value' => intval( $course_id ),
];
$query_results = new WP_Query( $query_args );
$lessons = $query_results->posts;
// re order the lessons. This could not be done via the OR meta query as there may be lessons
// with the course order for a different course and this should not be included. It could also not
// be done via the AND meta query as it excludes lesson that does not have the _order_$course_id but
// that have been added to the course.
if ( count( $lessons ) > 1 ) {
$lesson_order = array();
foreach ( $lessons as $lesson ) {
$order = intval( get_post_meta( $lesson->ID, '_order_' . $course_id, true ) );
// for lessons with no order set it to be 10000 so that it show up at the end
$lesson_order[ $lesson->ID ] = $order ? $order : 100000;
}
uasort(
$lessons,
function ( $lesson_1, $lesson_2 ) use ( $lesson_order ) {
$lesson_1_order = $lesson_order[ $lesson_1->ID ];
$lesson_2_order = $lesson_order[ $lesson_2->ID ];
if ( $lesson_1_order == $lesson_2_order ) {
return 0;
}
return ( $lesson_1_order < $lesson_2_order ) ? -1 : 1;
}
);
}
/**
* Filter runs inside Sensei_Course::course_lessons function
*
* Returns all lessons for a given course
*
* @hook sensei_course_get_lessons
*
* @param {array} $lessons Array of lesson objects.
* @param {int} $course_id The course ID.
* @return {array} Array of lesson objects.
*/
$lessons = apply_filters( 'sensei_course_get_lessons', $lessons, $course_id );
// Return the requested fields.
// Runs after the sensei_course_get_lessons filter so the filter always give an array of lesson objects.
if ( 'ids' === $fields ) {
$lesson_objects = $lessons;
$lessons = [];
foreach ( $lesson_objects as $lesson ) {
$lessons[] = $lesson->ID;
}
}
return $lessons;
}
/**
* Fetch all quiz ids in a course
*
* @since 1.5.0
* @param integer $course_id ID of course
* @param boolean $boolean_check True if a simple yes/no is required
* @return array Array of quiz post objects
*/
public function course_quizzes( $course_id = 0, $boolean_check = false ) {
$course_quizzes = [];
if ( $course_id ) {
$lesson_ids = Sensei()->course->course_lessons( $course_id, 'any', 'ids' );
foreach ( $lesson_ids as $lesson_id ) {
$has_questions = Sensei_Lesson::lesson_quiz_has_questions( $lesson_id );
if ( $has_questions && $boolean_check ) {
return true;
} elseif ( $has_questions ) {
$quiz_id = Sensei()->lesson->lesson_quizzes( $lesson_id );
$course_quizzes[] = $quiz_id;
}
}
}
if ( $boolean_check && empty( $course_quizzes ) ) {
$course_quizzes = false;
}
return $course_quizzes;
}
/**
* course_lessons_completed function. Appears to be completely unused and a duplicate of course_lessons()!
*
* @access public
* @param int $course_id (default: 0)
* @param string $post_status (default: 'publish')
* @return array
*/
public function course_lessons_completed( $course_id = 0, $post_status = 'publish' ) {
return $this->course_lessons( $course_id, $post_status );
}
/**
* course_author_lesson_count function.
*
* @access public
* @param int $author_id (default: 0)
* @param int $course_id (default: 0)
* @return int
*/
public function course_author_lesson_count( $author_id = 0, $course_id = 0 ) {
$lesson_args = [
'post_type' => 'lesson',
'posts_per_page' => -1,
'author' => $author_id,
'meta_key' => '_lesson_course',
'meta_value' => $course_id,
'post_status' => 'publish',
'suppress_filters' => 0,
'fields' => 'ids', // less data to retrieve
];
$lessons_array = get_posts( $lesson_args );
$count = count( $lessons_array );
return $count;
}
/**
* course_lesson_count function.
*
* @access public
* @param int $course_id (default: 0)
* @return int
*/
public function course_lesson_count( $course_id = 0 ) {
$lesson_args = [
'post_type' => 'lesson',
'posts_per_page' => -1,
'meta_key' => '_lesson_course',
'meta_value' => $course_id,
'post_status' => 'publish',
'suppress_filters' => 0,
'fields' => 'ids', // less data to retrieve
];
$lessons_array = get_posts( $lesson_args );
$count = count( $lessons_array );
return $count;
}
/**
* course_lesson_preview_count function.
*
* @access public
* @param int $course_id (default: 0)
* @return int
*/
public function course_lesson_preview_count( $course_id = 0 ) {
$lesson_args = [
'post_type' => 'lesson',
'posts_per_page' => -1,
'post_status' => 'publish',
'suppress_filters' => 0,
'meta_query' => [
[
'key' => '_lesson_course',
'value' => $course_id,
],
[
'key' => '_lesson_preview',
'value' => 'preview',
],
],
'fields' => 'ids', // less data to retrieve
];
$lessons_array = get_posts( $lesson_args );
$count = count( $lessons_array );
return $count;
}
/**
* Fix posts_per_page for My Courses page
*
* @deprecated 3.0.0
*
* @param WP_Query $query
* @return void
*/
public function filter_my_courses( $query ) {
_deprecated_function( __METHOD__, '3.0.0' );
}
/**
* Has results links check.
*
* @param int $course_id Course ID.
*
* @return boolean
*/
private static function has_results_links( $course_id ) {
/**
* Filter results links.
*
* @hook sensei_results_links
*
* @param {string} $results_links HTML for results links.
* @param {int} $course_id Course ID.
* @return {string} HTML for results links.
*/
return has_filter( 'sensei_results_links' ) && ! empty( apply_filters( 'sensei_results_links', '', $course_id ) );
}
/**
* load_user_courses_content generates HTML for user's active & completed courses
*
* This function also ouputs the html so no need to echo the content.
*
* @since 1.4.0
* @param object $user Queried user object
* @param boolean $manage Whether the user has permission to manage the courses
* @return string HTML displayng course data
*/
public function load_user_courses_content( $user = false ) {
global $course;
if ( ! isset( Sensei()->settings->settings['learner_profile_show_courses'] )
|| ! Sensei()->settings->settings['learner_profile_show_courses'] ) {
// do not show the content if the settings doesn't allow for it
return;
}
$manage = ( $user->ID == get_current_user_id() ) ? true : false;
/**
* Action to run before the student course content is loaded.
*
* @hook sensei_before_learner_course_content
*
* @param {object} $user User object.
*/
do_action( 'sensei_before_learner_course_content', $user );
// Build Output HTML
$complete_html = $active_html = '';
if ( is_a( $user, 'WP_User' ) ) {
/**
* Fires before the My Courses content is loaded.
*
* @hook sensei_before_my_courses
*
* @param {int} $user_id User ID.
*/
do_action( 'sensei_before_my_courses', $user->ID );
// Logic for Active and Completed Courses
$per_page = 20;
if ( isset( Sensei()->settings->settings['my_course_amount'] )
&& ( 0 < absint( Sensei()->settings->settings['my_course_amount'] ) ) ) {
$per_page = absint( Sensei()->settings->settings['my_course_amount'] );
}
$learner_manager = Sensei_Learner::instance();
$active_query_args = [
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Safe use of pagination var.
'paged' => isset( $_GET['active_page'] ) ? absint( $_GET['active_page'] ) : 1,
'posts_per_page' => $per_page,
];
$active_courses_query = $learner_manager->get_enrolled_active_courses_query( $user->ID, $active_query_args );
$active_courses = $active_courses_query->posts;
$active_count = $active_courses_query->found_posts;
$completed_query_args = [
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Safe use of pagination var.
'paged' => isset( $_GET['completed_page'] ) ? absint( $_GET['completed_page'] ) : 1,
'posts_per_page' => $per_page,
];
$completed_courses_query = $learner_manager->get_enrolled_completed_courses_query( $user->ID, $completed_query_args );
$completed_courses = $completed_courses_query->posts;
$completed_count = $completed_courses_query->found_posts;
foreach ( $active_courses as $course_item ) {
$course_lessons = Sensei()->course->course_lessons( $course_item->ID );
$lessons_completed = 0;
foreach ( $course_lessons as $lesson ) {
if ( Sensei_Utils::user_completed_lesson( $lesson->ID, $user->ID ) ) {
++$lessons_completed;
}
}
// Get Course Categories
$category_output = get_the_term_list( $course_item->ID, 'course-category', '', ', ', '' );
$active_html .= '<article class="' . esc_attr( implode( ' ', get_post_class( [ 'course', 'post' ], $course_item->ID ) ) ) . '">';
// Image
$active_html .= Sensei()->course->course_image( absint( $course_item->ID ), '100', '100', true );
// Title
$active_html .= '<header>';
$active_html .= '<h2 class="course-title"><a href="' . esc_url( get_permalink( absint( $course_item->ID ) ) ) . '" title="' . esc_attr( $course_item->post_title ) . '">' . esc_html( $course_item->post_title ) . '</a></h2>';
// Author
$user_info = get_userdata( absint( $course_item->post_author ) );
if ( isset( Sensei()->settings->settings['course_author'] )
&& ( Sensei()->settings->settings['course_author'] ) ) {
$active_html .= '<span class="course-author">'
. esc_html__( 'by ', 'sensei-lms' )
. '<a href="' . esc_url( get_author_posts_url( absint( $course_item->post_author ) ) )
. '" title="' . esc_attr( $user_info->display_name ) . '">'
. esc_html( $user_info->display_name )
. '</a></span>';
}
$active_html .= '</header>';
$active_html .= '<section class="entry">';
$active_html .= '<div class="sensei-course-meta">';
// Lesson count for this author
$lesson_count = Sensei()->course->course_lesson_count( absint( $course_item->ID ) );
// Handle Division by Zero
if ( 0 == $lesson_count ) {
$lesson_count = 1;
}
$active_html .= '<span class="course-lesson-count">' .
// translators: Placeholder %d is the lesson count.
esc_html( sprintf( _n( '%d Lesson', '%d Lessons', $lesson_count, 'sensei-lms' ), $lesson_count ) ) .
'</span>';
// Course Categories
if ( '' != $category_output ) {
$active_html .= '<span class="course-category">'
// translators: Placeholder is a comma-separated list of the Course categories.
. sprintf( __( 'in %s', 'sensei-lms' ), $category_output )
. '</span>';
}
// translators: Placeholders are the counts for lessons completed and total lessons, respectively.
$active_html .= '<span class="course-lesson-progress">' . esc_html( sprintf( __( '%1$d of %2$d lessons completed', 'sensei-lms' ), $lessons_completed, $lesson_count ) ) . '</span>';
$active_html .= '</div>';
$active_html .= '<p class="course-excerpt">' . esc_html( $course_item->post_excerpt ) . '</p>';
$progress_percentage = Sensei_Utils::quotient_as_absolute_rounded_percentage( $lessons_completed, $lesson_count, 0 );
$active_html .= $this->get_progress_meter( (int) $progress_percentage );
$active_html .= '</section>';
if ( is_user_logged_in() ) {
$active_html .= '<section class="entry-actions">';
$active_html .= '<form method="POST" action="' . esc_url( remove_query_arg( [ 'active_page', 'completed_page' ] ) ) . '">';
$active_html .= '<input type="hidden" name="' . esc_attr( 'woothemes_sensei_complete_course_noonce' ) . '" id="' . esc_attr( 'woothemes_sensei_complete_course_noonce' ) . '" value="' . esc_attr( wp_create_nonce( 'woothemes_sensei_complete_course_noonce' ) ) . '" />';
$active_html .= '<input type="hidden" name="course_complete_id" id="course-complete-id" value="' . esc_attr( absint( $course_item->ID ) ) . '" />';
if ( 0 < absint( count( $course_lessons ) )
&& Sensei()->settings->settings['course_completion'] == 'complete' ) {
wp_enqueue_script( 'sensei-stop-double-submission' );
$active_html .= '<span><input name="course_complete" type="submit" class="course-complete sensei-stop-double-submission" value="'
. esc_attr__( 'Mark as Complete', 'sensei-lms' ) . '"/> </span>';
}
$course_purchased = false;
if ( class_exists( 'Sensei_WC' ) && Sensei_WC::is_woocommerce_active() ) {
// Get the product ID
$wc_post_id = get_post_meta( absint( $course_item->ID ), '_course_woocommerce_product', true );
if ( 0 < $wc_post_id ) {
$course_purchased = Sensei_WC::has_customer_bought_product( $user->ID, $wc_post_id );
}
}
$active_html .= '</form>';
$active_html .= '</section>';
}
$active_html .= '</article>';
}
// Active pagination
if ( $active_count > $per_page ) {
$current_page = 1;
if ( isset( $_GET['active_page'] ) && 0 < intval( $_GET['active_page'] ) ) {
$current_page = $_GET['active_page'];
}
$active_html .= '<nav class="pagination woo-pagination">';
$total_pages = ceil( $active_count / $per_page );
if ( $current_page > 1 ) {
$prev_link = add_query_arg( 'active_page', $current_page - 1 );
$active_html .= '<a class="prev page-numbers" href="' . esc_url( $prev_link ) . '">' . esc_html__( 'Previous', 'sensei-lms' ) . '</a> ';
}
for ( $i = 1; $i <= $total_pages; $i++ ) {
$link = add_query_arg( 'active_page', $i );
if ( $i == $current_page ) {
$active_html .= '<span class="page-numbers current">' . esc_html( $i ) . '</span> ';
} else {
$active_html .= '<a class="page-numbers" href="' . esc_url( $link ) . '">' . esc_html( $i ) . '</a> ';
}
}
if ( $current_page < $total_pages ) {
$next_link = add_query_arg( 'active_page', $current_page + 1 );
$active_html .= '<a class="next page-numbers" href="' . esc_url( $next_link ) . '">' . esc_html__( 'Next', 'sensei-lms' ) . '</a> ';
}
$active_html .= '</nav>';
}
foreach ( $completed_courses as $course_item ) {
$course = $course_item;
$lesson_count = Sensei()->course->course_lesson_count( absint( $course_item->ID ) );
// Get Course Categories
$category_output = get_the_term_list( $course_item->ID, 'course-category', '', ', ', '' );
$complete_html .= '<article class="' . esc_attr( implode( ' ', get_post_class( [ 'course', 'post' ], $course_item->ID ) ) ) . '">';
// Image
$complete_html .= Sensei()->course->course_image( absint( $course_item->ID ), 100, 100, true );
// Title
$complete_html .= '<header>';
$complete_html .= '<h2 class="course-title"><a href="' . esc_url( get_permalink( absint( $course_item->ID ) ) ) . '" title="' . esc_attr( $course_item->post_title ) . '">' . esc_html( $course_item->post_title ) . '</a></h2>';
// Author
$user_info = get_userdata( absint( $course_item->post_author ) );
if ( isset( Sensei()->settings->settings['course_author'] ) && ( Sensei()->settings->settings['course_author'] ) ) {
$complete_html .= '<span class="course-author">' . esc_html__( 'by ', 'sensei-lms' ) . '<a href="' . esc_url( get_author_posts_url( absint( $course_item->post_author ) ) ) . '" title="' . esc_attr( $user_info->display_name ) . '">' . esc_html( $user_info->display_name ) . '</a></span>';
}
$complete_html .= '</header>';
$complete_html .= '<section class="entry">';
$complete_html .= '<p class="sensei-course-meta">';
// Lesson count for this author
$complete_html .= '<span class="course-lesson-count">' .
// translators: Placeholder %d is the lesson count.
esc_html( sprintf( _n( '%d Lesson', '%d Lessons', $lesson_count, 'sensei-lms' ), $lesson_count ) ) .
'</span>';
// Course Categories
if ( '' != $category_output ) {
// translators: Placeholder is a comma-separated list of the Course categories.
$complete_html .= '<span class="course-category">' . sprintf( __( 'in %s', 'sensei-lms' ), $category_output ) . '</span>';
}
$complete_html .= '</p>';
$complete_html .= '<p class="course-excerpt">' . esc_html( $course_item->post_excerpt ) . '</p>';
$complete_html .= $this->get_progress_meter( 100 );
$results_link = '';
if ( $manage && Sensei()->course->course_quizzes( $course_item->ID, true ) ) {
$results_link = '<a class="button view-results" href="'
. esc_url( self::get_view_results_link( $course_item->ID ) )
. '">' . esc_html__( 'View Results', 'sensei-lms' )
. '</a>';
}
/**
* Filter results links.
*
* @hook sensei_results_links
*
* @param {string} $results_links HTML for results links.
* @param {int} $course_id Course ID.
* @param {int} $user_id User ID.
* @return {string} HTML for results links.
*/
$results_links = apply_filters( 'sensei_results_links', $results_link, $course_item->ID, $user->ID );
if ( $results_links ) {
$complete_html .= '<p class="sensei-results-links">';
$complete_html .= $results_links;
$complete_html .= '</p>';
}
$complete_html .= '</section>';
$complete_html .= '</article>';
}
// Active pagination
if ( $completed_count > $per_page ) {
$current_page = 1;
if ( isset( $_GET['completed_page'] ) && 0 < intval( $_GET['completed_page'] ) ) {
$current_page = $_GET['completed_page'];
}
$complete_html .= '<nav class="pagination woo-pagination">';
$total_pages = ceil( $completed_count / $per_page );
if ( $current_page > 1 ) {
$prev_link = add_query_arg( 'completed_page', $current_page - 1 );
$complete_html .= '<a class="prev page-numbers" href="' . esc_url( $prev_link ) . '">' . esc_html__( 'Previous', 'sensei-lms' ) . '</a> ';
}
for ( $i = 1; $i <= $total_pages; $i++ ) {
$link = add_query_arg( 'completed_page', $i );
if ( $i == $current_page ) {
$complete_html .= '<span class="page-numbers current">' . esc_html( $i ) . '</span> ';
} else {
$complete_html .= '<a class="page-numbers" href="' . esc_url( $link ) . '">' . esc_html( $i ) . '</a> ';
}
}
if ( $current_page < $total_pages ) {
$next_link = add_query_arg( 'completed_page', $current_page + 1 );
$complete_html .= '<a class="next page-numbers" href="' . esc_url( $next_link ) . '">' . esc_html__( 'Next', 'sensei-lms' ) . '</a> ';
}
$complete_html .= '</nav>';
}
}
if ( $manage ) {
$no_active_message = __( 'You have no active courses.', 'sensei-lms' );
$no_complete_message = __( 'You have not completed any courses yet.', 'sensei-lms' );
} else {
$no_active_message = __( 'This student has no active courses.', 'sensei-lms' );
$no_complete_message = __( 'This student has not completed any courses yet.', 'sensei-lms' );
}
ob_start();
?>
<?php
/**
* Action to run before the user courses are displayed.
*
* @hook sensei_before_user_courses
*/
do_action( 'sensei_before_user_courses' );
?>
<?php
if ( $manage && ( ! isset( Sensei()->settings->settings['messages_disable'] ) || ! Sensei()->settings->settings['messages_disable'] ) ) {
?>
<p class="my-messages-link-container">
<a class="my-messages-link" href="<?php echo esc_url( get_post_type_archive_link( 'sensei_message' ) ); ?>"
title="<?php esc_attr_e( 'View & reply to private messages sent to your course & lesson teachers.', 'sensei-lms' ); ?>">
<?php esc_html_e( 'My Messages', 'sensei-lms' ); ?>
</a>
</p>
<?php
}
?>
<div id="my-courses">
<ul>
<li><a href="#active-courses"><?php esc_html_e( 'Active Courses', 'sensei-lms' ); ?></a></li>
<li><a href="#completed-courses"><?php esc_html_e( 'Completed Courses', 'sensei-lms' ); ?></a></li>
</ul>
<?php
/**
* Action to run before the active user courses are displayed.
*
* @hook sensei_before_active_user_courses
*/
do_action( 'sensei_before_active_user_courses' );
?>
<?php
$course_page_url = self::get_courses_page_url();
?>
<div id="active-courses">
<?php
if ( '' != $active_html ) {
echo wp_kses(
$active_html,
array_merge(
wp_kses_allowed_html( 'post' ),
[
// Explicitly allow form tag for WP.com.
'form' => [
'action' => [],
'method' => [],
],
'input' => [
'class' => [],
'id' => [],
'name' => [],
'type' => [],
'value' => [],
],
// Explicitly allow nav tag for WP.com.
'nav' => [
'class' => [],
],
]
)
);
} else {
?>
<div class="sensei-message info">
<?php echo esc_html( $no_active_message ); ?>
<a href="<?php echo esc_url( $course_page_url ); ?>">
<?php esc_html_e( 'Start a Course!', 'sensei-lms' ); ?>
</a>
</div>
<?php } ?>
</div>
<?php
/**
* Action to run after the active user courses are displayed.
*
* @hook sensei_after_active_user_courses
*/
do_action( 'sensei_after_active_user_courses' );
?>
<?php
/**
* Action to run before the completed user courses are displayed.
*
* @hook sensei_before_completed_user_courses
*/
do_action( 'sensei_before_completed_user_courses' );
?>
<div id="completed-courses">
<?php
if ( '' != $complete_html ) {
echo wp_kses(
$complete_html,
array_merge(
wp_kses_allowed_html( 'post' ),
[
// Explicitly allow nav tag for WP.com.
'nav' => [
'class' => [],
],
]
)
);
} else {
?>
<div class="sensei-message info">
<?php echo esc_html( $no_complete_message ); ?>
</div>
<?php } ?>
</div>
<?php
/**
* Action to run after the completed user courses are displayed.
*
* @hook sensei_after_completed_user_courses
*/
do_action( 'sensei_after_completed_user_courses' );
?>
</div>
<?php
/**
* Action to run after the user courses are displayed.
*
* @hook sensei_after_user_courses
*/
do_action( 'sensei_after_user_courses' );
?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output escaped above and should be escaped in hooked methods.
echo ob_get_clean();
/**
* Action to run after the student course content is displayed.
*
* @hook sensei_after_learner_course_content
*
* @param {object} $user User object.
*/
do_action( 'sensei_after_learner_course_content', $user );
}
/**
* Returns a list of all courses
*
* @since 1.8.0
* @return WP_Post[]
*/
public static function get_all_courses() {
$args = [
'post_type' => 'course',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
'post_status' => 'any',
'suppress_filters' => 0,
];
$wp_query_obj = new WP_Query( $args );
/**
* Filter courses in Sensei_Course::get_all_courses.
*
* @hook sensei_get_all_courses
*
* @param {WP_Post[]} Array of courses.
* @return {WP_Post[]} Filtered courses.
*/
return apply_filters( 'sensei_get_all_courses', $wp_query_obj->posts );
}
/**
* Generate the course meter component
*
* @since 1.8.0
* @param int|float $progress_percentage 0 - 100
* @return string $progress_bar_html
*/
public function get_progress_meter( $progress_percentage ) {
if ( 50 < $progress_percentage ) {
$class = ' green';
} elseif ( 25 <= $progress_percentage && 50 >= $progress_percentage ) {
$class = ' orange';
} else {
$class = ' red';
}
$progress_bar_html = '<div class="meter' . esc_attr( $class ) . '"><span class="value" style="width: ' .
esc_attr( $progress_percentage ) . '%">' . esc_html( round( $progress_percentage ) ) . '%</span></div>';
return $progress_bar_html;
}
/**
* Generate a statement that tells users
* how far they are in the course.
*
* @param int $course_id
* @param int $user_id
*
* @return string $statement_html
*/
public function get_progress_statement( $course_id, $user_id ) {
if (
empty( $course_id )
|| empty( $user_id )
|| ! self::is_user_enrolled( $course_id, $user_id )
) {
return '';
}
$completed = count( $this->get_completed_lesson_ids( $course_id, $user_id ) );
$total_lessons = count( $this->course_lessons( $course_id ) );
// translators: Placeholders are the counts for lessons completed and total lessons, respectively.
$statement = sprintf( _n( 'Currently completed %1$s lesson of %2$s in total', 'Currently completed %1$s lessons of %2$s in total', $completed, 'sensei-lms' ), $completed, $total_lessons );
/**
* Filter the course completion statement.
* Default Currently completed $var lesson($plural) of $var in total
*
* @hook sensei_course_completion_statement
*
* @param {string} $statement The course completion statement.
* @return {string} Filtered course completion statement.
*/
return apply_filters( 'sensei_course_completion_statement', $statement );
}
/**
* Returns a summary of course progress statistics in keyed array.
*
* @param int $course_id The id of the course.
*
* @return array $stats An array of progress stats.
* $stats['lessons_count'] The total number of lessons in the course.
* $stats['completed_lessons_count'] The total number of completed lessons of the course by the user.
* $stats['completed_lessons_percentage'] The completed lessons percentage relative to total number of lessons.
*/
public function get_progress_stats( int $course_id ): array {
$lessons_count = count( \Sensei()->course->course_lessons( $course_id, null, 'ids' ) );
$completed_lessons_count = count( \Sensei()->course->get_completed_lesson_ids( $course_id ) );
$stats = [
'lessons_count' => $lessons_count,
'completed_lessons_count' => $completed_lessons_count,
'completed_lessons_percentage' => \Sensei_Utils::quotient_as_absolute_rounded_percentage( $completed_lessons_count, $lessons_count, 2 ),
];
/**
* Filter the course progress stats.
*
* @since 3.14.4
*
* @hook sensei_course_progress_stats
*
* @param {array} $stat An array of course progress stats.
* $stats['lessons_count'] The total number of lessons in the course.
* $stats['completed_lessons_count'] The total number of completed lessons of the course by the user.
* $stats['completed_lessons_percentage'] The completed lessons percentage relative to total number of lessons.
* @param {int} $course_id The id of the course.
* @return {array} Filtered course progress stats.
*/
return apply_filters( 'sensei_course_progress_stats', $stats, $course_id );
}
/**
* Output the course progress statement
*
* @param int $course_id The course ID.
* @param int $user_id The user ID.
*/
public function the_progress_statement( $course_id = 0, $user_id = 0 ) {
if ( empty( $course_id ) ) {
global $post;
$course_id = $post->ID;
}
if ( empty( $user_id ) ) {
$user_id = get_current_user_id();
}
$progress_statement = $this->get_progress_statement( $course_id, $user_id );
if ( ! empty( $progress_statement ) ) {
echo '<div class="progress statement course-completion-rate">' . esc_html( $progress_statement ) . '</div>';
}
}
/**
* Output the course progress bar
*
* @param $course_id
* @return void
*/
public function the_progress_meter( $course_id = 0, $user_id = 0 ) {
if ( empty( $course_id ) ) {
global $post;
$course_id = $post->ID;
}
if ( empty( $user_id ) ) {
$user_id = get_current_user_id();
}
if (
'course' !== get_post_type( $course_id )
|| ! get_userdata( $user_id )
|| ! self::is_user_enrolled( $course_id, $user_id )
) {
return;
}
$percentage_completed = $this->get_completion_percentage( $course_id, $user_id );
echo wp_kses_post( $this->get_progress_meter( $percentage_completed ) );
}
/**
* Checks how many lessons are completed
*
* @since 1.8.0
*
* @param int $course_id The course ID.
* @param int $user_id The user ID.
* @return array $completed_lesson_ids
*/
public function get_completed_lesson_ids( $course_id, $user_id = 0 ) {
if ( ! ( intval( $user_id ) ) > 0 ) {
$user_id = get_current_user_id();
}
$completed_lesson_ids = [];
$course_lessons = $this->course_lessons( $course_id );
foreach ( $course_lessons as $lesson ) {
$is_lesson_completed = Sensei_Utils::user_completed_lesson( $lesson->ID, $user_id );
if ( $is_lesson_completed ) {
$completed_lesson_ids[] = $lesson->ID;
}
}
return $completed_lesson_ids;
}
/**
* Calculate the perceantage completed in the course
*
* @since 1.8.0
*
* @param int $course_id The course ID.
* @param int $user_id The user ID.
* @return float Percentage of the course completed.
*/
public function get_completion_percentage( $course_id, $user_id = 0 ) {
if ( ! ( intval( $user_id ) ) > 0 ) {
$user_id = get_current_user_id();
}
$completed = count( $this->get_completed_lesson_ids( $course_id, $user_id ) );
if ( ! ( $completed > 0 ) ) {
return 0;
}
$total_lessons = count( $this->course_lessons( $course_id ) );
$percentage = Sensei_Utils::quotient_as_absolute_rounded_percentage( $completed, $total_lessons, 2 );
/**
*
* Filter the percentage returned for a users course.
*
* @since 1.8.0
*
* @hook sensei_course_completion_percentage
*
* @param {float} $percentage The percentage of the course completed.
* @param {int} $course_id The ID of the course.
* @param {int} $user_id The ID of the user.
* @return {float} Filtered percentage.
*/
return apply_filters( 'sensei_course_completion_percentage', $percentage, $course_id, $user_id );
}
/**
* Block email notifications for the specific courses that the user disabled the notifications.
*
* @since 1.8.0
*
* @param bool $should_send Whether the email should be sent, initial value.
* @return bool
*/
public function block_notification_emails( $should_send ) {
global $sensei_email_data;
$email = $sensei_email_data;
$course_id = '';
if ( isset( $email['course_id'] ) ) {
$course_id = $email['course_id'];
} elseif ( isset( $email['lesson_id'] ) ) {
$course_id = Sensei()->lesson->get_course_id( $email['lesson_id'] );
} elseif ( isset( $email['quiz_id'] ) ) {
$lesson_id = Sensei()->quiz->get_lesson_id( $email['quiz_id'] );
$course_id = Sensei()->lesson->get_course_id( $lesson_id );
}
if ( ! empty( $course_id ) && 'course' == get_post_type( $course_id ) ) {
$course_emails_disabled = get_post_meta( $course_id, 'disable_notification', true );
if ( $course_emails_disabled ) {
return false;
}
}
return $should_send;
}
/**
* Render the course notification setting meta box
*
* @since 1.8.0
* @param $course
*/
public function course_notification_meta_box_content( $course ) {
$checked = get_post_meta( $course->ID, 'disable_notification', true );
wp_nonce_field( 'update-course-notification-setting', '_sensei_course_notification' );
echo '<input id="disable_sensei_course_notification" ' . checked( $checked, true, false ) . ' type="checkbox" name="disable_sensei_course_notification" >';
echo '<label for="disable_sensei_course_notification">' . esc_html__( 'Disable notifications on this course?', 'sensei-lms' ) . '</label>';
}
/**
* Store the setting for the course notification setting.
*
* @hooked int save_post
* @since 1.8.0
*
* @param $course_id
*/
public function save_course_notification_meta_box( $course_id ) {
if ( ! isset( $_POST['_sensei_course_notification'] )
|| ! wp_verify_nonce( $_POST['_sensei_course_notification'], 'update-course-notification-setting' ) ) {
return;
}
if ( isset( $_POST['disable_sensei_course_notification'] ) && 'on' == $_POST['disable_sensei_course_notification'] ) {
$new_val = true;
} else {
$new_val = false;
}
update_post_meta( $course_id, 'disable_notification', $new_val );
}
/**
* Output a link to view course. The button text is different depending on the amount of preview lesson available.
*
* hooked into 'sensei_course_content_inside_after'
*
* @since 1.9.0
*
* @param integer $course_id
*/
public function the_course_free_lesson_preview( $course_id ) {
// Meta data
$course = get_post( $course_id );
$preview_lesson_count = intval( Sensei()->course->course_lesson_preview_count( $course->ID ) );
$is_user_taking_course = self::is_user_enrolled( $course->ID, get_current_user_id() );
if ( 0 < $preview_lesson_count && ! $is_user_taking_course ) {
?>
<p class="sensei-free-lessons">
<a href="<?php echo esc_url( get_permalink() ); ?>">
<?php esc_html_e( 'Preview this course', 'sensei-lms' ); ?>
</a>
-
<?php
// translators: Placeholder is the number of preview lessons.
echo esc_html( sprintf( __( '(%d preview lessons)', 'sensei-lms' ), $preview_lesson_count ) );
?>
</p>
<?php
}
}
/**
* Add course mata to the course meta hook
*
* @since 1.9.0
* @param integer $course_id
*/
public function the_course_meta( $course_id ) {
$course = get_post( $course_id );
$category_output = get_the_term_list( $course->ID, 'course-category', '', ', ', '' );
$author_display_name = get_the_author_meta( 'display_name', $course->post_author );
$lesson_count = Sensei()->course->course_lesson_count( $course_id );
if ( isset( Sensei()->settings->settings['course_author'] ) && ( Sensei()->settings->settings['course_author'] ) ) {
echo '<span class="course-author">' .
wp_kses(
sprintf(
// translators: %1$s is the author posts URL, %2$s and %3$s are the author name.
__( 'by <a href="%1$s" title="%2$s">%3$s</a>', 'sensei-lms' ),
esc_url( get_author_posts_url( $course->post_author ) ),
esc_attr( $author_display_name ),
esc_html( $author_display_name )
),
[
'a' => [
'href' => [],
'title' => [],
],
]
) .
'</span>';
}
echo '<div class="sensei-course-meta">';
/**
* Fires before the course meta is displayed.
*
* @hook sensei_course_meta_inside_before
*
* @param {int} $course_id The course ID.
*/
do_action( 'sensei_course_meta_inside_before', $course->ID );
echo '<span class="course-lesson-count">' .
// translators: Placeholder %d is the lesson count.
esc_html( sprintf( _n( '%d Lesson', '%d Lessons', $lesson_count, 'sensei-lms' ), $lesson_count ) ) .
'</span>';
if ( ! empty( $category_output ) ) {
echo '<span class="course-category">' .
// translators: Placeholder is a comma-separated list of the Course categories.
wp_kses_post( sprintf( __( 'in %s', 'sensei-lms' ), $category_output ) ) .
'</span>';
}
// number of completed lessons
if ( Sensei_Utils::has_started_course( $course->ID, get_current_user_id() )
|| Sensei_Utils::user_completed_course( $course->ID, get_current_user_id() ) ) {
$completed = count( $this->get_completed_lesson_ids( $course->ID, get_current_user_id() ) );
$lesson_count = count( $this->course_lessons( $course->ID ) );
// translators: Placeholders are the counts for lessons completed and total lessons, respectively.
echo '<span class="course-lesson-progress">' . esc_html( sprintf( __( '%1$d of %2$d lessons completed', 'sensei-lms' ), $completed, $lesson_count ) ) . '</span>';
}
/**
* Fires after course meta is displayed.
*
* @param {int} $course_id The course ID.
*/
do_action( 'sensei_course_meta_inside_after', $course->ID );
echo '</div>';
}
/**
* Filter the classes attached to a post types for courses
* and add a status class for when the user is logged in.
*
* @param $classes
* @param $class
* @param $post_id
*
* @return array $classes
*/
public static function add_course_user_status_class( $classes, $class, $course_id ) {
if ( 'course' == get_post_type( $course_id ) && is_user_logged_in() ) {
if ( Sensei_Utils::user_completed_course( $course_id, get_current_user_id() ) ) {
$classes[] = 'user-status-completed';
} else {
$classes[] = 'user-status-active';
}
}
return $classes;
}
/**
* Prints out the course action buttons links
*
* - complete course
* - delete course
*
* @param WP_Post $course
*/
public static function the_course_action_buttons( $course ) {
if ( ! is_user_logged_in() ) {
return;
}
$has_course_complete_button = false;
$has_results_button = false;
if ( 0 < absint( count( Sensei()->course->course_lessons( $course->ID ) ) )
&& Sensei()->settings->settings['course_completion'] == 'complete'
&& ! Sensei_Utils::user_completed_course( $course, get_current_user_id() ) ) {
$has_course_complete_button = true;
}
$course_purchased = false;
if ( class_exists( 'Sensei_WC' ) && Sensei_WC::is_woocommerce_active() ) {
// Get the product ID.
$wc_post_id = get_post_meta( intval( $course->ID ), '_course_woocommerce_product', true );
if ( 0 < $wc_post_id ) {
$user = wp_get_current_user();
$course_purchased = Sensei_WC::has_customer_bought_product( $user->ID, $wc_post_id );
}
}
$has_quizzes = Sensei()->course->course_quizzes( $course->ID, true );
if ( self::has_results_links( $course->ID ) || $has_quizzes ) {
$has_results_button = true;
}
if ( ! $has_course_complete_button && ! $has_results_button ) {
return;
}
?>
<section class="entry-actions">
<form method="POST" action="<?php echo esc_url( remove_query_arg( [ 'active_page', 'completed_page' ] ) ); ?>">
<input type="hidden"
name="<?php echo esc_attr( 'woothemes_sensei_complete_course_noonce' ); ?>"
id="<?php echo esc_attr( 'woothemes_sensei_complete_course_noonce' ); ?>"
value="<?php echo esc_attr( wp_create_nonce( 'woothemes_sensei_complete_course_noonce' ) ); ?>"
/>
<input type="hidden" name="course_complete_id" id="course-complete-id" value="<?php echo esc_attr( intval( $course->ID ) ); ?>" />
<?php
if ( $has_course_complete_button ) {
wp_enqueue_script( 'sensei-stop-double-submission' );
?>
<span>
<input name="course_complete" type="submit" class="course-complete sensei-stop-double-submission"
value="<?php esc_attr_e( 'Mark as Complete', 'sensei-lms' ); ?>" />
</span>
<?php
}
$results_link = '';
if ( $has_quizzes && Sensei_Utils::user_completed_course( $course, wp_get_current_user()->ID ) ) {
$results_link = '<a class="button view-results" href="' . esc_url( self::get_view_results_link( $course->ID ) ) . '">' .
esc_html__( 'View Results', 'sensei-lms' ) . '</a>';
}
// Output only if there is content to display.
if ( $has_results_button ) {
?>
<p class="sensei-results-links">
<?php
/**
* Filter the results links
*
* @hook sensei_results_links
*
* @param {string} $results_links_html The HTML for the results links.
* @param {int} $course_id The ID of the course.
* @return {string} Filtered results links HTML.
*/
echo wp_kses_post( apply_filters( 'sensei_results_links', $results_link, $course->ID ) );
?>
</p>
<?php
}
?>
</form>
</section>
<?php
}
/**
* This function alter the main query on the course archive page.
* This also gives Sensei specific filters that allows variables to be altered specifically on the course archive.
*
* This function targets only the course archives and the my courses page. Shortcodes can set their own
* query parameters via the arguments.
*
* This function is hooked into pre_get_posts filter
*
* @since 1.9.0
*
* @param WP_Query $query
* @return WP_Query $query
*/
public static function course_query_filter( $query ) {
// exit early for no course queries and admin queries
if ( is_admin() || 'course' != $query->get( 'post_type' ) ) {
return $query;
}
global $post; // used to get the current page id for my courses
// for the course archive page
if ( $query->is_main_query() && is_post_type_archive( 'course' ) ) {
/**
* Filter courses per page on the course archive.
*
* @since 1.9.0
*
* @hook sensei_archive_courses_per_page
*
* @param {int} $posts_per_page defaults to the value of get_option( 'posts_per_page' )
* @return {int} Filtered posts per page.
*/
$query->set( 'posts_per_page', apply_filters( 'sensei_archive_courses_per_page', get_option( 'posts_per_page' ) ) );
if ( isset( $query->query ) && isset( $query->query['paged'] ) && false === $query->get( 'paged', false ) ) {
$query->set( 'paged', $query->query['paged'] );
}
}
// for the my courses page
elseif ( isset( $post ) && is_page() && Sensei()->settings->get_my_courses_page_id() == $post->ID ) {
/**
* Filters number of courses per page on the my courses page as set in the settings.
*
* @since 1.9.0
*
* @hook sensei_my_courses_per_page
* @param {int} $posts_per_page Number of courses per page, default 10.
* @return {int} Filtered posts per page.
*/
$query->set( 'posts_per_page', apply_filters( 'sensei_my_courses_per_page', $query->get( 'posts_per_page', 10 ) ) );
}
return $query;
}
/**
* Determine the class of the course loop
*
* This will output .first or .last and .course-item-number-x
*
* @return array $extra_classes
* @since 1.9.0
*/
public static function get_course_loop_content_class() {
global $sensei_course_loop;
if ( ! isset( $sensei_course_loop ) ) {
$sensei_course_loop = [];
}
if ( ! isset( $sensei_course_loop['counter'] ) ) {
$sensei_course_loop['counter'] = 0;
}
if ( ! isset( $sensei_course_loop['columns'] ) ) {
$sensei_course_loop['columns'] = self::get_loop_number_of_columns();
}
// increment the counter
++$sensei_course_loop['counter'];
$extra_classes = [];
// Apply "first" and "last" CSS classes for grid-based layouts.
if ( 1 !== $sensei_course_loop['columns'] ) {
if ( 0 === ( $sensei_course_loop['counter'] - 1 ) % $sensei_course_loop['columns'] ) {
$extra_classes[] = 'first';
}
if ( 0 === $sensei_course_loop['counter'] % $sensei_course_loop['columns'] ) {
$extra_classes[] = 'last';
}
}
// add the item number to the classes as well.
$extra_classes[] = 'loop-item-number-' . $sensei_course_loop['counter'];
/**
* Filter the course loop class the fires in the in get_course_loop_content_class function
* which is called from the course loop content-course.php.
*
* @since 1.9.0
*
* @hook sensei_course_loop_content_class
*
* @param {array} $extra_classes
* @param {WP_Post} $loop_current_course
* @return {array} Additional CSS classes.
*/
return apply_filters( 'sensei_course_loop_content_class', $extra_classes, get_post() );
}
/**
* Get the number of columns set for Sensei courses
*
* @since 1.9.0
* @return mixed|void
*/
public static function get_loop_number_of_columns() {
/**
* Filter the number of columns on the course archive page.
*
* @since 1.9.0
*
* @hook sensei_course_loop_number_of_columns
*
* @param {int} $number_of_columns Number of columns, default 1.
* @return {int} Filtered number of columns.
*/
return apply_filters( 'sensei_course_loop_number_of_columns', 1 );
}
/**
* Output the course archive filter markup
*
* hooked into sensei_loop_course_before
*
* @since 1.9.0
* @param
*/
public static function course_archive_sorting( $query ) {
// don't show on category pages and other pages
if ( ! is_post_type_archive( 'course' ) || is_tax( 'course-category' ) ) {
return;
}
/**
* Filter the sensei archive course order by values
*
* @since 1.9.0
* @param array $options {
* @type string $option_value
* @type string $option_string
* }
*/
$course_order_by_options = apply_filters(
'sensei_archive_course_order_by_options',
[
'default' => __( 'Default sort', 'sensei-lms' ),
'newness' => __( 'Sort by newest first', 'sensei-lms' ),
'title' => __( 'Sort by title A-Z', 'sensei-lms' ),
]
);
// setup the currently selected item.
$selected = 'default';
if ( isset( $_REQUEST['course-orderby'] ) && in_array( $selected, array_keys( $course_order_by_options ), true ) ) {
$selected = sanitize_text_field( $_REQUEST['course-orderby'] );
}
?>
<form class="sensei-ordering" name="sensei-course-order" method="get">
<?php
// phpcs:disable WordPress.Security.NonceVerification.Recommended
foreach ( $_GET as $param => $value ) {
// We should ignore the field that will be set just below.
if ( 'course-orderby' !== $param ) {
echo '<input type="hidden" name="' . esc_attr( $param ) . '" value="' . esc_attr( $value ) . '" />';
}
}
?>
<select name="course-orderby" class="orderby">
<?php
foreach ( $course_order_by_options as $value => $text ) {
echo '<option value="' . esc_attr( $value ) . '"' . selected( $selected, $value, false ) . '>' . esc_html( $text ) . '</option>';
}
?>
</select>
</form>
<?php
}
/**
* Output the course archive filter markup
*
* hooked into sensei_loop_course_before
*
* @since 1.9.0
* @param
*/
public static function course_archive_filters( $query ) {
// don't show on category pages
if ( is_tax( 'course-category' ) ) {
return;
}
/**
* filter the course archive filter buttons
*
* @since 1.9.0
* @param array $filters{
* @type array ( $id, $url , $title )
* }
*/
$filters = apply_filters(
'sensei_archive_course_filter_by_options',
[
[
'id' => 'all',
'url' => self::get_courses_page_url(),
'title' => __( 'All', 'sensei-lms' ),
],
[
'id' => 'featured',
'url' => add_query_arg( [ 'course_filter' => 'featured' ], self::get_courses_page_url() ),
'title' => __( 'Featured', 'sensei-lms' ),
],
]
);
?>
<ul class="sensei-course-filters clearfix" >
<?php
$active_course_filter = isset( $_GET['course_filter'] ) ? sanitize_text_field( $_GET['course_filter'] ) : 'all';
foreach ( $filters as $filter ) {
$active_class = $active_course_filter == $filter['id'] ? 'active' : '';
echo '<li><a class="' . esc_attr( $active_class ) . '" id="' . esc_attr( $filter['id'] ) . '" href="' . esc_url( $filter['url'] ) . '" >' . esc_html( $filter['title'] ) . '</a></li>';
}
?>
</ul>
<?php
}
/**
* if the featured link is clicked on the course archive page
* filter the courses returned to only show those featured
*
* Hooked into pre_get_posts
*
* @since 1.9.0
* @param WP_Query $query
* @return WP_Query $query
*/
public static function course_archive_featured_filter( $query ) {
if ( isset( $_GET['course_filter'] ) && 'featured' == $_GET['course_filter'] && $query->is_main_query() ) {
// setup meta query for featured courses
$query->set( 'meta_value', 'featured' );
$query->set( 'meta_key', '_course_featured' );
$query->set( 'meta_compare', '=' );
}
return $query;
}
/**
* If the category filter is used on the course archive page
* filter the courses returned to only show those in that category.
*
* Hooked into pre_get_posts
*
* @since 4.11.0
*
* @param WP_Query $query Incoming WP_Query object.
*
* @return WP_Query $query
*/
public static function course_archive_category_filter( $query ) {
if ( isset( $_GET['course_category_filter'] ) && intval( $_GET['course_category_filter'] ) > 0 && $query->is_main_query() ) {
$query->set(
'tax_query',
[
[
'taxonomy' => 'course-category',
'field' => 'id',
'terms' => intval( $_GET['course_category_filter'] ),
],
]
);
}
return $query;
}
/**
* If the student course filter is used on the course archive page
* filter the courses returned to only show those in that student course state.
*
* Hooked into pre_get_posts
*
* @since 4.11.0
*
* @param WP_Query $query Incoming WP_Query object.
*
* @return WP_Query $query
*/
public static function course_archive_student_course_state_filter( $query ) {
if ( isset( $_GET['student_course_filter'] ) && $query->is_main_query() && is_user_logged_in() ) {
$learner_manager = Sensei_Learner::instance();
$user_id = get_current_user_id();
$selected_option = sanitize_text_field( wp_unslash( $_GET['student_course_filter'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
$args = [
'posts_per_page' => -1,
'fields' => 'ids',
];
switch ( $selected_option ) {
case 'active':
$courses_query = $learner_manager->get_enrolled_active_courses_query( $user_id, $args );
$course_ids = $courses_query->posts;
break;
case 'completed':
$courses_query = $learner_manager->get_enrolled_completed_courses_query( $user_id, $args );
$course_ids = $courses_query->posts;
break;
default:
return $query;
}
$query->set( 'post__in', $course_ids );
}
return $query;
}
/**
* If the course order drop down is changed.
* In versions previous to 3.15.0 this method was hooked into pre_get_posts.
*
* @since 1.9.0
* @deprecated 3.15.0
* @param WP_Query $query WordPress query.
* @return WP_Query
*/
public static function course_archive_order_by_title( $query ) {
_deprecated_function( __METHOD__, '3.15.0' );
if ( isset( $_REQUEST['course-orderby'] ) && 'title' === sanitize_text_field( wp_unslash( $_REQUEST['course-orderby'] ) )
&& 'course' === $query->get( 'post_type' ) && $query->is_main_query() ) {
// Setup the order by title for this query.
$query->set( 'orderby', 'title' );
$query->set( 'order', 'ASC' );
}
return $query;
}
/**
* Set the sorting options based on the query parameter and configuration in the Courses archive page.
*
* Hooked into pre_get_posts.
*
* @since 3.15.0
* @param WP_Query $query WordPress query.
* @return WP_Query
*/
public static function course_archive_set_order_by( $query ) {
// Exit early if it is from admin panel or anywhere else other than an archive page, like admin panel and pages with shortcode.
if ( ! $query->is_post_type_archive( 'course' ) || ! $query->is_main_query() || is_admin() ) {
return;
}
// Default sort order depends on custom course order being set or not.
$orderby = 'date';
$order = 'DESC';
if ( ! empty( get_option( 'sensei_course_order', '' ) ) ) {
$orderby = 'menu_order';
$order = 'ASC';
}
// phpcs:ignore WordPress.Security.NonceVerification
if ( isset( $_REQUEST['course-orderby'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification
$request_orderby = sanitize_text_field( wp_unslash( $_REQUEST['course-orderby'] ) );
switch ( $request_orderby ) {
case 'title':
$orderby = 'title';
$order = 'ASC';
break;
case 'newness':
$orderby = 'date';
$order = 'DESC';
break;
case 'default':
// Use default values (initialized above).
break;
}
}
$query->set( 'orderby', $orderby );
$query->set( 'order', $order );
return $query;
}
/**
* Get the link to the courses page. This will be the course post type archive
* page link or the page the user set in their settings
*
* @since 1.9.0
* @return string $course_page_url
*/
public static function get_courses_page_url() {
$course_page_id = intval( Sensei()->settings->settings['course_page'] );
$course_page_url = empty( $course_page_id ) ? get_post_type_archive_link( 'course' ) : get_permalink( $course_page_id );
/**
* Filter the course archive page URL.
*
* @since 3.0.0
*
* @hook sensei_course_archive_page_url
*
* @param {string} $course_page_url Course archive page URL.
* @return {string} Course archive page URL.
*/
return apply_filters( 'sensei_course_archive_page_url', $course_page_url );
}
/**
* Get the link to the course completed page.
*
* @since 3.13.0
* @param int $course_id Course ID to append to URL.
*
* @return string The URL or empty string if page is not set or does not exist.
*/
public static function get_course_completed_page_url( $course_id ) {
$page_id = isset( Sensei()->settings->settings['course_completed_page'] ) ? intval( Sensei()->settings->settings['course_completed_page'] ) : 0;
/**
* Filter the course completed page ID.
*
* @since 4.23.1
*
* @hook sensei_course_completed_page_id
*
* @param {int} $page_id Course completed page ID.
* @return {int} Course completed page ID.
*/
$page_id = apply_filters( 'sensei_course_completed_page_id', $page_id );
$url = $page_id ? get_permalink( $page_id ) : '';
$url = $url && 0 < $course_id ? add_query_arg( 'course_id', $course_id, $url ) : '';
/**
* Filter the course completed page URL.
*
* @since 3.13.0
*
* @hook sensei_course_completed_page_url
*
* @param {string} $url Course completed page URL.
* @param {int} $course_id ID of the course that was completed.
* @return {string} Course completed page URL.
*/
return apply_filters( 'sensei_course_completed_page_url', $url, $course_id );
}
/**
* Get the link for the View Results button.
*
* @since 3.13.0
* @param int $course_id Course ID.
*
* @return string The URL for the View Results button.
*/
public static function get_view_results_link( $course_id ) {
$url = self::get_course_completed_page_url( $course_id );
if ( ! $url ) {
$url = Sensei()->course_results->get_permalink( $course_id );
}
return $url;
}
/**
* Output the headers on the course archive page
*
* @since 1.9.0
* @param string $query_type
* @param string $before_html
* @param string $after_html
* @return void
*/
public static function archive_header( $query_type = '', $before_html = '', $after_html = '' ) {
if ( ! is_post_type_archive( 'course' ) ) {
return;
}
$html = '';
if ( empty( $before_html ) ) {
$before_html = '<header class="archive-header"><h1>';
}
if ( empty( $after_html ) ) {
$after_html = '</h1></header>';
}
if ( is_tax( 'course-category' ) ) {
global $wp_query;
$taxonomy_obj = $wp_query->get_queried_object();
$taxonomy_short_name = $taxonomy_obj->taxonomy;
$taxonomy_raw_obj = get_taxonomy( $taxonomy_short_name );
// translators: Placeholders are the taxonomy name and the term name, respectively.
$title = sprintf( __( '%1$s Archives: %2$s', 'sensei-lms' ), $taxonomy_raw_obj->labels->name, $taxonomy_obj->name );
echo wp_kses_post( apply_filters( 'course_category_archive_title', $before_html . $title . $after_html ) );
return;
}
switch ( $query_type ) {
case 'newcourses':
$html .= $before_html . __( 'New Courses', 'sensei-lms' ) . $after_html;
break;
case 'featuredcourses':
$html .= $before_html . __( 'Featured Courses', 'sensei-lms' ) . $after_html;
break;
case 'freecourses':
$html .= $before_html . __( 'Free Courses', 'sensei-lms' ) . $after_html;
break;
case 'paidcourses':
$html .= $before_html . __( 'Paid Courses', 'sensei-lms' ) . $after_html;
break;
default:
$html .= $before_html . __( 'Courses', 'sensei-lms' ) . $after_html;
break;
}
echo wp_kses_post( apply_filters( 'course_archive_title', $html ) );
}
/**
* Filter the single course content taking into account if the user has access.
*
* @since 1.9.0
*
* @param string $content
* @return string $content or $excerpt
*/
public static function single_course_content( $content ) {
if ( ! is_singular( 'course' ) ) {
return $content;
}
/**
* Access check for the course content.
*
* @since 2.0.0
*
* @hook sensei_course_content_has_access
*
* @param {bool} $has_access_to_content Filtered variable for if the visitor has access to view the content.
* @param {int} $course_id Post ID for the course.
* @return {bool} Filtered variable for if the visitor has access to view the content.
*/
if ( apply_filters( 'sensei_course_content_has_access', true, get_the_ID() ) ) {
if ( empty( $content ) ) {
remove_filter( 'the_content', [ 'Sensei_Course', 'single_course_content' ] );
$course = get_post( get_the_ID() );
$content = apply_filters( 'the_content', $course->post_content );
}
return $content;
} else {
return '<p class="course-excerpt">' . get_post( get_the_ID() )->post_excerpt . '</p>';
}
}
/**
* Output the the single course lessons title with markup.
*
* @since 1.9.0
*/
public static function the_course_lessons_title() {
if ( ! is_singular( 'course' ) || ! Sensei_Utils::show_course_lessons( get_the_ID() ) ) {
return;
}
global $post;
$none_module_lessons = Sensei()->modules->get_none_module_lessons( $post->ID );
$course_lessons = Sensei()->course->course_lessons( $post->ID );
// title should be Other Lessons if there are lessons belonging to models.
$title = __( 'Other Lessons', 'sensei-lms' );
// show header if there are lessons the number of lesson in the course is the same as those that isn't assigned to a module
if ( ! empty( $course_lessons ) && count( $course_lessons ) == count( $none_module_lessons ) ) {
$title = ( 1 === count( $course_lessons ) ) ? __( 'Lesson', 'sensei-lms' ) : __( 'Lessons', 'sensei-lms' );
} elseif ( empty( $none_module_lessons ) ) { // if the none module lessons are simply empty the title should not be shown
$title = '';
}
/**
* Filter Sensei single title
*
* @hook sensei_single_title
*
* @param {string} $title The title.
* @param {string} $post_type The post type.
* @return {string} Filtered title.
*/
$title = apply_filters( 'sensei_single_title', $title, $post->post_type );
ob_start(); // start capturing the following output.
?>
<header>
<h2>
<?php echo esc_html( $title ); ?>
</h2>
</header>
<?php
/**
* Filter the title and markup that appears above the lessons on a single course
* page.
*
* @since 1.9.0
* @param string $lessons_title_html
*/
echo wp_kses_post( apply_filters( 'the_course_lessons_title', ob_get_clean() ) ); // output and filter the captured output and stop capturing.
}
/**
* This function loads the global wp_query object with lessons
* of the current course. It is designed to be used on the single-course template
* and expects the global post to be a singular course.
*
* This function excludes lessons belonging to modules as they are
* queried separately.
*
* @since 1.9.0
* @global $wp_query
*/
public static function load_single_course_lessons_query() {
global $post, $wp_query;
$course_id = $post->ID;
if ( 'course' != get_post_type( $course_id ) ) {
return;
}
$course_lessons_post_status = isset( $wp_query ) && $wp_query->is_preview() ? 'all' : 'publish';
$course_lesson_query_args = [
'post_status' => $course_lessons_post_status,
'post_type' => 'lesson',
'posts_per_page' => 500,
'orderby' => 'date',
'order' => 'ASC',
'meta_query' => [
[
'key' => '_lesson_course',
'value' => intval( $course_id ),
],
],
'suppress_filters' => 0,
];
// Exclude lessons belonging to modules as they are queried along with the modules.
$modules = Sensei()->modules->get_course_modules( $course_id );
if ( ! is_wp_error( $modules ) && ! empty( $modules ) && is_array( $modules ) ) {
$terms_ids = [];
foreach ( $modules as $term ) {
$terms_ids[] = $term->term_id;
}
$course_lesson_query_args['tax_query'] = [
[
'taxonomy' => 'module',
'field' => 'id',
'terms' => $terms_ids,
'operator' => 'NOT IN',
],
];
}
// setting lesson order
$course_lesson_order = get_post_meta( $course_id, '_lesson_order', true );
$all_ids = get_posts(
[
'post_type' => 'lesson',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_key' => '_lesson_course',
'meta_value' => intval( $course_id ),
]
);
if ( ! empty( $course_lesson_order ) ) {
$course_lesson_query_args['post__in'] = array_merge( explode( ',', $course_lesson_order ), $all_ids );
$course_lesson_query_args['orderby'] = 'post__in';
unset( $course_lesson_query_args['order'] );
}
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Used for lesson loop on single course page. Reset in hook to `sensei_single_course_lessons_after`.
$wp_query = new WP_Query( $course_lesson_query_args );
}
/**
* If the user is already taking the course, show a progress indicator.
* Otherwise, output the course actions like start taking course, register,
* etc.
*
* @since 1.9.0
*/
public static function the_course_enrolment_actions() {
global $post, $current_user;
if ( 'course' != $post->post_type ) {
return;
}
$course_id = $post->ID;
$user_id = $current_user->ID;
?>
<section class="course-meta course-enrolment">
<?php
// Check if course is completed.
$completed_course = false;
if ( ! empty( $user_id ) ) {
$completed_course = Sensei_Utils::user_completed_course( $course_id, $user_id );
}
/**
* Display course enrollment actions.
*
* @since 3.13.3
*
* @param {boolean} $display_actions Whether display the actions.
* @param {int} $course_id Course ID.
* @param {int} $user_id User ID.
* @param {boolean} $completed_course Whether user completed the course.
*
* @return {boolean} Whether display course enrollment actions.
*/
$display_actions = apply_filters(
'sensei_display_course_enrollment_actions',
self::is_user_enrolled( $course_id, $user_id ),
$course_id,
$user_id,
$completed_course
);
if ( $display_actions ) {
// Success message
if ( $completed_course ) {
?>
<div class="status completed"><?php esc_html_e( 'Completed', 'sensei-lms' ); ?></div>
<?php
$has_quizzes = Sensei()->course->course_quizzes( $course_id, true );
if ( self::has_results_links( $course_id ) || $has_quizzes ) {
?>
<p class="sensei-results-links">
<?php
$results_link = '';
if ( $has_quizzes ) {
$results_link = '<a class="view-results" href="' . esc_url( self::get_view_results_link( $course_id ) ) . '">' . esc_html__( 'View Results', 'sensei-lms' ) . '</a>';
}
/**
* Filter results links.
*
* @hook sensei_results_links
*
* @param {string} $results_links HTML for results links.
* @param {int} $course_id Course ID.
* @return {string} HTML for results links.
*/
$results_link = apply_filters( 'sensei_results_links', $results_link, $course_id );
echo wp_kses_post( $results_link );
?>
</p>
<?php
}
} else {
?>
<div class="status in-progress"><?php echo esc_html__( 'In Progress', 'sensei-lms' ); ?></div>
<?php
}
} else {
/**
* Action to output the course enrolment buttons.
* When this is called, we know that the user is not taking the course,
* but do not know whether the user is logged in.
*
* @since 2.0.0
*
* @hook sensei_output_course_enrolment_actions
*/
do_action( 'sensei_output_course_enrolment_actions' );
}
?>
</section>
<?php
}
/**
* Check if a user can manually enrol themselves.
*
* @since 4.19.0 Add a check whether self-enrollment is not allowed.
*
* @param int $course_id Course post ID.
*
* @return bool
*/
public static function can_current_user_manually_enrol( $course_id ) {
// Check if the user is already enrolled through any provider.
$is_user_enrolled = self::is_user_enrolled( $course_id, get_current_user_id() );
// Check if self-enrollment is not allowed for this course.
$is_self_enrollment_not_allowed = self::is_self_enrollment_not_allowed( $course_id );
$default_can_user_manually_enrol = is_user_logged_in() && ! $is_user_enrolled && ! $is_self_enrollment_not_allowed;
$can_user_manually_enrol = apply_filters_deprecated(
'sensei_display_start_course_form',
[ $default_can_user_manually_enrol, $course_id ],
'3.0.0',
'sensei_can_user_manually_enrol'
);
/**
* Check if currently logged in user can manually enrol themselves. Defaults to `true` when not already enrolled.
*
* @since 3.0.0
*
* @hook sensei_can_user_manually_enrol
*
* @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} Filtered value.
*/
return (bool) apply_filters( 'sensei_can_user_manually_enrol', $can_user_manually_enrol, $course_id );
}
/**
* Check if self-enrollment is not allowed for the given course.
*
* @since 4.19.0
*
* @param int $course_id Course post ID.
*
* @return boolean Whether self-enrollment is not allowed.
*/
public static function is_self_enrollment_not_allowed( $course_id ) {
$self_enrollment_not_allowed = (bool) get_post_meta( $course_id, '_sensei_self_enrollment_not_allowed', true );
/**
* Check if self-enrollment is not allowed.
*
* @since 4.19.0
*
* @hook sensei_self_enrollment_not_allowed
*
* @param {bool} $self_enrollment_not_allowed True if self-enrollment is not allowed, false otherwise.
* @param {int} $course_id Course post ID.
* @return {bool} Filtered value.
*/
return (bool) apply_filters( 'sensei_self_enrollment_not_allowed', $self_enrollment_not_allowed, $course_id );
}
/**
* Output the course actions like start taking course, register, etc. Note
* that this expects that the user is not already taking the course; that
* check is done in `the_course_enrolment_actions`.
*
* @access private
*
* @since 2.0.0
*/
public static function output_course_enrolment_actions() {
global $post;
$is_course_content_restricted = (bool) apply_filters_deprecated(
'sensei_is_course_content_restricted',
[ false, $post->ID ],
'3.0.0',
null
);
if ( is_user_logged_in() && $is_course_content_restricted ) {
self::add_course_access_permission_message( '' );
}
if ( self::can_current_user_manually_enrol( $post->ID ) ) {
sensei_start_course_form( $post->ID );
} elseif ( ! self::is_self_enrollment_not_allowed( $post->ID ) ) {
if ( get_option( 'users_can_register' ) ) {
// set the permissions message
$anchor_before = '<a href="' . esc_url( sensei_user_login_url() ) . '" >';
$anchor_after = '</a>';
$notice = sprintf(
// translators: Placeholders are an opening and closing <a> tag linking to the login URL.
__( 'or %1$slog in%2$s to view this course.', 'sensei-lms' ),
$anchor_before,
$anchor_after
);
self::add_course_access_permission_message( $notice );
if (
! (bool) apply_filters_deprecated(
'sensei_user_can_register_for_course',
[ true, $post->ID ],
'3.0.0',
null
)
) {
return;
}
$my_courses_url = sensei_user_registration_url( false );
if ( ! empty( $my_courses_url ) ) {
echo '<div class="status register"><a href="' . esc_url( $my_courses_url ) . '">' .
esc_html__( 'Register', 'sensei-lms' ) . '</a></div>';
} else {
wp_register( '<div class="status register">', '</div>' );
}
}
}
}
/**
* Output the course video inside the loop.
*
* @since 1.9.0
*/
public static function the_course_video() {
global $post;
if ( ! ( $post instanceof WP_Post ) || 'course' !== $post->post_type ) {
return;
}
// Get the meta info
$course_video_embed = get_post_meta( $post->ID, '_course_video_embed', true );
if ( 'http' == substr( $course_video_embed, 0, 4 ) ) {
$course_video_embed = wp_oembed_get( esc_url( $course_video_embed ) );
}
$course_video_embed = do_shortcode( $course_video_embed );
$course_video_embed = Sensei_Wp_Kses::maybe_sanitize( $course_video_embed, self::$allowed_html );
if ( '' != $course_video_embed ) {
?>
<div class="course-video">
<?php echo wp_kses( $course_video_embed, self::$allowed_html ); ?>
</div>
<?php
}
}
/**
* Output the title for the single lesson page
*
* @global $post
* @since 1.9.0
*/
public static function the_title() {
if ( ! is_singular( 'course' ) ) {
return;
}
global $post;
?>
<header>
<h1>
<?php
/**
* Filter Sensei single title
*
* @hook sensei_single_title
*
* @param {string} $title The title.
* @param {string} $post_type The post type.
* @return {string} Filtered title.
*/
echo wp_kses_post( apply_filters( 'sensei_single_title', get_the_title( $post ), $post->post_type ) );
?>
</h1>
</header>
<?php
}
/**
* Show the title on the course category pages
*
* @since 1.9.0
*/
public static function course_category_title() {
if ( ! is_tax( 'course-category' ) ) {
return;
}
$term = get_queried_object();
if ( ! empty( $term ) ) {
$title = __( 'Course Category:', 'sensei-lms' ) . ' ' . $term->name;
} else {
$title = __( 'Course Category', 'sensei-lms' );
}
$html = '<h2 class="sensei-category-title">';
$html .= $title;
$html .= '</h2>';
echo wp_kses_post( apply_filters( 'course_category_title', $html, $term->term_id ) );
}
/**
* Alter the course query to respect the order set for courses and apply
* this on the course-category pages.
*
* @since 1.9.0
*
* @param WP_Query $query
* @return WP_Query
*/
public static function alter_course_category_order( $query ) {
if ( ! $query->is_main_query() || ! is_tax( 'course-category' ) ) {
return $query;
}
$order = get_option( 'sensei_course_order', '' );
if ( ! empty( $order ) ) {
$query->set( 'orderby', 'menu_order' );
$query->set( 'order', 'ASC' );
}
return $query;
}
/**
* The very basic course query arguments
* so we don't have to repeat this througout
* the code base.
*
* Usage:
* $args = Sensei_Course::get_default_query_args();
* $args['custom_arg'] ='custom value';
* $courses = get_posts( $args )
*
* @since 1.9.0
*
* @return array
*/
public static function get_default_query_args() {
return [
'post_type' => 'course',
'posts_per_page' => 1000,
'orderby' => 'date',
'order' => 'DESC',
'suppress_filters' => 0,
];
}
/**
* Check if the prerequisite course is completed
* Courses with no pre-requisite should always return true
*
* @since 1.9.0
* @param $course_id
* @return bool
*/
public static function is_prerequisite_complete( $course_id ) {
$course_prerequisite_id = get_post_meta( $course_id, '_course_prerequisite', true );
// if it has a pre requisite course check it
$prerequisite_complete = true;
if ( ! empty( $course_prerequisite_id ) ) {
$prerequisite_complete = Sensei_Utils::user_completed_course( $course_prerequisite_id, get_current_user_id() );
}
/**
* Filter course prerequisite complete
*
* @since 1.9.10
*
* @hook sensei_course_is_prerequisite_complete
*
* @param {bool} $prerequisite_complete Whether the prerequisite is complete.
* @param {int} $course_id Course ID.
* @return {bool} Whether the prerequisite is complete.
*/
return apply_filters( 'sensei_course_is_prerequisite_complete', $prerequisite_complete, $course_id );
}
/**
* Allowing user to set course archive page as front page.
*
* Expects to be called during pre_get_posts, but only if page_on_front is set
* to a non-empty value.
*
* @since 1.9.5
* @param WP_Query $query hooked in from pre_get_posts
*/
function allow_course_archive_on_front_page( $query ) {
// Bail if it's clear we're not looking at a static front page or if the $running flag is
// set @see https://github.com/Automattic/sensei/issues/1438 and https://github.com/Automattic/sensei/issues/1491
if ( is_admin() || false === $query->is_main_query() || false === $this->is_front_page( $query ) ) {
return;
}
// We don't need this callback to run for subsequent queries (nothing after the main query interests us
// besides the need to avoid an infinite loop of doom when we call get_posts() on our cloned query
remove_action( 'pre_get_posts', [ $this, 'allow_course_archive_on_front_page' ] );
// Set the flag indicating our test query is (about to be) running
$query_check = clone $query;
$posts = $query_check->get_posts();
if ( ! $query_check->have_posts() ) {
return;
}
// Check if the first returned post matches the currently set static frontpage
$post = array_shift( $posts );
if ( 'page' !== $post->post_type || $post->ID != get_option( 'page_on_front' ) ) {
return;
}
// for a valid post that doesn't have any of the old short codes set the archive the same
// as the page URI
$settings_course_page = get_post( Sensei()->settings->get( 'course_page' ) );
if ( ! is_a( $settings_course_page, 'WP_Post' )
|| Sensei()->post_types->has_old_shortcodes( $settings_course_page->post_content )
|| $settings_course_page->ID != get_option( 'page_on_front' ) ) {
return;
}
$query->set( 'post_type', 'course' );
$query->set( 'page_id', '' );
// Set properties to match an archive
$query->is_page = 0;
$query->is_singular = 0;
$query->is_post_type_archive = 1;
$query->is_archive = 1;
}
/**
* Workaround for determining if this is the front page.
* We cannot use is_front_page() on pre_get_posts, or it will throw notices.
* See https://core.trac.wordpress.org/ticket/21790
*
* @param WP_Query $query
* @return bool
*/
private function is_front_page( $query ) {
if ( 'page' != get_option( 'show_on_front' ) ) {
return false;
}
$page_on_front = get_option( 'page_on_front', '' );
if ( empty( $page_on_front ) ) {
return false;
}
$page_id = $query->get( 'page_id', '' );
if ( empty( $page_id ) ) {
return false;
}
return $page_on_front == $page_id;
}
/**
* Show a message telling the user to complete the previous course if they haven't done so yet
*
* @since 1.9.10
*/
public static function prerequisite_complete_message() {
if ( ! self::is_prerequisite_complete( get_the_ID() ) ) {
$message = self::get_course_prerequisite_message( get_the_ID() );
Sensei()->notices->add_notice( $message, 'info' );
}
}
/**
* Show a message telling the user that they are not allowed to self enroll if the setting is enabled.
*
* @since 4.19.0
*
* @internal
*/
public static function self_enrollment_not_allowed_message() {
$course_id = get_the_ID();
$user_id = get_current_user_id();
if ( false === $course_id ) {
return;
}
if ( self::is_self_enrollment_not_allowed( $course_id ) && ! self::is_user_enrolled( $course_id, $user_id ) ) {
Sensei()->notices->add_notice(
__( 'Please contact the course administrator to sign up for this course.', 'sensei-lms' ),
'info'
);
}
}
/**
* Generate the HTML of the course prerequisite notice.
*
* @param int $course_id The course id.
*
* @return string The HTML.
*/
public static function get_course_prerequisite_message( int $course_id ): string {
$course_prerequisite_id = absint( get_post_meta( $course_id, '_course_prerequisite', true ) );
$course_title = get_the_title( $course_prerequisite_id );
$prerequisite_course_link = '<a href="' . esc_url( get_permalink( $course_prerequisite_id ) )
. '" title="'
. sprintf(
// translators: Placeholder is the item title.
esc_attr__( 'You must first complete: %1$s', 'sensei-lms' ),
$course_title
)
. '">' . $course_title . '</a>';
$complete_prerequisite_message = sprintf(
// translators: Placeholder $1$s is the course title.
esc_html__( 'You must first complete %1$s before taking this course.', 'sensei-lms' ),
$prerequisite_course_link
);
/**
* Filter sensei_course_complete_prerequisite_message.
*
* @since 1.9.10
*
* @hook sensei_course_complete_prerequisite_message
*
* @param {string} $complete_prerequisite_message The message to filter
* @return {string} The filtered message.
*/
return apply_filters( 'sensei_course_complete_prerequisite_message', $complete_prerequisite_message );
}
/**
* Log an event when a course is initially published.
*
* @since 2.1.0
* @access private
*
* @param WP_Post $course The Course.
*/
public function log_initial_publish_event( $course ) {
$product_ids = get_post_meta( $course->ID, '_course_woocommerce_product', false );
$product_count = empty( $product_ids ) ? 0 : count( array_filter( $product_ids, 'is_numeric' ) );
$modules = wp_get_post_terms( $course->ID, 'module' );
$event_properties = [
'module_count' => is_countable( $modules ) ? count( $modules ) : 0,
'lesson_count' => $this->course_lesson_count( $course->ID ),
'product_count' => $product_count,
'sample_course' => Sensei_Data_Port_Manager::SAMPLE_COURSE_SLUG === $course->post_name ? 1 : 0,
];
sensei_log_event( 'course_publish', $event_properties );
}
/**
* Mark updating course id when no resave is needed for id sync.
*
* Hooked into `save_post_course`.
*
* @since 3.6.0
* @access private
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
*/
public function mark_updating_course_id( $post_id, $post ) {
if ( 'publish' !== $post->post_status ) {
return;
}
$content = $post->post_content;
if ( has_block( 'sensei-lms/course-outline', $content ) ) {
$blocks = parse_blocks( $content );
if ( ! $this->has_pending_id_sync( $blocks ) ) {
$this->course_id_updating = $post_id;
}
} else {
$this->course_id_updating = $post_id;
}
}
/**
* Check if blocks has pending id sync.
*
* @since 3.6.0
*
* @param array[] $blocks Blocks array.
*
* @return boolean Whether has block with pending id sync.
*/
private function has_pending_id_sync( $blocks ) {
foreach ( $blocks as $block ) {
$is_checkable_block = 'sensei-lms/course-outline-module' === $block['blockName']
|| 'sensei-lms/course-outline-lesson' === $block['blockName'];
if (
// Check pending id sync.
( $is_checkable_block && empty( $block['attrs']['id'] ) )
// Check inner blocks pending id sync.
|| (
! empty( $block['innerBlocks'] )
&& $this->has_pending_id_sync( $block['innerBlocks'] )
)
) {
return true;
}
}
return false;
}
/**
* Log the course update.
*
* Hooked into `shutdown`.
*
* @since 3.6.0
* @access private
*/
public function log_course_update() {
if ( empty( $this->course_id_updating ) ) {
return;
}
$course_id = $this->course_id_updating;
$post = get_post( $course_id );
if ( empty( $post ) ) {
return;
}
$content = $post->post_content;
$product_ids = get_post_meta( $course_id, '_course_woocommerce_product', false );
$product_count = empty( $product_ids ) ? 0 : count( array_filter( $product_ids, 'is_numeric' ) );
$modules = wp_get_post_terms( $course_id, 'module' );
$event_properties = [
'course_id' => $course_id,
'has_outline_block' => has_block( 'sensei-lms/course-outline', $content ) ? 1 : 0,
'has_progress_block' => has_block( 'sensei-lms/course-progress', $content ) ? 1 : 0,
'has_take_course_block' => has_block( 'sensei-lms/button-take-course', $content ) ? 1 : 0,
'has_contact_teacher_block' => has_block( 'sensei-lms/button-contact-teacher', $content ) ? 1 : 0,
'has_conditional_content_block' => has_block( 'sensei-lms/conditional-content', $content ) ? 1 : 0,
'module_count' => is_countable( $modules ) ? count( $modules ) : 0,
'lesson_count' => $this->course_lesson_count( $course_id ),
'product_count' => $product_count,
'sample_course' => Sensei_Data_Port_Manager::SAMPLE_COURSE_SLUG === $post->post_name ? 1 : 0,
];
sensei_log_event( 'course_update', $event_properties );
}
/**
* Disable log course update when it's a REST request.
*
* Hooked into `rest_api_init`.
*
* @since 3.6.0
* @access private
*/
public function disable_log_course_update() {
remove_action( 'shutdown', [ $this, 'log_course_update' ] );
}
/**
* Setup the single course page.
*
* @access private
*/
public function setup_single_course_page() {
global $post;
// Remove legacy actions on courses with new blocks.
if (
$post
&& is_singular( 'course' )
&& $this->has_sensei_blocks( $post )
) {
$this->remove_legacy_course_actions();
}
}
/**
* Adds legacy course actions.
*
* @param Sensei_Main $sensei Sensei object.
*/
public function add_legacy_course_hooks( $sensei ) {
// Legacy progress bar on the single course page.
add_action( 'sensei_single_course_content_inside_before', [ $this, 'the_progress_statement' ], 15 );
add_action( 'sensei_single_course_content_inside_before', [ $this, 'the_progress_meter' ], 16 );
// Legacy lesson listing.
add_action( 'sensei_single_course_content_inside_after', [ __CLASS__, 'the_course_lessons_title' ], 9 );
add_action( 'sensei_single_course_content_inside_after', 'course_single_lessons', 10 );
// Take this course.
add_action( 'sensei_single_course_content_inside_before', [ __CLASS__, 'the_course_enrolment_actions' ], 30 );
// Module listing.
add_action( 'sensei_single_course_content_inside_after', [ $sensei->modules, 'load_course_module_content_template' ], 8 );
// Add message links to courses.
add_action( 'sensei_single_course_content_inside_before', [ $sensei->post_types->messages, 'send_message_link' ], 35 );
// Course prerequisite completion message.
add_action( 'sensei_single_course_content_inside_before', [ 'Sensei_Course', 'prerequisite_complete_message' ], 20 );
// Self-enrollment not allowed message.
add_action( 'sensei_single_course_content_inside_before', [ 'Sensei_Course', 'self_enrollment_not_allowed_message' ], 20 );
}
/**
* Remove legacy course actions.
*/
public function remove_legacy_course_actions() {
// Legacy lesson listing.
remove_action( 'sensei_single_course_content_inside_after', [ __CLASS__, 'the_course_lessons_title' ], 9 );
remove_action( 'sensei_single_course_content_inside_after', 'course_single_lessons', 10 );
// Module listing.
remove_action( 'sensei_single_course_content_inside_after', [ Sensei()->modules, 'load_course_module_content_template' ], 8 );
// Legacy progress bar on the single course page.
remove_action( 'sensei_single_course_content_inside_before', [ $this, 'the_progress_statement' ], 15 );
remove_action( 'sensei_single_course_content_inside_before', [ $this, 'the_progress_meter' ], 16 );
// Take this course.
remove_action( 'sensei_single_course_content_inside_before', [ __CLASS__, 'the_course_enrolment_actions' ], 30 );
// Course prerequisite completion message.
remove_action( 'sensei_single_course_content_inside_before', [ 'Sensei_Course', 'prerequisite_complete_message' ], 20 );
// Self-enrollment not allowed message.
remove_action( 'sensei_single_course_content_inside_before', [ 'Sensei_Course', 'self_enrollment_not_allowed_message' ], 20 );
// Add message links to courses.
remove_action( 'sensei_single_course_content_inside_before', [ Sensei()->post_types->messages, 'send_message_link' ], 35 );
}
/**
* Check if a course is a legacy course.
*
* @param int|WP_Post $course Course ID or course object.
*
* @return bool
*/
public function is_legacy_course( $course = null ) {
return ! $this->has_sensei_blocks( $course );
}
/**
* Check if a course contains Sensei blocks.
*
* @param int|WP_Post $course Course ID or course object.
*
* @return bool
*/
public function has_sensei_blocks( $course = null ) {
$course = get_post( $course );
$course_blocks = [
'sensei-lms/course-outline',
'sensei-lms/course-progress',
'sensei-lms/button-take-course',
'sensei-lms/button-contact-teacher',
'sensei-lms/button-view-results',
];
foreach ( $course_blocks as $block ) {
if ( has_block( $block, $course ) ) {
return true;
}
}
return false;
}
/**
* Alters the redirect url after a user enrols to a course.
*
* @param String $url Default redirect url.
* @param WP_Post $post Post object for course.
*
* @return String
*/
public static function alter_redirect_url_after_enrolment( $url, $post ) {
// Only redirect to the lesson if the course is published.
if ( 'publish' !== $post->post_status ) {
return $url;
}
$course_id = $post->ID;
if ( Sensei_Course_Theme_Option::has_learning_mode_enabled( $course_id ) ) {
$first_incomplete_lesson_id = Sensei_Course_Structure::instance( $course_id )->get_first_incomplete_lesson_id();
if ( false !== $first_incomplete_lesson_id ) {
$url = get_permalink( $first_incomplete_lesson_id );
}
}
return $url;
}
/**
* Get average days to completion for all courses.
*
* @since 4.2.0
*
* @deprecated 4.4.1 use Sensei_Reports_Overview_List_Table_Courses::get_average_days_to_completion
*
* @access private
*
* @return float Average days to completion, rounded to the highest integer.
*/
public function get_average_days_to_completion() {
_deprecated_function( __METHOD__, '4.4.1', 'Sensei_Reports_Overview_Service_Courses::get_average_days_to_completion' );
global $wpdb;
$query = "
SELECT AVG( aggregated.days_to_completion )
FROM (
SELECT CEIL( SUM( ABS( DATEDIFF( {$wpdb->comments}.comment_date, STR_TO_DATE( {$wpdb->commentmeta}.meta_value, '%Y-%m-%d %H:%i:%s' ) ) ) + 1 ) / COUNT({$wpdb->commentmeta}.comment_id) ) AS days_to_completion
FROM {$wpdb->comments}
LEFT JOIN {$wpdb->commentmeta} ON {$wpdb->comments}.comment_ID = {$wpdb->commentmeta}.comment_id
AND {$wpdb->commentmeta}.meta_key = 'start'
WHERE {$wpdb->comments}.comment_type = 'sensei_course_status'
AND {$wpdb->comments}.comment_approved = 'complete'
GROUP BY {$wpdb->comments}.comment_post_ID
) AS aggregated
";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching -- Performance improvement.
return (float) $wpdb->get_var( $query );
}
/**
* Determines if course archive page has content.
*
* @since 4.11.0
* @return bool
*/
public function course_archive_page_has_query_block() {
$sensei_settings_course_page = get_post( Sensei()->settings->get( 'course_page' ) );
return is_a( $sensei_settings_course_page, 'WP_Post' ) &&
! Sensei()->post_types->has_old_shortcodes( $sensei_settings_course_page->post_content ) &&
! empty( $sensei_settings_course_page->post_content ) &&
has_block( 'core/query', $sensei_settings_course_page->post_content );
}
/**
* Render course archive page content.
*
* @since 4.11.0
*/
public function archive_page_content() {
$sensei_settings_course_page = get_post( Sensei()->settings->get( 'course_page' ) );
if (
$this->course_archive_page_has_query_block()
) {
echo wp_kses(
do_blocks( $sensei_settings_course_page->post_content ),
array_merge(
wp_kses_allowed_html( 'post' ),
[
'option' => [
'selected' => [],
'value' => [],
],
'select' => [
'class' => [],
'id' => [],
'name' => [],
'data-param-key' => [],
],
'form' => [
'action' => [],
'method' => [],
],
'input' => [
'type' => [],
'name' => [],
'value' => [],
],
]
)
);
remove_all_actions( 'sensei_pagination' );
// Running query loop block in do_blocks sets $wp_query->current_post to 0 which should be -1.
// This causes have_posts() to return false after rendering blocks. Which eventually causes
// Astra theme to not render the generated content.
global $wp_query;
$wp_query->current_post = -1;
}
}
/**
* Take the user to login page if trying to access course completion page without being logged in.
*
* @since 4.12.0
*
* @access private
*/
public function maybe_redirect_to_login_from_course_completion() {
if ( is_user_logged_in() ) {
return;
}
$completed_page_id = intval( Sensei()->settings->get( 'course_completed_page' ) );
if ( $completed_page_id < 1 || get_the_ID() !== $completed_page_id ) {
return;
}
$my_courses_url = null;
$my_courses_page_id = Sensei()->settings->get_my_courses_page_id();
if ( 0 < $my_courses_page_id ) {
$my_courses_url = get_permalink( $my_courses_page_id );
}
$redirect_to_url = $my_courses_url ?? home_url( '/wp-login.php' );
$redirect_after_login_url = remove_query_arg(
array( '_wp_http_referer', '_wpnonce' ),
isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''
);
wp_safe_redirect( add_query_arg( 'redirect_to', $redirect_after_login_url, $redirect_to_url ), 303 );
}
}
/**
* Class WooThemes_Sensei_Course
*
* @ignore only for backward compatibility
* @since 1.9.0
*/
class WooThemes_Sensei_Course extends Sensei_Course{}