<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
/**
* Sensei Grading Class
*
* All functionality pertaining to the Admin Grading in Sensei.
*
* @package Assessment
* @author Automattic
*
* @since 1.3.0
*/
class Sensei_Grading {
public $file;
public $page_slug;
/**
* Constructor
*
* @since 1.3.0
*
* @param $file
*/
public function __construct( $file ) {
$this->file = $file;
$this->page_slug = 'sensei_grading';
// Admin functions
if ( is_admin() ) {
add_action( 'grading_wrapper_container', array( $this, 'wrapper_container' ) );
if ( isset( $_GET['page'] ) && ( $_GET['page'] == $this->page_slug ) ) {
add_action( 'admin_print_scripts', array( $this, 'enqueue_scripts' ) );
add_action( 'admin_print_styles', array( $this, 'enqueue_styles' ) );
}
add_action( 'admin_init', array( $this, 'admin_process_grading_submission' ) );
add_action( 'admin_notices', array( $this, 'add_grading_notices' ) );
}
// Ajax functions
if ( is_admin() ) {
add_action( 'wp_ajax_get_lessons_dropdown', array( $this, 'get_lessons_dropdown' ) );
add_action( 'wp_ajax_get_redirect_url', array( $this, 'get_redirect_url' ) );
}
}
/**
* Graceful fallback for deprecated properties.
*
* @since 4.24.4
*
* @param string $key The key to get.
*
* @return mixed
*/
public function __get( $key ) {
if ( 'name' === $key ) {
_doing_it_wrong( __CLASS__ . '->name', 'The "name" property is deprecated. Use get_name() instead.', '$$next-version$$' );
return $this->get_name();
}
}
/**
* Get the name of the screen.
*
* @return string
*/
public function get_name() {
return __( 'Grading', 'sensei-lms' );
}
/**
* Add the Grading submenu.
*
* @since 1.3.0
* @access public
*/
public function grading_admin_menu() {
$indicator_html = '';
$grading_counts = Sensei()->grading->count_statuses( [ 'type' => 'lesson' ] );
if ( intval( $grading_counts['ungraded'] ) > 0 ) {
$indicator_html = ' <span class="awaiting-mod">' . esc_html( $grading_counts['ungraded'] ) . '</span>';
}
if ( current_user_can( 'manage_sensei_grades' ) ) {
add_submenu_page(
'sensei',
__( 'Grading', 'sensei-lms' ),
__( 'Grading', 'sensei-lms' ) . $indicator_html,
'manage_sensei_grades',
$this->page_slug,
array( $this, 'grading_page' )
);
}
}
/**
* enqueue_scripts function.
*
* @description Load in JavaScripts where necessary.
* @access public
* @since 1.3.0
* @return void
*/
public function enqueue_scripts() {
// Load Grading JS
Sensei()->assets->enqueue( 'sensei-grading-general', 'js/grading-general.js', [ 'jquery', 'sensei-core-select2' ] );
}
/**
* enqueue_styles function.
*
* @description Load in CSS styles where necessary.
* @access public
* @since 1.0.0
* @return void
*/
public function enqueue_styles() {
wp_enqueue_style( Sensei()->token . '-admin' );
Sensei()->assets->enqueue( 'sensei-settings-api', 'css/settings.css' );
}
/**
* load_data_table_files loads required files for Grading
*
* @since 1.3.0
* @return void
*/
public function load_data_table_files() {
// Load Grading Classes
$classes_to_load = array(
'list-table',
'grading-main',
'grading-user-quiz',
);
foreach ( $classes_to_load as $class_file ) {
Sensei()->load_class( $class_file );
}
}
/**
* load_data_object creates new instance of class
*
* @since 1.3.0
* @deprecated 3.3.0 Use constructors instead.
*
* @param string $name Name of class
* @param integer $data constructor arguments
* @param undefined $optional_data optional constructor arguments
* @return object class instance object
*/
public function load_data_object( $name = '', $data = 0, $optional_data = null ) {
// Use constructors directly.
_deprecated_function( __METHOD__, '3.3.0', 'new Sensei_Grading_{$name}' );
// Load Analysis data
$object_name = 'Sensei_Grading_' . $name;
if ( is_null( $optional_data ) ) {
$sensei_grading_object = new $object_name( $data );
} else {
$sensei_grading_object = new $object_name( $data, $optional_data );
}
if ( 'Main' == $name ) {
$sensei_grading_object->prepare_items();
}
return $sensei_grading_object;
}
/**
* grading_page function.
*
* @since 1.3.0
* @access public
* @return void
*/
public function grading_page() {
if ( isset( $_GET['quiz_id'] ) && 0 < intval( $_GET['quiz_id'] ) && isset( $_GET['user'] ) && 0 < intval( $_GET['user'] ) ) {
$this->grading_user_quiz_view();
} else {
$this->grading_default_view();
}
}
/**
* grading_default_view default view for grading page
*
* @since 1.3.0
* @return void
*/
public function grading_default_view() {
$course_id = null;
$lesson_id = null;
$user_id = null;
$view = null;
// Load Grading data
if ( ! empty( $_GET['course_id'] ) ) {
$course_id = intval( $_GET['course_id'] );
if ( ! current_user_can( get_post_type_object( 'course' )->cap->edit_post, $course_id ) ) {
return;
}
}
if ( ! empty( $_GET['lesson_id'] ) ) {
$lesson_id = intval( $_GET['lesson_id'] );
if ( ! current_user_can( get_post_type_object( 'lesson' )->cap->edit_post, $lesson_id ) ) {
return;
}
}
if ( ! empty( $_GET['user_id'] ) ) {
$user_id = intval( $_GET['user_id'] );
}
if ( ! empty( $_GET['view'] ) ) {
$view = esc_html( $_GET['view'] );
}
$sensei_grading_overview = new Sensei_Grading_Main( compact( 'course_id', 'lesson_id', 'user_id', 'view' ) );
$sensei_grading_overview->prepare_items();
// Wrappers
/**
* Fires before the container of the Grading page.
*
* @hook grading_before_container
*/
do_action( 'grading_before_container' );
/**
* Fires before the container of the Grading page.
* This hook allows to wrap the container.
*
* @hook grading_wrapper_container
*
* @param {string} $which The position ('top' here).
*/
do_action( 'grading_wrapper_container', 'top' );
$this->grading_default_nav();
/**
* Fires after the headers of the Grading page.
*
* @hook sensei_grading_after_headers
*/
do_action( 'sensei_grading_after_headers' );
$sensei_grading_overview->views();
?>
<form id="grading-filters" method="get">
<?php
Sensei_Utils::output_query_params_as_inputs( [ 'course_id', 'lesson_id', 's' ] );
$sensei_grading_overview->table_search_form();
$sensei_grading_overview->display();
?>
</form>
<?php
/**
* Fires after the container with main content on the Grading page.
* Allows to add extra content.
*
* @hook sensei_grading_extra
*/
do_action( 'sensei_grading_extra' );
/**
* Fires after the container of the Grading page.
* This hook allows to wrap the container.
*
* @hook grading_wrapper_container
*
* @param {string} $which The position ('bottom' here).
*/
do_action( 'grading_wrapper_container', 'bottom' );
/**
* Fires after the container of the Grading page.
*
* @hook grading_after_container
*/
do_action( 'grading_after_container' );
}
/**
* grading_user_quiz_view user quiz answers view for grading page
*
* @since 1.2.0
* @return void
*/
public function grading_user_quiz_view() {
// Load Grading data
$user_id = 0;
$quiz_id = 0;
if ( isset( $_GET['user'] ) ) {
$user_id = intval( $_GET['user'] );
}
if ( isset( $_GET['quiz_id'] ) ) {
$quiz_id = intval( $_GET['quiz_id'] );
$lesson_id = get_post_meta( $quiz_id, '_quiz_lesson', true );
if ( ! current_user_can( get_post_type_object( 'lesson' )->cap->edit_post, $lesson_id ) ) {
return;
}
}
$sensei_grading_user_profile = new Sensei_Grading_User_Quiz( $user_id, $quiz_id );
// Wrappers
/**
* Fires before the container of the Grading page.
*
* @hook grading_before_container
*/
do_action( 'grading_before_container' );
/**
* Fires before the container of the Grading page.
* This hook allows to wrap the container.
*
* @hook grading_wrapper_container
*
* @param {string} $which The position ('top' here).
*/
do_action( 'grading_wrapper_container', 'top' );
$this->grading_user_quiz_nav();
/**
* Fires after the headers of the Grading page.
*
* @hook sensei_grading_after_headers
*/
do_action( 'sensei_grading_after_headers' );
?>
<div id="poststuff" class="sensei-grading-wrap user-profile">
<div class="sensei-grading-main">
<?php $sensei_grading_user_profile->display(); ?>
</div>
</div>
<?php
/**
* Fires after the container on the Grading page.
* This hook allows to wrap the container.
*
* @hook grading_wrapper_container
*
* @param {string} $which The position ('bottom' here).
*/
do_action( 'grading_wrapper_container', 'bottom' );
/**
* Fires after the container of the Grading page.
*
* @hook grading_after_container
*/
do_action( 'grading_after_container' );
}
/**
* Outputs Grading general headers.
*
* @since 1.3.0
* @deprecated 3.13.4
*
* @param array $args
* @return void
*/
public function grading_headers( $args = array( 'nav' => 'default' ) ) {
_deprecated_function( __METHOD__, '3.13.4' );
$function = 'grading_' . $args['nav'] . '_nav';
$this->$function();
do_action( 'sensei_grading_after_headers' );
}
/**
* wrapper_container wrapper for Grading area
*
* @since 1.3.0
* @param $which string
* @return void
*/
public function wrapper_container( $which ) {
if ( 'top' == $which ) {
?>
<div id="woothemes-sensei" class="wrap woothemes-sensei">
<?php
} elseif ( 'bottom' == $which ) {
?>
</div><!--/#woothemes-sensei-->
<?php
}
}
/**
* Default nav area for Grading
*
* @since 1.3.0
* @return void
*/
public function grading_default_nav() {
$title = esc_html( $this->get_name() );
if ( isset( $_GET['course_id'] ) ) {
$course_id = intval( $_GET['course_id'] );
$title .= '<span class="course-title">> ' . esc_html( get_the_title( $course_id ) ) . '</span>';
}
if ( isset( $_GET['lesson_id'] ) ) {
$lesson_id = intval( $_GET['lesson_id'] );
$title .= ' <span class="lesson-title">> ' . esc_html( get_the_title( intval( $lesson_id ) ) ) . '</span>';
}
if ( isset( $_GET['user_id'] ) && 0 < intval( $_GET['user_id'] ) ) {
$user_name = Sensei_Learner::get_full_name( $_GET['user_id'] );
$title .= ' <span class="user-title">> ' . esc_html( $user_name ) . '</span>';
}
?>
<h1>
<?php
/**
* Filter the title of the Grading page.
*
* @hook sensei_grading_nav_title
*
* @param {string} $title
* @return {string} Filtered title.
*/
echo wp_kses_post( apply_filters( 'sensei_grading_nav_title', $title ) );
?>
</h1>
<?php
}
/**
* Nav area for Grading specific users' quiz answers
*
* @since 1.3.0
* @return void
*/
public function grading_user_quiz_nav() {
$title = esc_html( $this->get_name() );
if ( isset( $_GET['quiz_id'] ) ) {
$quiz_id = intval( $_GET['quiz_id'] );
$lesson_id = get_post_meta( $quiz_id, '_quiz_lesson', true );
$course_id = get_post_meta( $lesson_id, '_lesson_course', true );
$url = add_query_arg(
array(
'page' => $this->page_slug,
'course_id' => $course_id,
),
admin_url( 'admin.php' )
);
$title .= sprintf( ' <span class="course-title">> <a href="%s">%s</a></span>', esc_url( $url ), esc_html( get_the_title( $course_id ) ) );
$url = add_query_arg(
array(
'page' => $this->page_slug,
'lesson_id' => $lesson_id,
),
admin_url( 'admin.php' )
);
$title .= sprintf( ' <span class="lesson-title">> <a href="%s">%s</a></span>', esc_url( $url ), esc_html( get_the_title( $lesson_id ) ) );
}
if ( isset( $_GET['user'] ) && 0 < intval( $_GET['user'] ) ) {
$user_name = Sensei_Learner::get_full_name( $_GET['user'] );
$title .= ' <span class="user-title">> ' . esc_html( $user_name ) . '</span>';
}
?>
<h2>
<?php
/**
* Filter the title of the Grading page.
*
* @hook sensei_grading_nav_title
*
* @param {string} $title
* @return {string} Filtered title.
*/
echo wp_kses_post( apply_filters( 'sensei_grading_nav_title', $title ) );
?>
</h2>
<?php
}
/**
* Return array of valid statuses for either Course or Lesson
*
* @since 1.7.0
* @return array
*/
public function get_stati( $type ) {
$statuses = array();
switch ( $type ) {
case 'course':
$statuses = array(
'in-progress',
'complete',
);
break;
case 'lesson':
$statuses = array(
'in-progress',
'complete',
'ungraded',
'graded',
'passed',
'failed',
);
break;
}
return $statuses;
}
/**
* Count the various statuses for Course or Lesson
* Very similar to get_comment_count()
*
* @since 1.7.0
* @param array $args (default: array())
* @return object
*/
public function count_statuses( $args = array() ) {
global $wpdb;
/**
* Filter fires inside Sensei_Grading::count_statuses
*
* Alter the post_in array to determine which posts the comment query should be limited to.
*
* @since 1.8.0
*
* @hook sensei_count_statuses_args
*
* @param {array} $args Array of arguments for the query.
* @return {array} Filtered arguments.
*/
$args = apply_filters( 'sensei_count_statuses_args', $args );
if ( 'course' === $args['type'] ) {
$type = 'sensei_course_status';
} else {
$type = 'sensei_lesson_status';
}
$cache_key = 'sensei-statuses-' . md5( wp_json_encode( $args ) );
$query = "SELECT comment_approved, COUNT( * ) AS total FROM {$wpdb->comments} WHERE comment_type = %s ";
// Restrict to specific posts.
if ( isset( $args['post__in'] ) && ! empty( $args['post__in'] ) && is_array( $args['post__in'] ) ) {
$query .= ' AND comment_post_ID IN (' . implode( ',', array_map( 'absint', $args['post__in'] ) ) . ')';
} elseif ( ! empty( $args['post_id'] ) ) {
$query .= $wpdb->prepare( ' AND comment_post_ID = %d', $args['post_id'] );
}
// Restrict to specific users.
if ( isset( $args['user_id'] ) && is_array( $args['user_id'] ) ) {
$query .= ' AND user_id IN (' . implode( ',', array_map( 'absint', $args['user_id'] ) ) . ')';
} elseif ( ! empty( $args['user_id'] ) ) {
$query .= $wpdb->prepare( ' AND user_id = %d', $args['user_id'] );
}
// Restrict to specific users.
if ( isset( $args['query'] ) ) {
$query .= $args['query'];
}
$query .= ' GROUP BY comment_approved';
$counts = wp_cache_get( $cache_key, 'counts' );
if ( false === $counts ) {
$sql = $wpdb->prepare( $query, $type );
$results = (array) $wpdb->get_results( $sql, ARRAY_A );
$counts = array_fill_keys( $this->get_stati( $type ), 0 );
foreach ( $results as $row ) {
$counts[ $row['comment_approved'] ] = $row['total'];
}
wp_cache_set( $cache_key, $counts, 'counts' );
}
if ( ! isset( $counts['graded'] ) ) {
$counts['graded'] = 0;
}
if ( ! isset( $counts['ungraded'] ) ) {
$counts['ungraded'] = 0;
}
if ( ! isset( $counts['passed'] ) ) {
$counts['passed'] = 0;
}
if ( ! isset( $counts['failed'] ) ) {
$counts['failed'] = 0;
}
if ( ! isset( $counts['in-progress'] ) ) {
$counts['in-progress'] = 0;
}
if ( ! isset( $counts['complete'] ) ) {
$counts['complete'] = 0;
}
/**
* Filter the counts of statuses for a given type.
*
* @hook sensei_count_statuses
*
* @param {array} $counts Array of counts for each status.
* @param {string} $type Type of status to count: sensei_course_status or sensei_lesson_status.
* @return {array} Filtered counts.
*/
return apply_filters( 'sensei_count_statuses', $counts, $type );
}
/**
* Build the Courses dropdown for return in AJAX
*
* @since 1.7.0
* @return string
*/
public function courses_drop_down_html( $selected_course_id = 0 ) {
$html = '';
$course_args = array(
'post_type' => 'course',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
'post_status' => 'any',
'suppress_filters' => 0,
'fields' => 'ids',
);
/**
* Filter the arguments used to query for courses in the grading dropdown.
*
* @hook sensei_grading_filter_courses
*
* @param {array} $course_args Array of arguments for the query.
* @return {array} Filtered arguments.
*/
$courses = get_posts( apply_filters( 'sensei_grading_filter_courses', $course_args ) );
$html .= '<option value="">' . __( 'Select a course', 'sensei-lms' ) . '</option>';
if ( $courses ) {
foreach ( $courses as $course_id ) {
$html .= '<option value="' . esc_attr( absint( $course_id ) ) . '" ' . selected( $course_id, $selected_course_id, false ) . '>' . esc_html( get_the_title( $course_id ) ) . '</option>' . "\n";
}
}
return $html;
}
/**
* Build the Lessons dropdown for return in AJAX
*
* @since 1.?
* @return string
*/
public function get_lessons_dropdown() {
if ( ! isset( $_GET['course_id'] ) ) {
// Try deprecated functionality as a fallback.
// phpcs:ignore WordPress.Security.NonceVerification -- No modifications are made here.
if ( isset( $_POST['data'] ) ) {
_doing_it_wrong(
'get_lessons_dropdown',
'The get_lessons_dropdown AJAX call should be a GET request with parameter "course_id".',
'1.12.2'
);
$this->deprecated_get_lessons_dropdown();
}
wp_die();
}
// Get course ID.
$course_id = intval( $_GET['course_id'] );
echo wp_kses(
$this->lessons_drop_down_html( $course_id ),
array(
'option' => array(
'selected' => array(),
'value' => array(),
),
)
);
wp_die(); // WordPress may print out a spurious zero without this can be particularly bad if using JSON
}
/**
* Deprecated version of the get_lessons_dropdown function, used as a
* fallback.
*/
private function deprecated_get_lessons_dropdown() {
// Parse POST data
// phpcs:ignore WordPress.Security.NonceVerification -- No modifications are made here.
$data = $_POST['data'];
$course_data = array();
parse_str( $data, $course_data );
$course_id = intval( $course_data['course_id'] );
echo wp_kses(
$this->lessons_drop_down_html( $course_id ),
array(
'option' => array(
'selected' => array(),
'value' => array(),
),
)
);
die(); // WordPress may print out a spurious zero without this can be particularly bad if using JSON
}
public function lessons_drop_down_html( $course_id = 0, $selected_lesson_id = 0 ) {
$html = '';
if ( 0 < intval( $course_id ) ) {
$lesson_args = array(
'post_type' => 'lesson',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
'meta_key' => '_lesson_course',
'meta_value' => $course_id,
'post_status' => 'publish',
'suppress_filters' => 0,
'fields' => 'ids',
);
/**
* Filter the arguments used to query for lessons in the grading dropdown.
*
* @hook sensei_grading_filter_lessons
*
* @param {array} $lesson_args Array of arguments for the query.
* @return {array} Filtered arguments.
*/
$lessons = get_posts( apply_filters( 'sensei_grading_filter_lessons', $lesson_args ) );
$html .= '<option value="">' . esc_html__( 'Select a lesson', 'sensei-lms' ) . '</option>';
if ( $lessons ) {
foreach ( $lessons as $lesson_id ) {
$html .= '<option value="' . esc_attr( absint( $lesson_id ) ) . '" ' . selected( $lesson_id, $selected_lesson_id, false ) . '>' . esc_html( get_the_title( $lesson_id ) ) . '</option>' . "\n";
}
}
}
return $html;
}
/**
* The process grading function handles admin grading submissions.
*
* This function is hooked on to admin_init. It simply accepts
* the grades as the Grader selected theme and saves the total grade and
* individual question grades.
*
* @return bool
*/
public function admin_process_grading_submission() {
if ( ! isset( $_POST['sensei_manual_grade'] )
|| ! wp_verify_nonce( $_POST['_wp_sensei_manual_grading_nonce'], 'sensei_manual_grading' )
|| ! isset( $_GET['quiz_id'] )
|| $_GET['quiz_id'] != $_POST['sensei_manual_grade'] ) {
return false; // exit and do not grade.
}
$quiz_id = $_GET['quiz_id'];
$user_id = $_GET['user'];
$questions = Sensei_Utils::sensei_get_quiz_questions( $quiz_id );
$quiz_lesson_id = Sensei()->quiz->get_lesson_id( $quiz_id );
$quiz_grade = 0;
$quiz_grade_total = $_POST['quiz_grade_total'];
$all_question_grades = array();
$all_answers_feedback = array();
foreach ( $questions as $question ) {
$question_id = $question->ID;
if ( isset( $_POST[ 'question_' . $question_id . '_grade' ] ) ) {
$question_grade = absint( wp_unslash( $_POST[ 'question_' . $question_id . '_grade' ] ) ) ?? 0;
// add data to the array that will, after the loop, be stored on the lesson status
$all_question_grades[ $question_id ] = $question_grade;
// tally up the total quiz grade
$quiz_grade += $question_grade;
} // endif
// Question answer feedback / notes
$question_feedback = '';
if ( isset( $_POST['questions_feedback'][ $question_id ] ) ) {
$question_feedback = wp_unslash( $_POST['questions_feedback'][ $question_id ] );
}
$all_answers_feedback[ $question_id ] = $question_feedback;
}
// store all question grades on the lesson status
Sensei()->quiz->set_user_grades( $all_question_grades, $quiz_lesson_id, $user_id );
// store the feedback from grading
Sensei()->quiz->save_user_answers_feedback( $all_answers_feedback, $quiz_lesson_id, $user_id );
$lesson_progress = Sensei()->lesson_progress_repository->get( $quiz_lesson_id, $user_id );
if ( ! $lesson_progress ) {
$lesson_progress = Sensei()->lesson_progress_repository->create( $quiz_lesson_id, $user_id );
}
$quiz_progress = Sensei()->quiz_progress_repository->get( $quiz_id, $user_id );
if ( ! $quiz_progress ) {
return false;
}
$lesson_metadata = [];
$quiz_progress->ungrade();
// $_POST['all_questions_graded'] is set when all questions have been graded
// in the class sensei grading user quiz -> display()
if ( $_POST['all_questions_graded'] == 'yes' ) {
// Set the users total quiz grade.
$grade = Sensei_Utils::quotient_as_absolute_rounded_percentage( $quiz_grade, $quiz_grade_total, 2 );
Sensei_Utils::sensei_grade_quiz( $quiz_id, $grade, $user_id );
// Duplicating what Frontend->sensei_complete_quiz() does.
$pass_required = get_post_meta( $quiz_id, '_pass_required', true );
$quiz_passmark = Sensei_Utils::as_absolute_rounded_number( get_post_meta( $quiz_id, '_quiz_passmark', true ), 2 );
if ( $pass_required ) {
// Student has reached the pass mark and lesson is complete.
if ( $quiz_passmark <= $grade ) {
// Due to our internal logic, we need to complete the lesson first.
// This is because in the comments-based version the lesson status is used for both the lesson and the quiz.
$lesson_progress->complete();
$quiz_progress->pass();
} else {
$quiz_progress->fail();
}
}
// Student only has to partake the quiz.
else {
$lesson_progress->complete();
$quiz_progress->grade();
}
}
// Due to our internal logic, we need to save the lesson progress first.
// This is because in the comments-based version the lesson status is used for both the lesson and the quiz.
// And in this context the quiz status should be preserved in the comments-based version.
// For the tables-based version the order does not matter.
Sensei()->lesson_progress_repository->save( $lesson_progress );
Sensei()->quiz_progress_repository->save( $quiz_progress );
if ( count( $lesson_metadata ) ) {
foreach ( $lesson_metadata as $key => $value ) {
update_comment_meta( $quiz_progress->get_id(), $key, $value );
}
}
if ( $lesson_progress->is_complete() ) {
/**
* Fires when a user completes a lesson.
* Here as part of grading a quiz.
*
* @hook sensei_user_lesson_end
*
* @param {int} $user_id The user ID.
* @param {int} $lesson_id The lesson ID.
*/
do_action( 'sensei_user_lesson_end', $user_id, $quiz_lesson_id );
}
if ( isset( $_POST['sensei_grade_next_learner'] ) && strlen( $_POST['sensei_grade_next_learner'] ) > 0 ) {
$load_url = add_query_arg( array( 'message' => 'graded' ) );
} elseif ( isset( $_POST['_wp_http_referer'] ) ) {
$load_url = add_query_arg( array( 'message' => 'graded' ), $_POST['_wp_http_referer'] );
} else {
$load_url = add_query_arg( array( 'message' => 'graded' ) );
}
wp_safe_redirect( esc_url_raw( $load_url ) );
exit;
}
public function get_redirect_url() {
if ( ! isset( $_GET['course_id'] ) || ! isset( $_GET['lesson_id'] ) ) {
// Try deprecated functionality as a fallback.
// phpcs:ignore WordPress.Security.NonceVerification -- No modifications are made here.
if ( isset( $_POST['data'] ) ) {
_doing_it_wrong(
'get_redirect_url',
'The get_redirect_url AJAX call should be a GET request with parameters "course_id" and "lesson_id".',
'1.12.2'
);
$this->deprecated_get_redirect_url();
}
wp_die();
}
// Get data.
$course_id = intval( $_GET['course_id'] );
$lesson_id = intval( $_GET['lesson_id'] );
$grading_view = ( isset( $_GET['view'] ) && $_GET['view'] ) ? $_GET['view'] : 'ungraded';
if ( 0 < $lesson_id && 0 < $course_id ) {
echo esc_url_raw(
apply_filters(
'sensei_ajax_redirect_url',
add_query_arg(
array(
'page' => $this->page_slug,
'lesson_id' => $lesson_id,
'course_id' => $course_id,
'view' => $grading_view,
),
admin_url( 'admin.php' )
)
)
);
} else {
echo '';
}
wp_die();
}
/**
* Deprecated version of the get_redirect_url function, used as a fallback.
*/
private function deprecated_get_redirect_url() {
// Parse POST data
// phpcs:ignore WordPress.Security.NonceVerification -- No modifications are made here.
$data = $_POST['data'];
$lesson_data = array();
parse_str( $data, $lesson_data );
$lesson_id = intval( $lesson_data['lesson_id'] );
$course_id = intval( $lesson_data['course_id'] );
$grading_view = sanitize_text_field( $lesson_data['view'] );
if ( 0 < $lesson_id && 0 < $course_id ) {
echo esc_url_raw(
apply_filters(
'sensei_ajax_redirect_url',
add_query_arg(
array(
'page' => $this->page_slug,
'lesson_id' => $lesson_id,
'course_id' => $course_id,
'view' => $grading_view,
),
admin_url( 'admin.php' )
)
)
);
} else {
echo '';
}
die();
}
public function add_grading_notices() {
$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : false;
$message = isset( $_GET['message'] ) ? sanitize_text_field( wp_unslash( $_GET['message'] ) ) : false;
if (
$page
&& $message
&& $this->page_slug === $page
&& 'graded' === $message
) {
?>
<div class="grading-notice updated">
<p><?php echo esc_html__( 'Quiz Graded Successfully!', 'sensei-lms' ); ?></p>
</div>
<?php
}
}
public function sensei_grading_notices() {
if ( isset( $_GET['action'] ) && 'graded' == $_GET['action'] ) {
echo '<div class="grading-notice updated">';
echo '<p>' . esc_html__( 'Quiz Graded Successfully!', 'sensei-lms' ) . '</p>';
echo '</div>';
}
}
/**
* Grade quiz automatically
*
* This function grades each question automatically if there all questions are auto gradable. If not
* the quiz will not be auto gradable.
*
* @since 1.7.4
*
* @param integer $quiz_id ID of quiz
* @param array $submitted questions id ans answers {
* @type int $question_id
* @type mixed $answer
* }
* @param integer $total_questions Total questions in quiz (not used)
* @param string $quiz_grade_type Optional defaults to auto
*
* @return int $quiz_grade total sum of all question grades
*/
public static function grade_quiz_auto( $quiz_id = 0, $submitted = array(), $total_questions = 0, $quiz_grade_type = 'auto' ) {
if ( ! ( intval( $quiz_id ) > 0 ) || ! $submitted
|| $quiz_grade_type != 'auto' ) {
return false; // exit early
}
$user_id = get_current_user_id();
$lesson_id = Sensei()->quiz->get_lesson_id( $quiz_id );
$quiz_autogradable = true;
/**
* Filter question types that can be automatically graded.
*
* This filter fires inside the auto grade quiz function and provides you with the default list.
*
* @hook sensei_autogradable_question_types
*
* @param {array} Types of questions, default: array( 'multiple-choice', 'boolean', 'gap-fill' ).
* @return {array} Filtered array of question types.
*/
$autogradable_question_types = apply_filters( 'sensei_autogradable_question_types', array( 'multiple-choice', 'boolean', 'gap-fill' ) );
$grade_total = 0;
$all_question_grades = array();
foreach ( $submitted as $question_id => $answer ) {
// check if the question is autogradable, either by type, or because the grade is 0
$question_type = Sensei()->question->get_question_type( $question_id );
$achievable_grade = Sensei()->question->get_question_grade( $question_id );
// Question has a zero grade, so skip grading
if ( 0 == $achievable_grade ) {
$all_question_grades[ $question_id ] = $achievable_grade;
} elseif ( in_array( $question_type, $autogradable_question_types ) ) {
// Get user question grade
$question_grade = self::grade_question_auto( $question_id, $question_type, $answer, $user_id );
$all_question_grades[ $question_id ] = $question_grade;
$grade_total += $question_grade;
} else {
// There is a question that cannot be autograded
$quiz_autogradable = false;
}
}
// Only if the whole quiz was autogradable do we set a grade
if ( $quiz_autogradable ) {
$quiz_total = Sensei_Utils::sensei_get_quiz_total( $quiz_id );
$grade = Sensei_Utils::quotient_as_absolute_rounded_percentage( $grade_total, $quiz_total, 2 );
Sensei_Utils::sensei_grade_quiz( $quiz_id, $grade, $user_id, $quiz_grade_type );
} else {
$grade = new WP_Error( 'autograde', __( 'This quiz is not able to be automatically graded.', 'sensei-lms' ) );
}
// store the auto gradable grades. If the quiz is not auto gradable the grades can be use as the default
// when doing manual grading.
Sensei()->quiz->set_user_grades( $all_question_grades, $lesson_id, $user_id );
return $grade;
}
/**
* Grade question automatically
*
* This function checks the question type and then grades it accordingly.
*
* @since 1.7.4
*
* @param integer $question_id
* @param string $question_type of the standard Sensei question types
* @param string $answer
* @param int $user_id
*
* @return int $question_grade
*/
public static function grade_question_auto( $question_id = 0, $question_type = '', $answer = '', $user_id = 0 ) {
if ( intval( $user_id ) == 0 ) {
$user_id = get_current_user_id();
}
if ( ! ( intval( $question_id ) > 0 ) ) {
return false;
}
Sensei()->question->get_question_type( $question_id );
/**
* Applying a grade before the auto grading takes place.
*
* This filter is applied just before the question is auto graded. It fires in the context of a single question
* in the sensei_grade_question_auto function. It fires irrespective of the question type. If you return a value
* other than false the auto grade functionality will be ignored and your supplied grade will be user for this question.
*
* @hook sensei_pre_grade_question_auto
*
* @param {int|false} $question_grade Question grade, default false.
* @param {int} $question_id ID of the question being graded.
* @param {string} $question_type One of the Sensei question type.
* @param {string} $answer User supplied question answer.
* @return {int|false} Filtered question grade.
*/
$question_grade = apply_filters( 'sensei_pre_grade_question_auto', false, $question_id, $question_type, $answer );
if ( false !== $question_grade ) {
return $question_grade;
}
// auto grading core
if ( in_array( $question_type, array( 'multiple-choice', 'boolean' ) ) ) {
$right_answer = (array) get_post_meta( $question_id, '_question_right_answer', true );
$answer = wp_unslash( $answer );
$answer = (array) $answer;
if ( is_array( $right_answer ) && count( $right_answer ) == count( $answer ) ) {
// Loop through all answers ensure none are 'missing'
$all_correct = true;
foreach ( $answer as $check_answer ) {
if ( ! in_array( $check_answer, $right_answer ) ) {
$all_correct = false;
}
}
// If all correct then grade
if ( $all_correct ) {
$question_grade = Sensei()->question->get_question_grade( $question_id );
}
}
} elseif ( 'gap-fill' == $question_type ) {
$question_grade = self::grade_gap_fill_question( $question_id, $answer );
} else {
/**
* Grading questions that are not auto gradable.
*
* This filter is applied the context of ta single question within the sensei_grade_question_auto function.
* It fires for all other questions types. It does not apply to 'multiple-choice' , 'boolean' and gap-fill.
*
* @hook sensei_grade_question_auto
*
* @param {int} $question_grade Question grade.
* @param {int} $question_id ID of the question being graded.
* @param {string} $question_type One of the Sensei question type.
* @param {string} $answer User supplied question answer
* @return {int} Filtered question grade.
*/
$question_grade = (int) apply_filters( 'sensei_grade_question_auto', $question_grade, $question_id, $question_type, $answer );
}
return $question_grade;
}
/**
* Grading logic specifically for the gap fill questions
*
* @since 1.9.0
* @param $question_id
* @param $user_answer
*
* @return bool | int false or the grade given to the user answer
*/
public static function grade_gap_fill_question( $question_id, $user_answer ) {
$right_answer = get_post_meta( $question_id, '_question_right_answer', true );
$gapfill_array = explode( '||', $right_answer );
$user_answer = wp_unslash( $user_answer );
/**
* case sensitive grading filter
*
* alter the value simply use this code in your plugin or the themes functions.php
* add_filter( 'sensei_gap_fill_case_sensitive_grading','__return_true' );
*
* @since 1.9.0
*
* @hook sensei_gap_fill_case_sensitive_grading
*
* @param {bool} $do_case_sensitive_comparison Whether to do case sensitive comparison or not, default false.
* @return {bool} Filtered value.
*/
$do_case_sensitive_comparison = apply_filters( 'sensei_gap_fill_case_sensitive_grading', false );
$regex_modifier = '';
if ( $do_case_sensitive_comparison ) {
$is_exact_answer_match = trim( $user_answer ) === trim( $gapfill_array[1] );
} else {
$is_exact_answer_match = trim( strtolower( $user_answer ) ) === trim( strtolower( $gapfill_array[1] ) );
$regex_modifier = 'i';
}
$regex_answer_check = '/' . addcslashes( $gapfill_array[1], '/' ) . '/' . $regex_modifier;
if (
$is_exact_answer_match
|| 1 === @preg_match( $regex_answer_check, $user_answer )
) {
return Sensei()->question->get_question_grade( $question_id );
}
return false;
}
/**
* Counts the lessons that have been graded manually and automatically
*
* @since 1.9.0
* @return int $number_of_graded_lessons
*/
public static function get_graded_lessons_count() {
global $wpdb;
$comment_query_piece = [];
$comment_query_piece['select'] = 'SELECT COUNT(*) AS total';
$comment_query_piece['from'] = " FROM {$wpdb->comments} INNER JOIN {$wpdb->commentmeta} ON ( {$wpdb->comments}.comment_ID = {$wpdb->commentmeta}.comment_id ) ";
$comment_query_piece['where'] = " WHERE {$wpdb->comments}.comment_type IN ('sensei_lesson_status') AND ( {$wpdb->commentmeta}.meta_key = 'grade')";
$comment_query = $comment_query_piece['select'] . $comment_query_piece['from'] . $comment_query_piece['where'];
$number_of_graded_lessons = intval( $wpdb->get_var( $comment_query, 0, 0 ) );
return $number_of_graded_lessons;
}
/**
* Add together all the graded lesson grades
*
* @since 1.9.0
* @return double $sum_of_all_grades
*/
public static function get_graded_lessons_sum() {
global $wpdb;
$comment_query_piece = [];
$comment_query_piece['select'] = "SELECT SUM({$wpdb->commentmeta}.meta_value) AS meta_sum";
$comment_query_piece['from'] = " FROM {$wpdb->comments} INNER JOIN {$wpdb->commentmeta} ON ( {$wpdb->comments}.comment_ID = {$wpdb->commentmeta}.comment_id ) ";
$comment_query_piece['where'] = " WHERE {$wpdb->comments}.comment_type IN ('sensei_lesson_status') AND ( {$wpdb->commentmeta}.meta_key = 'grade')";
$comment_query = $comment_query_piece['select'] . $comment_query_piece['from'] . $comment_query_piece['where'];
$sum_of_all_grades = intval( $wpdb->get_var( $comment_query, 0, 0 ) );
return $sum_of_all_grades;
}
/**
* Get average grade of all lessons graded in all the courses.
*
* @since 4.2.0
* @access public
* @return double $graded_lesson_average_grade Average value of all the graded lessons in all the courses.
*/
public function get_graded_lessons_average_grade() {
global $wpdb;
// Fetching all the grades of all the lessons that are graded.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Performance improvement.
$sum_result = $wpdb->get_row(
"SELECT SUM( {$wpdb->commentmeta}.meta_value ) AS grade_sum,COUNT( * ) as grade_count FROM {$wpdb->comments}
INNER JOIN {$wpdb->commentmeta} ON ( {$wpdb->comments}.comment_ID = {$wpdb->commentmeta}.comment_id )
WHERE {$wpdb->comments}.comment_type IN ('sensei_lesson_status') AND ( {$wpdb->commentmeta}.meta_key = 'grade')"
);
$average_grade_value = 0;
if ( '0' === $sum_result->grade_count ) {
return $average_grade_value;
}
$average_grade_value = $sum_result->grade_sum / $sum_result->grade_count;
return $average_grade_value;
}
/**
* Get the sum of all grades for the given user.
*
* @since 1.9.0
* @param $user_id
* @return double
*/
public static function get_user_graded_lessons_sum( $user_id ) {
global $wpdb;
$clean_user_id = esc_sql( $user_id );
$comment_query_piece = [];
$comment_query_piece['select'] = "SELECT SUM({$wpdb->commentmeta}.meta_value) AS meta_sum";
$comment_query_piece['from'] = " FROM {$wpdb->comments} INNER JOIN {$wpdb->commentmeta} ON ( {$wpdb->comments}.comment_ID = {$wpdb->commentmeta}.comment_id ) ";
$comment_query_piece['where'] = " WHERE {$wpdb->comments}.comment_type IN ('sensei_lesson_status') AND ( {$wpdb->commentmeta}.meta_key = 'grade') AND {$wpdb->comments}.user_id = {$clean_user_id} ";
$comment_query = $comment_query_piece['select'] . $comment_query_piece['from'] . $comment_query_piece['where'];
$sum_of_all_grades = intval( $wpdb->get_var( $comment_query, 0, 0 ) );
return $sum_of_all_grades;
}
/**
* Get the sum of all user grades for the given lesson.
*
* @since 1.9.0
*
* @param int lesson_id
* @return double
*/
public static function get_lessons_users_grades_sum( $lesson_id ) {
global $wpdb;
$clean_lesson_id = esc_sql( $lesson_id );
$comment_query_piece = [];
$comment_query_piece['select'] = "SELECT SUM({$wpdb->commentmeta}.meta_value) AS meta_sum";
$comment_query_piece['from'] = " FROM {$wpdb->comments} INNER JOIN {$wpdb->commentmeta} ON ( {$wpdb->comments}.comment_ID = {$wpdb->commentmeta}.comment_id ) ";
$comment_query_piece['where'] = " WHERE {$wpdb->comments}.comment_type IN ('sensei_lesson_status') AND ( {$wpdb->commentmeta}.meta_key = 'grade') AND {$wpdb->comments}.comment_post_ID = {$clean_lesson_id} ";
$comment_query = $comment_query_piece['select'] . $comment_query_piece['from'] . $comment_query_piece['where'];
$sum_of_all_grades = intval( $wpdb->get_var( $comment_query, 0, 0 ) );
return $sum_of_all_grades;
}
/**
* Get the sum of all user grades for the given course.
*
* @since 1.9.0
*
* @param int $course_id
* @return double
*/
public static function get_course_users_grades_sum( $course_id ) {
global $wpdb;
$lesson_ids = Sensei()->course->course_lessons( $course_id, 'any', 'ids' );
if ( ! $lesson_ids ) {
return 0;
}
$comment_query_piece = [];
$clean_lesson_ids = implode( ',', esc_sql( $lesson_ids ) );
$comment_query_piece['select'] = "SELECT SUM({$wpdb->commentmeta}.meta_value) AS meta_sum";
$comment_query_piece['from'] = " FROM {$wpdb->comments} INNER JOIN {$wpdb->commentmeta} ON ( {$wpdb->comments}.comment_ID = {$wpdb->commentmeta}.comment_id ) ";
$comment_query_piece['where'] = " WHERE {$wpdb->comments}.comment_type IN ('sensei_lesson_status') AND {$wpdb->comments}.comment_approved IN ('graded', 'passed', 'failed') AND ( {$wpdb->commentmeta}.meta_key = 'grade')
AND {$wpdb->comments}.comment_post_ID IN ({$clean_lesson_ids}) ";
$comment_query = $comment_query_piece['select'] . $comment_query_piece['from'] . $comment_query_piece['where'];
$sum_of_all_grades = intval( $wpdb->get_var( $comment_query, 0, 0 ) );
return $sum_of_all_grades;
}
/**
* Get the average grade of all courses.
*
* @since 4.2.0
* @access public
*
* @return double Average grade of all courses.
*/
public function get_courses_average_grade() {
global $wpdb;
/**
* The subquery calculates the average grade per course, and the outer query then calculates the
* average grade of all courses. To be included in the calculation, a lesson must:
* Have a status of 'graded', 'passed' or 'failed'.
* Have grade data.
* Be associated with a course.
* Have quiz questions (checking for the existence of '_quiz_has_questions' meta is sufficient;
* if it exists its value will be 1).
*/
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Performance improvement.
$result = $wpdb->get_row(
"SELECT AVG(course_average) as courses_average
FROM (
SELECT AVG(cm.meta_value) as course_average
FROM {$wpdb->comments} c
INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
INNER JOIN {$wpdb->postmeta} course ON c.comment_post_ID = course.post_id
INNER JOIN {$wpdb->postmeta} has_questions ON c.comment_post_ID = has_questions.post_id
INNER JOIN {$wpdb->posts} p ON p.ID = course.meta_value
WHERE c.comment_type = 'sensei_lesson_status'
AND c.comment_approved IN ( 'graded', 'passed', 'failed' )
AND cm.meta_key = 'grade'
AND course.meta_key = '_lesson_course'
AND course.meta_value <> ''
AND has_questions.meta_key = '_quiz_has_questions'
GROUP BY course.meta_value
) averages_by_course"
);
return floatval( $result->courses_average );
}
}
/**
* Class WooThemes_Sensei_Grading
*
* @ignore only for backward compatibility
* @since 1.9.0
*/
class WooThemes_Sensei_Grading extends Sensei_Grading{}