<?php
/**
* File containing the class Sensei_REST_API_Lesson_Quiz_Controller.
*
* @package sensei
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Sensei Lesson Quiz REST API endpoints.
*
* @package Sensei
* @author Automattic
* @since 3.9.0
*/
class Sensei_REST_API_Lesson_Quiz_Controller extends \WP_REST_Controller {
use Sensei_REST_API_Question_Helpers_Trait;
/**
* Routes namespace.
*
* @var string
*/
protected $namespace;
/**
* Routes prefix.
*
* @var string
*/
protected $rest_base = 'lesson-quiz';
/**
* Sensei_REST_API_Quiz_Controller constructor.
*
* @param string $routes_namespace Routes namespace.
*/
public function __construct( $routes_namespace ) {
$this->namespace = $routes_namespace;
}
/**
* Register the REST API endpoints for quiz.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
$this->rest_base . '/(?P<lesson_id>[0-9]+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_quiz' ),
'permission_callback' => array( $this, 'can_user_get_quiz' ),
'args' => array(
'lesson_id' => array(
'type' => 'integer',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
),
),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'save_quiz' ),
'permission_callback' => array( $this, 'can_user_save_quiz' ),
'args' => array(
'options' => array(
'type' => 'object',
'required' => true,
'sanitize_callback' => 'rest_sanitize_request_arg',
'validate_callback' => 'rest_validate_request_arg',
),
'questions' => array(
'type' => 'array',
'required' => true,
'sanitize_callback' => array( $this, 'sanitize_questions' ),
'validate_callback' => array( $this, 'validate_questions' ),
),
),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
}
/**
* Sanitization method for questions.
*
* This can be replaced by `rest_sanitize_request_arg` in the callback once we no longer support WordPress
* versions before 5.6 (when `oneOf` support was added).
*
* @param array $questions The questions.
*
* @return array|WP_Error The sanitized questions.
*/
public function sanitize_questions( array $questions ) {
$sanitized_questions = array();
foreach ( $questions as $question ) {
$result = rest_sanitize_value_from_schema( $question, $this->get_question_schema( $question['type'] ) );
if ( is_wp_error( $result ) ) {
return $result;
}
$sanitized_questions[] = $result;
}
return $sanitized_questions;
}
/**
* Validation method for questions.
*
* This can be replaced by `rest_validate_request_arg` in the callback once we no longer support WordPress
* versions before 5.6 (when `oneOf` support was added).
*
* @param array $questions The questions.
*
* @return true|WP_Error True on success, error otherwise.
*/
public function validate_questions( array $questions ) {
foreach ( $questions as $question ) {
$result = rest_validate_value_from_schema( $question, $this->get_question_schema( $question['type'] ) );
if ( is_wp_error( $result ) ) {
return $result;
}
}
return true;
}
/**
* Check user permission for saving course structure.
*
* @param WP_REST_Request $request WordPress request object.
*
* @return bool|WP_Error Whether the user can save course structure data. Error if not found.
*/
public function can_user_save_quiz( WP_REST_Request $request ) {
$lesson = get_post( (int) $request->get_param( 'lesson_id' ) );
if ( ! $lesson || 'lesson' !== $lesson->post_type ) {
return new WP_Error(
'sensei_lesson_quiz_missing_lesson',
__( 'Lesson not found.', 'sensei-lms' ),
array( 'status' => 404 )
);
}
if ( ! is_user_logged_in() ) {
return false;
}
return current_user_can( get_post_type_object( 'lesson' )->cap->edit_post, $lesson->ID );
}
/**
* Save the quiz.
*
* @param WP_REST_Request $request WordPress request object.
*
* @return WP_REST_Response|WP_Error
*/
public function save_quiz( WP_REST_Request $request ) {
$lesson = get_post( (int) $request->get_param( 'lesson_id' ) );
if ( 'auto-draft' === $lesson->post_status ) {
return new WP_Error(
'sensei_lesson_quiz_lesson_auto_draft',
__( 'Cannot update the quiz of an Auto Draft lesson.', 'sensei-lms' ),
array( 'status' => 400 )
);
}
$quiz_id = Sensei()->lesson->lesson_quizzes( $lesson->ID );
$is_new = null === $quiz_id;
$json_params = $request->get_json_params();
$quiz_id = wp_insert_post(
array(
'ID' => $quiz_id,
'post_content' => '',
'post_status' => $lesson->post_status,
'post_title' => $lesson->post_title,
'post_type' => 'quiz',
'post_parent' => $lesson->ID,
'meta_input' => $this->get_quiz_meta( $json_params, $lesson ),
)
);
if ( is_wp_error( $quiz_id ) ) {
return $quiz_id;
}
if ( $is_new ) {
update_post_meta( (int) $lesson->ID, '_lesson_quiz', (int) $quiz_id );
wp_set_post_terms( $quiz_id, array( 'multiple-choice' ), 'quiz-type' );
/**
* Fires after a quiz is created via the REST API.
*
* @since 4.22.0
*
* @hook sensei_quiz_create
*
* @param {int} $quiz_id Quiz post ID.
* @param {int} $lesson_id Course post ID.
*/
do_action( 'sensei_quiz_create', (int) $quiz_id, (int) $lesson->ID );
}
$existing_question_ids = array_map( 'intval', wp_list_pluck( Sensei()->quiz->get_questions( $quiz_id ), 'ID' ) );
$question_ids = array();
foreach ( $json_params['questions'] as $question ) {
if ( isset( $question['type'] ) && 'category-question' === $question['type'] ) {
$question_id = $this->save_category_question( $question );
} else {
$question_id = $this->save_question( $question );
}
if ( is_wp_error( $question_id ) ) {
if ( 'sensei_lesson_quiz_question_not_available' === $question_id->get_error_code() ) {
// Gracefully ignore this error and include it (unchanged) in the quiz if it already exists.
$question_id = (int) $question_id->get_error_data();
if ( ! in_array( $question_id, $existing_question_ids, true ) ) {
$question_id = null;
}
} else {
return $question_id;
}
}
$question_ids[] = $question_id;
}
Sensei()->quiz->set_questions( $quiz_id, array_filter( $question_ids ) );
/**
* Fire action when a the lesson-quiz controller updates the data of the quiz.
*
* @hook sensei_rest_api_lesson_quiz_update
*
* @param {WP_Post} $lesson The lesson.
* @param {array} $request_params The request parameters.
*/
do_action( 'sensei_rest_api_lesson_quiz_update', $lesson, $json_params );
$response = new WP_REST_Response();
$response->set_data( $this->get_quiz_data( get_post( $quiz_id ), $lesson ) );
return $response;
}
/**
* Helper method to translate input to quiz meta.
*
* @param array $json_params The input coming from JSON data.
* @param WP_Post $lesson The parent lesson.
*
* @return array The meta.
*/
private function get_quiz_meta( array $json_params, WP_Post $lesson ): array {
$meta_input = array( '_quiz_lesson' => $lesson->ID );
$quiz_options = $json_params['options'];
if ( isset( $quiz_options['pass_required'] ) ) {
$meta_input['_pass_required'] = true === $quiz_options['pass_required'] ? 'on' : '';
}
if ( isset( $quiz_options['quiz_passmark'] ) ) {
$meta_input['_quiz_passmark'] = empty( $quiz_options['quiz_passmark'] ) ? 0 : $quiz_options['quiz_passmark'];
}
if ( isset( $quiz_options['auto_grade'] ) ) {
$meta_input['_quiz_grade_type'] = true === $quiz_options['auto_grade'] ? 'auto' : 'manual';
}
if ( isset( $quiz_options['allow_retakes'] ) ) {
$meta_input['_enable_quiz_reset'] = true === $quiz_options['allow_retakes'] ? 'on' : '';
}
if ( array_key_exists( 'show_questions', $quiz_options ) ) {
$meta_input['_show_questions'] = empty( $quiz_options['show_questions'] ) ? '' : $quiz_options['show_questions'];
}
if ( isset( $quiz_options['random_question_order'] ) ) {
$meta_input['_random_question_order'] = true === $quiz_options['random_question_order'] ? 'yes' : 'no';
}
foreach ( array( 'failed_indicate_incorrect', 'failed_show_correct_answers', 'failed_show_answer_feedback' ) as $option ) {
if ( isset( $quiz_options[ $option ] ) ) {
$meta_input[ '_' . $option ] = $quiz_options[ $option ] ? 'yes' : 'no';
} else {
$meta_input[ '_' . $option ] = null;
}
}
$meta_input['_button_text_color'] = $quiz_options['button_text_color'] ?? null;
$meta_input['_button_background_color'] = $quiz_options['button_background_color'] ?? null;
if ( isset( $quiz_options['pagination'] ) ) {
$meta_input['_pagination'] = wp_json_encode( $quiz_options['pagination'] );
}
return $meta_input;
}
/**
* Check user permission for reading a quiz.
*
* @param WP_REST_Request $request WordPress request object.
*
* @return bool|WP_Error Whether the user can read a quiz. Error if not found.
*/
public function can_user_get_quiz( WP_REST_Request $request ) {
$lesson = get_post( (int) $request->get_param( 'lesson_id' ) );
if ( ! $lesson || 'lesson' !== $lesson->post_type ) {
return new WP_Error(
'sensei_lesson_quiz_missing_lesson',
__( 'Lesson not found.', 'sensei-lms' ),
array( 'status' => 404 )
);
}
if ( ! is_user_logged_in() ) {
return false;
}
return current_user_can( get_post_type_object( 'lesson' )->cap->edit_post, $lesson->ID ) || current_user_can( 'manage_options' );
}
/**
* Get the quiz.
*
* @param WP_REST_Request $request WordPress request object.
*
* @return WP_REST_Response
*/
public function get_quiz( WP_REST_Request $request ): WP_REST_Response {
$lesson = get_post( (int) $request->get_param( 'lesson_id' ) );
$quiz = Sensei()->lesson->lesson_quizzes( $lesson->ID );
if ( ! $quiz ) {
return new WP_REST_Response( null, 204 );
}
$response = new WP_REST_Response();
$response->set_data( $this->get_quiz_data( get_post( $quiz ), $lesson ) );
return $response;
}
/**
* Helper method which retrieves quiz options.
*
* @param WP_Post $quiz The quiz post.
* @param WP_Post $lesson The lesson post.
*
* @return array
*/
private function get_quiz_data( WP_Post $quiz, WP_Post $lesson ): array {
$post_meta = get_post_meta( $quiz->ID );
$allow_retakes = ! empty( $post_meta['_enable_quiz_reset'][0] ) && 'on' === $post_meta['_enable_quiz_reset'][0];
$failed_feedback_default = ! $allow_retakes;
$pagination_defaults = array(
'pagination_number' => null,
'show_progress_bar' => false,
'progress_bar_radius' => 6,
'progress_bar_height' => 12,
'progress_bar_color' => null,
'progress_bar_background' => null,
);
if ( empty( $post_meta['_pagination'][0] ) ) {
$pagination = $pagination_defaults;
} else {
$pagination = json_decode( $post_meta['_pagination'][0], true );
$pagination = $pagination ? $pagination : $pagination_defaults;
}
$quiz_data = array(
'options' => array(
'pass_required' => ! empty( $post_meta['_pass_required'][0] ) && 'on' === $post_meta['_pass_required'][0],
'quiz_passmark' => empty( $post_meta['_quiz_passmark'][0] ) ? 0 : (int) $post_meta['_quiz_passmark'][0],
'auto_grade' => ! empty( $post_meta['_quiz_grade_type'][0] ) && 'auto' === $post_meta['_quiz_grade_type'][0],
'allow_retakes' => $allow_retakes,
'show_questions' => empty( $post_meta['_show_questions'][0] ) ? null : (int) $post_meta['_show_questions'][0],
'random_question_order' => ! empty( $post_meta['_random_question_order'][0] ) && 'yes' === $post_meta['_random_question_order'][0],
'failed_indicate_incorrect' => empty( $post_meta['_failed_indicate_incorrect'][0] ) ? $failed_feedback_default : 'yes' === $post_meta['_failed_indicate_incorrect'][0],
'failed_show_correct_answers' => empty( $post_meta['_failed_show_correct_answers'][0] ) ? $failed_feedback_default : 'yes' === $post_meta['_failed_show_correct_answers'][0],
'failed_show_answer_feedback' => empty( $post_meta['_failed_show_answer_feedback'][0] ) ? $failed_feedback_default : 'yes' === $post_meta['_failed_show_answer_feedback'][0],
'button_text_color' => ! empty( $post_meta['_button_text_color'][0] ) ? $post_meta['_button_text_color'][0] : null,
'button_background_color' => ! empty( $post_meta['_button_background_color'][0] ) ? $post_meta['_button_background_color'][0] : null,
'pagination' => $pagination,
),
'questions' => $this->get_quiz_questions( $quiz ),
'lesson_title' => $lesson->post_title,
'lesson_status' => $lesson->post_status,
);
/**
* Filters the response of lesson-quiz requests.
*
* @hook sensei_rest_api_lesson_quiz_response
*
* @param {array} $quiz_data The response data.
* @param {WP_Post} $quiz The quiz post.default interval.
* @return {array} $quiz_data The modified response data.
*/
return apply_filters( 'sensei_rest_api_lesson_quiz_response', $quiz_data, $quiz );
}
/**
* Returns all the questions of a quiz.
*
* @param WP_Post $quiz The quiz.
*
* @return array The array of the questions as defined by the schema.
*/
private function get_quiz_questions( WP_Post $quiz ): array {
$questions = Sensei()->quiz->get_questions( $quiz->ID );
if ( empty( $questions ) ) {
return array();
}
$quiz_questions = array();
foreach ( $questions as $question ) {
if ( 'multiple_question' === $question->post_type ) {
$quiz_questions[] = $this->get_category_question( $question );
} else {
$quiz_questions[] = $this->get_question( $question );
}
}
return $quiz_questions;
}
/**
* Schema for the endpoint.
*
* @return array Schema object.
*/
public function get_item_schema(): array {
$schema = array(
'type' => 'object',
'properties' => array(
'options' => array(
'type' => 'object',
'required' => true,
'properties' => array(
'pass_required' => array(
'type' => 'boolean',
'description' => 'Pass required to complete lesson',
'default' => false,
),
'quiz_passmark' => array(
'type' => 'integer',
'description' => 'Score grade between 0 and 100 required to pass the quiz',
'default' => 100,
),
'auto_grade' => array(
'type' => 'boolean',
'description' => 'Whether auto-grading should take place',
'default' => true,
),
'allow_retakes' => array(
'type' => 'boolean',
'description' => 'Allow quizzes to be taken again',
'default' => true,
),
'show_questions' => array(
'type' => array( 'integer', 'null' ),
'description' => 'Number of questions to show randomly',
'default' => null,
),
'random_question_order' => array(
'type' => 'boolean',
'description' => 'Show questions in a random order',
'default' => false,
),
'failed_indicate_incorrect' => array(
'type' => array( 'boolean', 'null' ),
'description' => 'Indicate which questions are incorrect',
'default' => null,
),
'failed_show_correct_answers' => array(
'type' => array( 'boolean', 'null' ),
'description' => 'Show correct answers',
'default' => null,
),
'failed_show_answer_feedback' => array(
'type' => array( 'boolean', 'null' ),
'description' => 'Show answer feedback text',
'default' => null,
),
'button_text_color' => array(
'type' => array( 'string', 'null' ),
'description' => 'Button text color',
'default' => null,
),
'button_background_color' => array(
'type' => array( 'string', 'null' ),
'description' => 'Button background color',
'default' => null,
),
'pagination' => array(
'type' => 'object',
'required' => true,
'properties' => array(
'pagination_number' => array(
'type' => array( 'integer', 'null' ),
'description' => 'Number of questions per page',
'default' => null,
),
'show_progress_bar' => array(
'type' => 'boolean',
'description' => 'Whether to show the progress bar in the frontend',
'default' => false,
),
'progress_bar_radius' => array(
'type' => 'integer',
'description' => 'Progress bar radius',
'default' => 6,
),
'progress_bar_height' => array(
'type' => 'integer',
'description' => 'Progress bar height',
'default' => 12,
),
'progress_bar_background' => array(
'type' => array( 'string', 'null' ),
'description' => 'Progress bar background color',
'default' => null,
),
),
),
),
),
'questions' => array(
'type' => 'array',
'required' => true,
'items' => $this->get_single_question_schema(),
),
'lesson_title' => array(
'type' => 'string',
'description' => 'The lesson title',
),
'lesson_status' => array(
'type' => 'string',
'description' => 'The lesson status',
),
),
);
return $schema;
}
}