Diff: STRATO-apps/wordpress_03/app/wp-content/plugins/tutor/classes/Quiz.php

Keine Baseline-Datei – Diff nur gegen leer.
Zur Liste
1 -
1 + <?php
2 + /**
3 + * Quiz class
4 + *
5 + * @package Tutor\QuestionAnswer
6 + * @author Themeum <support@themeum.com>
7 + * @link https://themeum.com
8 + * @since 1.0.0
9 + */
10 +
11 + namespace TUTOR;
12 +
13 + if ( ! defined( 'ABSPATH' ) ) {
14 + exit;
15 + }
16 +
17 + use Tutor\Helpers\HttpHelper;
18 + use Tutor\Helpers\QueryHelper;
19 + use Tutor\Models\CourseModel;
20 + use Tutor\Models\QuizModel;
21 + use Tutor\Traits\JsonResponse;
22 +
23 + /**
24 + * Manage quiz operations.
25 + *
26 + * @since 1.0.0
27 + */
28 + class Quiz {
29 + use JsonResponse;
30 +
31 + const META_QUIZ_OPTION = 'tutor_quiz_option';
32 +
33 + /**
34 + * Allowed attrs
35 + *
36 + * @var array
37 + */
38 + private $allowed_attributes = array(
39 + 'src' => array(),
40 + 'style' => array(),
41 + 'class' => array(),
42 + 'id' => array(),
43 + 'href' => array(),
44 + 'alt' => array(),
45 + 'title' => array(),
46 + 'type' => array(),
47 + 'controls' => array(),
48 + 'muted' => array(),
49 + 'loop' => array(),
50 + 'poster' => array(),
51 + 'preload' => array(),
52 + 'autoplay' => array(),
53 + 'width' => array(),
54 + 'height' => array(),
55 + );
56 +
57 + /**
58 + * Allowed HTML tags
59 + *
60 + * @var array
61 + */
62 + private $allowed_html = array( 'img', 'b', 'i', 'br', 'a', 'audio', 'video', 'source' );
63 +
64 + /**
65 + * Register hooks
66 + *
67 + * @since 1.0.0
68 + *
69 + * @return void
70 + */
71 + public function __construct() {
72 + add_action( 'wp_ajax_tutor_quiz_timeout', array( $this, 'tutor_quiz_timeout' ) );
73 +
74 + // User take the quiz.
75 + add_action( 'template_redirect', array( $this, 'start_the_quiz' ) );
76 + add_action( 'template_redirect', array( $this, 'answering_quiz' ) );
77 + add_action( 'template_redirect', array( $this, 'finishing_quiz_attempt' ) );
78 +
79 + /**
80 + * Instructor quiz review and feedback Ajax API.
81 + */
82 + add_action( 'wp_ajax_review_quiz_answer', array( $this, 'review_quiz_answer' ) );
83 + add_action( 'wp_ajax_tutor_instructor_feedback', array( $this, 'tutor_instructor_feedback' ) );
84 +
85 + /**
86 + * New quiz builder Ajax API.
87 + */
88 + add_action( 'wp_ajax_tutor_quiz_details', array( $this, 'ajax_quiz_details' ) );
89 + add_action( 'wp_ajax_tutor_quiz_delete', array( $this, 'ajax_quiz_delete' ) );
90 +
91 + /**
92 + * Frontend Stuff
93 + */
94 + add_action( 'wp_ajax_tutor_render_quiz_content', array( $this, 'tutor_render_quiz_content' ) );
95 +
96 + /**
97 + * Quiz abandon action
98 + *
99 + * @since 1.9.6
100 + */
101 + add_action( 'wp_ajax_tutor_quiz_abandon', array( $this, 'tutor_quiz_abandon' ) );
102 +
103 + $this->prepare_allowed_html();
104 +
105 + /**
106 + * Delete quiz attempt
107 + *
108 + * @since 2.1.0
109 + */
110 + add_action( 'wp_ajax_tutor_attempt_delete', array( $this, 'attempt_delete' ) );
111 +
112 + add_action( 'tutor_quiz/answer/review/after', array( $this, 'do_auto_course_complete' ), 10, 3 );
113 + }
114 +
115 + /**
116 + * Get quiz time units options.
117 + *
118 + * @since 2.6.0
119 + *
120 + * @return array
121 + */
122 + public static function quiz_time_units() {
123 + $time_units = array(
124 + 'seconds' => __( 'Seconds', 'tutor' ),
125 + 'minutes' => __( 'Minutes', 'tutor' ),
126 + 'hours' => __( 'Hours', 'tutor' ),
127 + 'days' => __( 'Days', 'tutor' ),
128 + 'weeks' => __( 'Weeks', 'tutor' ),
129 + );
130 +
131 + return apply_filters( 'tutor_quiz_time_units', $time_units );
132 + }
133 +
134 + /**
135 + * Get quiz default settings.
136 + *
137 + * @since 3.0.0
138 + *
139 + * @return array
140 + */
141 + public static function get_default_quiz_settings() {
142 + $settings = array(
143 + 'time_limit' => array(
144 + 'time_type' => 'minutes',
145 + 'time_value' => 0,
146 + ),
147 + 'attempts_allowed' => 10,
148 + 'feedback_mode' => 'retry',
149 + 'hide_question_number_overview' => 0,
150 + 'hide_quiz_time_display' => 0,
151 + 'max_questions_for_answer' => 10,
152 + 'open_ended_answer_characters_limit' => 500,
153 + 'pass_is_required' => 0,
154 + 'passing_grade' => 80,
155 + 'question_layout_view' => '',
156 + 'questions_order' => 'rand',
157 + 'quiz_auto_start' => 0,
158 + 'short_answer_characters_limit' => 200,
159 + );
160 +
161 + return apply_filters( 'tutor_quiz_default_settings', $settings );
162 + }
163 +
164 + /**
165 + * Get question default settings.
166 + *
167 + * @since 3.0.0
168 + *
169 + * @param string $type type of question.
170 + *
171 + * @return array
172 + */
173 + public static function get_default_question_settings( $type ) {
174 + $settings = array(
175 + 'question_type' => $type,
176 + 'question_mark' => 1,
177 + 'answer_required' => 0,
178 + 'randomize_question' => 0,
179 + 'show_question_mark' => 0,
180 + );
181 +
182 + return apply_filters( 'tutor_question_default_settings', $settings );
183 + }
184 +
185 + /**
186 + * Get quiz modes
187 + *
188 + * @since 2.6.0
189 + *
190 + * @return array
191 + */
192 + public static function quiz_modes() {
193 + $modes = array(
194 + array(
195 + 'key' => 'default',
196 + 'value' => __( 'Default', 'tutor' ),
197 + 'description' => __( 'Answers shown after quiz is finished', 'tutor' ),
198 + ),
199 + array(
200 + 'key' => 'reveal',
201 + 'value' => __( 'Reveal Mode', 'tutor' ),
202 + 'description' => __( 'Show result after the attempt.', 'tutor' ),
203 + ),
204 + array(
205 + 'key' => 'retry',
206 + 'value' => __( 'Retry Mode', 'tutor' ),
207 + 'description' => __( 'Reattempt quiz any number of times. Define Attempts Allowed below.', 'tutor' ),
208 + ),
209 + );
210 +
211 + return apply_filters( 'tutor_quiz_modes', $modes );
212 + }
213 +
214 + /**
215 + * Get quiz modes
216 + *
217 + * @since 2.6.0
218 + *
219 + * @return array
220 + */
221 + public static function quiz_question_layouts() {
222 + $layouts = array(
223 + '' => __( 'Set question layout view', 'tutor' ),
224 + 'single_question' => __( 'Single Question', 'tutor' ),
225 + 'question_pagination' => __( 'Question Pagination', 'tutor' ),
226 + 'question_below_each_other' => __( 'Question below each other', 'tutor' ),
227 + );
228 +
229 + return apply_filters( 'tutor_quiz_layouts', $layouts );
230 + }
231 +
232 + /**
233 + * Get quiz modes
234 + *
235 + * @since 2.6.0
236 + *
237 + * @return array
238 + */
239 + public static function quiz_question_orders() {
240 + $orders = array(
241 + 'rand' => __( 'Random', 'tutor' ),
242 + 'sorting' => __( 'Sorting', 'tutor' ),
243 + 'asc' => __( 'Ascending', 'tutor' ),
244 + 'desc' => __( 'Descending', 'tutor' ),
245 + );
246 +
247 + return apply_filters( 'tutor_quiz_layouts', $orders );
248 + }
249 +
250 + /**
251 + * Prepare allowed HTML
252 + *
253 + * @since 1.0.0
254 + *
255 + * @return void
256 + */
257 + private function prepare_allowed_html() {
258 +
259 + $allowed = array();
260 +
261 + foreach ( $this->allowed_html as $tag ) {
262 + $allowed[ $tag ] = $this->allowed_attributes;
263 + }
264 +
265 + $this->allowed_html = $allowed;
266 + }
267 +
268 + /**
269 + * Instructor feedback ajax request handler
270 + *
271 + * @since 1.0.0
272 + *
273 + * @return void | send json response
274 + */
275 + public function tutor_instructor_feedback() {
276 + tutor_utils()->checking_nonce();
277 +
278 + // Check if user is privileged.
279 + if ( ! User::has_any_role( array( User::ADMIN, User::INSTRUCTOR ) ) ) {
280 + wp_send_json_error( tutor_utils()->error_message() );
281 + }
282 +
283 + $attempt_details = self::attempt_details( Input::post( 'attempt_id', 0, Input::TYPE_INT ) );
284 + $feedback = Input::post( 'feedback', '', Input::TYPE_KSES_POST );
285 + $attempt_info = isset( $attempt_details->attempt_info ) ? $attempt_details->attempt_info : false;
286 + $course_id = tutor_utils()->avalue_dot( 'course_id', $attempt_details, 0 );
287 + $is_instructor = tutor_utils()->is_instructor_of_this_course( get_current_user_id(), $course_id );
288 + if ( ! current_user_can( 'manage_options' ) && ! $is_instructor ) {
289 + wp_send_json_error( tutor_utils()->error_message() );
290 + }
291 +
292 + if ( $attempt_info ) {
293 + //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
294 + $unserialized = unserialize( $attempt_details->attempt_info );
295 + if ( is_array( $unserialized ) ) {
296 + $unserialized['instructor_feedback'] = $feedback;
297 +
298 + //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
299 + $update = self::update_attempt_info( $attempt_details->attempt_id, serialize( $unserialized ) );
300 + if ( $update ) {
301 + do_action( 'tutor_quiz/attempt/submitted/feedback', $attempt_details->attempt_id );
302 + wp_send_json_success();
303 + } else {
304 + wp_send_json_error();
305 + }
306 + } else {
307 + wp_send_json_error( __( 'Invalid quiz info', 'tutor' ) );
308 + }
309 + }
310 + wp_send_json_error();
311 + }
312 +
313 + /**
314 + * Start Quiz from here...
315 + *
316 + * @since 1.0.0
317 + *
318 + * @return void
319 + */
320 + public function start_the_quiz() {
321 + if ( 'tutor_start_quiz' !== Input::post( 'tutor_action' ) ) {
322 + return;
323 + }
324 +
325 + tutor_utils()->checking_nonce();
326 +
327 + if ( ! is_user_logged_in() ) {
328 + die( esc_html__( 'Please sign in to do this operation', 'tutor' ) );
329 + }
330 +
331 + $user_id = get_current_user_id();
332 + $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
333 + $course = CourseModel::get_course_by_quiz( $quiz_id );
334 +
335 + self::quiz_attempt( $course->ID, $quiz_id, $user_id );
336 + wp_safe_redirect( get_permalink( $quiz_id ) );
337 + die();
338 + }
339 +
340 + /**
341 + * Manage quiz attempt
342 + *
343 + * @since 2.6.1
344 + *
345 + * @param integer $course_id course id.
346 + * @param integer $quiz_id quiz id.
347 + * @param integer $user_id user id.
348 + * @param string $attempt_status attempt status.
349 + *
350 + * @return int inserted id|0
351 + */
352 + public static function quiz_attempt( int $course_id, int $quiz_id, int $user_id, $attempt_status = 'attempt_started' ) {
353 + global $wpdb;
354 +
355 + if ( ! $course_id ) {
356 + die( 'There is something went wrong with course, please check if quiz attached with a course' );
357 + }
358 +
359 + do_action( 'tutor_quiz/start/before', $quiz_id, $user_id );
360 +
361 + $date = date( 'Y-m-d H:i:s', tutor_time() ); //phpcs:ignore
362 +
363 + $tutor_quiz_option = (array) maybe_unserialize( get_post_meta( $quiz_id, 'tutor_quiz_option', true ) );
364 + $attempts_allowed = tutor_utils()->get_quiz_option( $quiz_id, 'attempts_allowed', 0 );
365 +
366 + $time_limit = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_value' );
367 + $time_limit_seconds = 0;
368 + $time_type = 'seconds';
369 + if ( $time_limit ) {
370 + $time_type = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_type' );
371 +
372 + switch ( $time_type ) {
373 + case 'seconds':
374 + $time_limit_seconds = $time_limit;
375 + break;
376 + case 'minutes':
377 + $time_limit_seconds = $time_limit * 60;
378 + break;
379 + case 'hours':
380 + $time_limit_seconds = $time_limit * 60 * 60;
381 + break;
382 + case 'days':
383 + $time_limit_seconds = $time_limit * 60 * 60 * 24;
384 + break;
385 + case 'weeks':
386 + $time_limit_seconds = $time_limit * 60 * 60 * 24 * 7;
387 + break;
388 + }
389 + }
390 +
391 + $max_question_allowed = tutor_utils()->max_questions_for_take_quiz( $quiz_id );
392 + $tutor_quiz_option['time_limit']['time_limit_seconds'] = $time_limit_seconds;
393 +
394 + $attempt_data = array(
395 + 'course_id' => $course_id,
396 + 'quiz_id' => $quiz_id,
397 + 'user_id' => $user_id,
398 + 'total_questions' => $max_question_allowed,
399 + 'total_answered_questions' => 0,
400 + 'attempt_info' => maybe_serialize( $tutor_quiz_option ),
401 + 'attempt_status' => $attempt_status,
402 + 'attempt_ip' => tutor_utils()->get_ip(),
403 + 'attempt_started_at' => $date,
404 + );
405 +
406 + $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_data );
407 + $attempt_id = (int) $wpdb->insert_id;
408 +
409 + if ( $attempt_id ) {
410 + do_action( 'tutor_quiz/start/after', $quiz_id, $user_id, $attempt_id );
411 + return $attempt_id;
412 + } else {
413 + return 0;
414 + }
415 + }
416 +
417 + /**
418 + * Answering quiz
419 + *
420 + * @since 1.0.0
421 + *
422 + * @return void
423 + */
424 + public function answering_quiz() {
425 +
426 + if ( Input::post( 'tutor_action' ) !== 'tutor_answering_quiz_question' ) {
427 + return;
428 + }
429 + // submit quiz attempts.
430 + self::tutor_quiz_attempt_submit();
431 +
432 + wp_safe_redirect( get_the_permalink() );
433 + die();
434 + }
435 +
436 + /**
437 + * Quiz abandon submission handler
438 + *
439 + * @since 1.9.6
440 + *
441 + * @return JSON response
442 + */
443 + public function tutor_quiz_abandon() {
444 + if ( Input::post( 'tutor_action' ) !== 'tutor_answering_quiz_question' ) {
445 + return;
446 + }
447 + tutor_utils()->checking_nonce();
448 + // submit quiz attempts.
449 + if ( self::tutor_quiz_attempt_submit() ) {
450 + wp_send_json_success();
451 + } else {
452 + wp_send_json_error();
453 + }
454 + }
455 +
456 + /**
457 + * This is a unified method for handling normal quiz submit or abandon submit
458 + * It will handle ajax or normal form submit and can be used with different hooks
459 + *
460 + * @since 1.9.6
461 + *
462 + * @return true | false
463 + */
464 + public static function tutor_quiz_attempt_submit() {
465 + // Check logged in.
466 + if ( ! is_user_logged_in() ) {
467 + die( 'Please sign in to do this operation' );
468 + }
469 +
470 + // Check nonce.
471 + tutor_utils()->checking_nonce();
472 +
473 + // Prepare attempt info.
474 + $user_id = get_current_user_id();
475 + $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
476 + $attempt = tutor_utils()->get_attempt( $attempt_id );
477 + $course_id = CourseModel::get_course_by_quiz( $attempt->quiz_id )->ID;
478 +
479 + if ( QuizModel::ATTEMPT_TIMEOUT === $attempt->attempt_status ) {
480 + return false;
481 + }
482 +
483 + // Sanitize data by helper method.
484 + $attempt_answers = isset( $_POST['attempt'] ) ? tutor_sanitize_data( $_POST['attempt'] ) : false; //phpcs:ignore
485 + $attempt_answers = is_array( $attempt_answers ) ? $attempt_answers : array();
486 +
487 + // Check if has access to the attempt.
488 + if ( ! $attempt || $user_id != $attempt->user_id ) {
489 + die( 'Operation not allowed, attempt not found or permission denied' );
490 + }
491 + self::manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id );
492 + return true;
493 + }
494 +
495 + /**
496 + * Manage attempt answers
497 + *
498 + * Evaluate each attempt answer and update the attempts table & insert in the attempt_answers table.
499 + *
500 + * @since 2.6.1
501 + *
502 + * @param array $attempt_answers attempt answers.
503 + * @param object $attempt single attempt.
504 + * @param int $attempt_id attempt id.
505 + * @param int $course_id course id.
506 + * @param int $user_id user id.
507 + *
508 + * @return void
509 + */
510 + public static function manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id ) {
511 + global $wpdb;
512 + // Before hook.
513 + do_action( 'tutor_quiz/attempt_analysing/before', $attempt_id );
514 +
515 + // Single quiz can have multiple question. So multiple answer should be saved.
516 + foreach ( $attempt_answers as $attempt_id => $attempt_answer ) {
517 + // Get total marks of all question comes.
518 + $question_ids = tutor_utils()->avalue_dot( 'quiz_question_ids', $attempt_answer );
519 + $question_ids = array_filter(
520 + $question_ids,
521 + function ( $id ) {
522 + return (int) $id;
523 + }
524 + );
525 +
526 + // Calculate and set the total marks in attempt table for this question.
527 + if ( is_array( $question_ids ) && count( $question_ids ) ) {
528 + $question_ids_string = QueryHelper::prepare_in_clause( $question_ids );
529 +
530 + // Get total marks of the questions from question table.
531 + //phpcs:disable
532 + $query = $wpdb->prepare(
533 + "SELECT SUM(question_mark)
534 + FROM {$wpdb->prefix}tutor_quiz_questions
535 + WHERE 1 = %d
536 + AND question_id IN({$question_ids_string});
537 + ",
538 + 1
539 + );
540 + $total_question_marks = $wpdb->get_var( $query );
541 + //phpcs:enable
542 +
543 + $total_question_marks = apply_filters( 'tutor_filter_update_before_question_mark', $total_question_marks, $question_ids, $user_id, $attempt_id );
544 +
545 + // Set the the total mark in the attempt table for the question.
546 + $wpdb->update(
547 + $wpdb->prefix . 'tutor_quiz_attempts',
548 + array( 'total_marks' => $total_question_marks ),
549 + array( 'attempt_id' => $attempt_id )
550 + );
551 + }
552 +
553 + $total_marks = 0;
554 + $review_required = false;
555 + $quiz_answers = tutor_utils()->avalue_dot( 'quiz_question', $attempt_answer );
556 +
557 + if ( tutor_utils()->count( $quiz_answers ) ) {
558 +
559 + foreach ( $quiz_answers as $question_id => $answers ) {
560 + $question = QuizModel::get_quiz_question_by_id( $question_id );
561 + $question_type = $question->question_type;
562 +
563 + $is_answer_was_correct = false;
564 + $given_answer = '';
565 +
566 + if ( 'true_false' === $question_type || 'single_choice' === $question_type ) {
567 +
568 + if ( ! is_numeric( $answers ) || ! $answers ) {
569 + wp_send_json_error();
570 + exit;
571 + }
572 +
573 + $given_answer = $answers;
574 + $is_answer_was_correct = (bool) $wpdb->get_var(
575 + $wpdb->prepare(
576 + "SELECT is_correct
577 + FROM {$wpdb->prefix}tutor_quiz_question_answers
578 + WHERE answer_id = %d
579 + ",
580 + $answers
581 + )
582 + );
583 +
584 + } elseif ( 'multiple_choice' === $question_type ) {
585 +
586 + $given_answer = (array) ( $answers );
587 +
588 + $given_answer = array_filter(
589 + $given_answer,
590 + function ( $id ) {
591 + return is_numeric( $id ) && $id > 0;
592 + }
593 + );
594 + $get_original_answers = (array) $wpdb->get_col(
595 + $wpdb->prepare(
596 + "SELECT
597 + answer_id
598 + FROM
599 + {$wpdb->prefix}tutor_quiz_question_answers
600 + WHERE belongs_question_id = %d
601 + AND belongs_question_type = %s
602 + AND is_correct = 1 ;
603 + ",
604 + $question->question_id,
605 + $question_type
606 + )
607 + );
608 +
609 + if ( count( array_diff( $get_original_answers, $given_answer ) ) === 0 && count( $get_original_answers ) === count( $given_answer ) ) {
610 + $is_answer_was_correct = true;
611 + }
612 + $given_answer = maybe_serialize( $answers );
613 +
614 + } elseif ( 'fill_in_the_blank' === $question_type ) {
615 +
616 + $get_original_answer = $wpdb->get_row(
617 + $wpdb->prepare(
618 + "SELECT *
619 + FROM {$wpdb->prefix}tutor_quiz_question_answers
620 + WHERE belongs_question_id = %d
621 + AND belongs_question_type = %s ;
622 + ",
623 + $question->question_id,
624 + $question_type
625 + )
626 + );
627 +
628 + /**
629 + * Answers stored in DB
630 + */
631 + $gap_answer = (array) explode( '|', $get_original_answer->answer_two_gap_match );
632 + $gap_answer = maybe_serialize(
633 + array_map(
634 + function ( $ans ) {
635 + return wp_slash( trim( $ans ) );
636 + },
637 + $gap_answer
638 + )
639 + );
640 +
641 + /**
642 + * Answers from user input
643 + */
644 + $given_answer = (array) array_map( 'sanitize_text_field', $answers );
645 + $given_answer = maybe_serialize( $given_answer );
646 +
647 + /**
648 + * Compare answer's by making both case-insensitive.
649 + */
650 + if ( strtolower( $given_answer ) == strtolower( $gap_answer ) ) {
651 + $is_answer_was_correct = true;
652 + }
653 + } elseif ( 'open_ended' === $question_type || 'short_answer' === $question_type ) {
654 + $review_required = true;
655 + $given_answer = wp_kses_post( $answers );
656 +
657 + } elseif ( 'ordering' === $question_type || 'matching' === $question_type || 'image_matching' === $question_type ) {
658 +
659 + $given_answer = (array) array_map( 'sanitize_text_field', tutor_utils()->avalue_dot( 'answers', $answers ) );
660 + $given_answer = maybe_serialize( $given_answer );
661 +
662 + $get_original_answers = (array) $wpdb->get_col(
663 + $wpdb->prepare(
664 + "SELECT answer_id
665 + FROM {$wpdb->prefix}tutor_quiz_question_answers
666 + WHERE belongs_question_id = %d
667 + AND belongs_question_type = %s
668 + ORDER BY answer_order ASC ;
669 + ",
670 + $question->question_id,
671 + $question_type
672 + )
673 + );
674 +
675 + $get_original_answers = array_map( 'sanitize_text_field', $get_original_answers );
676 +
677 + if ( maybe_serialize( $get_original_answers ) == $given_answer ) {
678 + $is_answer_was_correct = true;
679 + }
680 + } elseif ( 'image_answering' === $question_type ) {
681 + $image_inputs = tutor_utils()->avalue_dot( 'answer_id', $answers );
682 + $image_inputs = (array) array_map( 'sanitize_text_field', $image_inputs );
683 + $given_answer = maybe_serialize( $image_inputs );
684 + $is_answer_was_correct = false;
685 + /**
686 + * For the image_answering question type result
687 + * remain pending in spite of correct answer & required
688 + * review of admin/instructor. Since it's
689 + * pending we need to mark it as incorrect. Otherwise if
690 + * mark it correct then earned mark will be updated. then
691 + * again when instructor/admin review & mark it as correct
692 + * extra mark is adding. In this case, student
693 + * getting double mark for the same question.
694 + *
695 + * For now code is commenting will be removed later on
696 + *
697 + * @since 2.1.5
698 + */
699 +
700 + //phpcs:disable
701 +
702 + // $db_answer = $wpdb->get_col(
703 + // $wpdb->prepare(
704 + // "SELECT answer_title
705 + // FROM {$wpdb->prefix}tutor_quiz_question_answers
706 + // WHERE belongs_question_id = %d
707 + // AND belongs_question_type = 'image_answering'
708 + // ORDER BY answer_order asc ;",
709 + // $question_id
710 + // )
711 + // );
712 +
713 + // if ( is_array( $db_answer ) && count( $db_answer ) ) {
714 + // $is_answer_was_correct = ( strtolower( maybe_serialize( array_values( $image_inputs ) ) ) == strtolower( maybe_serialize( $db_answer ) ) );
715 + // }
716 + //phpcs:enable
717 + }
718 +
719 + $question_mark = $is_answer_was_correct ? $question->question_mark : 0;
720 + $total_marks += $question_mark;
721 +
722 + $total_marks = apply_filters( 'tutor_filter_quiz_total_marks', $total_marks, $question_id, $question_type, $user_id, $attempt_id );
723 +
724 + $answers_data = array(
725 + 'user_id' => $user_id,
726 + 'quiz_id' => $attempt->quiz_id,
727 + 'question_id' => $question_id,
728 + 'quiz_attempt_id' => $attempt_id,
729 + 'given_answer' => $given_answer,
730 + 'question_mark' => $question->question_mark,
731 + 'achieved_mark' => $question_mark,
732 + 'minus_mark' => 0,
733 + 'is_correct' => $is_answer_was_correct ? 1 : 0,
734 + );
735 +
736 + /**
737 + * Check if question_type open ended or short ans the set
738 + * is_correct default value null before saving
739 + */
740 + if ( in_array( $question_type, array( 'open_ended', 'short_answer', 'image_answering' ) ) ) {
741 + $answers_data['is_correct'] = null;
742 + $review_required = true;
743 + }
744 +
745 + $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id );
746 +
747 + $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data );
748 + }
749 + }
750 +
751 + $attempt_info = array(
752 + 'total_answered_questions' => tutor_utils()->count( $quiz_answers ),
753 + 'earned_marks' => $total_marks,
754 + 'attempt_status' => 'attempt_ended',
755 + 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
756 + );
757 +
758 + if ( $review_required ) {
759 + $attempt_info['attempt_status'] = 'review_required';
760 + }
761 +
762 + $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_info, array( 'attempt_id' => $attempt_id ) );
763 +
764 + QuizModel::update_attempt_result( $attempt_id );
765 + }
766 +
767 + // After hook.
768 + do_action( 'tutor_quiz/attempt_ended', $attempt_id, $course_id, $user_id );
769 + }
770 +
771 +
772 + /**
773 + * Quiz attempt will be finish here
774 + *
775 + * @since 1.0.0
776 + *
777 + * @return void
778 + */
779 + public function finishing_quiz_attempt() {
780 +
781 + if ( Input::post( 'tutor_action' ) !== 'tutor_finish_quiz_attempt' ) {
782 + return;
783 + }
784 + // Checking nonce.
785 + tutor_utils()->checking_nonce();
786 +
787 + if ( ! is_user_logged_in() ) {
788 + die( 'Please sign in to do this operation' );
789 + }
790 +
791 + global $wpdb;
792 +
793 + $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
794 + $attempt = tutor_utils()->is_started_quiz( $quiz_id );
795 + $attempt_id = $attempt->attempt_id;
796 +
797 + $attempt_info = array(
798 + 'total_answered_questions' => 0,
799 + 'earned_marks' => 0,
800 + 'attempt_status' => 'attempt_ended',
801 + 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
802 + );
803 +
804 + do_action( 'tutor_quiz_before_finish', $attempt_id, $quiz_id, $attempt->user_id );
805 + $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_info, array( 'attempt_id' => $attempt_id ) );
806 + do_action( 'tutor_quiz_finished', $attempt_id, $quiz_id, $attempt->user_id );
807 +
808 + wp_redirect( tutor_utils()->input_old( '_wp_http_referer' ) );
809 + }
810 +
811 + /**
812 + * Get quiz total marks.
813 + *
814 + * @since 3.0.0
815 + *
816 + * @param int $quiz_id quiz id.
817 + *
818 + * @return int|float
819 + */
820 + public static function get_quiz_total_marks( $quiz_id ) {
821 + global $wpdb;
822 +
823 + $total_marks = $wpdb->get_var(
824 + $wpdb->prepare(
825 + "SELECT SUM(question_mark) total_marks
826 + FROM {$wpdb->prefix}tutor_quiz_questions
827 + WHERE quiz_id=%d",
828 + $quiz_id
829 + )
830 + );
831 +
832 + return floatval( $total_marks );
833 + }
834 +
835 + /**
836 + * Quiz timeout by ajax
837 + *
838 + * @since 1.0.0
839 + *
840 + * @return void
841 + */
842 + public function tutor_quiz_timeout() {
843 + tutils()->checking_nonce();
844 +
845 + global $wpdb;
846 +
847 + $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
848 + $attempt = tutor_utils()->is_started_quiz( $quiz_id );
849 +
850 + if ( $attempt ) {
851 + $attempt_id = $attempt->attempt_id;
852 +
853 + $data = array(
854 + 'attempt_status' => 'attempt_timeout',
855 + 'total_marks' => self::get_quiz_total_marks( $quiz_id ),
856 + 'earned_marks' => 0,
857 + 'attempt_ended_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ),
858 + );
859 +
860 + $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $data, array( 'attempt_id' => $attempt->attempt_id ) );
861 +
862 + do_action( 'tutor_quiz_timeout', $attempt_id, $quiz_id, $attempt->user_id );
863 +
864 + wp_send_json_success();
865 + }
866 +
867 + wp_send_json_error( __( 'Quiz has been timeout already', 'tutor' ) );
868 + }
869 +
870 + /**
871 + * Review quiz answer
872 + *
873 + * @since 1.0.0
874 + *
875 + * @return void
876 + */
877 + public function review_quiz_answer() {
878 +
879 + tutor_utils()->checking_nonce();
880 +
881 + global $wpdb;
882 +
883 + $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
884 + $context = Input::post( 'context' );
885 + $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT );
886 + $mark_as = Input::post( 'mark_as' );
887 +
888 + if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) || ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer_id ) ) {
889 + wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
890 + }
891 +
892 + $attempt_answer = $wpdb->get_row(
893 + $wpdb->prepare(
894 + "SELECT *
895 + FROM {$wpdb->prefix}tutor_quiz_attempt_answers
896 + WHERE attempt_answer_id = %d
897 + ",
898 + $attempt_answer_id
899 + )
900 + );
901 +
902 + $attempt = tutor_utils()->get_attempt( $attempt_id );
903 + $question = QuizModel::get_quiz_question_by_id( $attempt_answer->question_id );
904 + $course_id = $attempt->course_id;
905 + $student_id = $attempt->user_id;
906 + $previous_ans = $attempt_answer->is_correct;
907 +
908 + do_action( 'tutor_quiz_review_answer_before', $attempt_answer_id, $attempt_id, $mark_as );
909 +
910 + if ( 'correct' === $mark_as ) {
911 + $attempt_update_data = array();
912 + $answer_update_data = array(
913 + 'achieved_mark' => $attempt_answer->question_mark,
914 + 'is_correct' => 1,
915 + );
916 +
917 + $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
918 +
919 + if ( 0 == $previous_ans || null == $previous_ans ) {
920 + // if previous answer was wrong or in review then add point as correct.
921 + $attempt_update_data = array(
922 + 'earned_marks' => $attempt->earned_marks + $attempt_answer->question_mark,
923 + 'is_manually_reviewed' => 1,
924 + 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
925 + );
926 + }
927 +
928 + if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
929 + $attempt_update_data['attempt_status'] = 'attempt_ended';
930 + }
931 +
932 + if ( ! empty( $attempt_update_data ) ) {
933 + $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
934 + }
935 + } elseif ( 'incorrect' === $mark_as ) {
936 + $attempt_update_data = array();
937 + $answer_update_data = array(
938 + 'achieved_mark' => '0.00',
939 + 'is_correct' => 0,
940 + );
941 +
942 + $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
943 +
944 + if ( 1 == $previous_ans ) {
945 + // If previous ans was right then mynus.
946 + $attempt_update_data = array(
947 + 'earned_marks' => $attempt->earned_marks - $attempt_answer->question_mark,
948 + 'is_manually_reviewed' => 1,
949 + 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ),//phpcs:ignore
950 + );
951 + }
952 +
953 + if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
954 + $attempt_update_data['attempt_status'] = 'attempt_ended';
955 + }
956 +
957 + if ( ! empty( $attempt_update_data ) ) {
958 + $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
959 + }
960 + }
961 +
962 + QuizModel::update_attempt_result( $attempt_id );
963 +
964 + do_action( 'tutor_quiz_review_answer_after', $attempt_answer_id, $attempt_id, $mark_as );
965 + do_action( 'tutor_quiz/answer/review/after', $attempt_answer_id, $course_id, $student_id );
966 +
967 + ob_start();
968 + tutor_load_template_from_custom_path(
969 + tutor()->path . '/views/quiz/attempt-details.php',
970 + array(
971 + 'attempt_id' => $attempt_id,
972 + 'user_id' => $student_id,
973 + 'context' => $context,
974 + 'back_url' => Input::post( 'back_url' ),
975 + )
976 + );
977 + wp_send_json_success( array( 'html' => ob_get_clean() ) );
978 + }
979 +
980 + /**
981 + * Do auto course complete after review a quiz attempt.
982 + *
983 + * @since 2.4.0
984 + *
985 + * @param int $attempt_answer_id attempt answer id.
986 + * @param int $course_id course id.
987 + * @param int $user_id student id.
988 + *
989 + * @return void
990 + */
991 + public function do_auto_course_complete( $attempt_answer_id, $course_id, $user_id ) {
992 + if ( CourseModel::can_autocomplete_course( $course_id, $user_id ) ) {
993 + CourseModel::mark_course_as_completed( $course_id, $user_id );
994 + Course::set_review_popup_data( $user_id, $course_id );
995 + }
996 + }
997 +
998 + /**
999 + * Get a quiz details by id
1000 + *
1001 + * @return void
1002 + */
1003 + public function ajax_quiz_details() {
1004 + tutor_utils()->check_nonce();
1005 +
1006 + $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1007 + if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1008 + $this->json_response(
1009 + tutor_utils()->error_message(),
1010 + null,
1011 + HttpHelper::STATUS_FORBIDDEN
1012 + );
1013 + }
1014 +
1015 + $data = QuizModel::get_quiz_details( $quiz_id );
1016 +
1017 + $data = apply_filters( 'tutor_quiz_details_response', $data, $quiz_id );
1018 +
1019 + $this->json_response(
1020 + __( 'Quiz data fetched successfully', 'tutor' ),
1021 + $data
1022 + );
1023 + }
1024 +
1025 + /**
1026 + * Delete quiz by id
1027 + *
1028 + * @since 1.0.0
1029 + * @since 3.0.0 refactor and response change.
1030 + *
1031 + * @return void
1032 + */
1033 + public function ajax_quiz_delete() {
1034 + if ( ! tutor_utils()->is_nonce_verified() ) {
1035 + $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
1036 + }
1037 +
1038 + global $wpdb;
1039 +
1040 + $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1041 + if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1042 + $this->json_response(
1043 + tutor_utils()->error_message(),
1044 + null,
1045 + HttpHelper::STATUS_FORBIDDEN
1046 + );
1047 + }
1048 +
1049 + $post = get_post( $quiz_id );
1050 + if ( 'tutor_quiz' !== $post->post_type ) {
1051 + $this->json_response(
1052 + __( 'Invalid quiz', 'tutor' ),
1053 + null,
1054 + HttpHelper::STATUS_BAD_REQUEST
1055 + );
1056 + }
1057 +
1058 + do_action( 'tutor_delete_quiz_before', $quiz_id );
1059 +
1060 + $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ) );
1061 + $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $quiz_id ) );
1062 +
1063 + $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $quiz_id ) );
1064 +
1065 + if ( is_array( $questions_ids ) && count( $questions_ids ) ) {
1066 + $in_question_ids = QueryHelper::prepare_in_clause( $questions_ids );
1067 + //phpcs:disable
1068 + $wpdb->query(
1069 + "DELETE
1070 + FROM {$wpdb->prefix}tutor_quiz_question_answers
1071 + WHERE belongs_question_id IN({$in_question_ids})
1072 + "
1073 + );
1074 + //phpcs:enable
1075 + }
1076 +
1077 + $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $quiz_id ) );
1078 +
1079 + wp_delete_post( $quiz_id, true );
1080 +
1081 + do_action( 'tutor_delete_quiz_after', $quiz_id );
1082 +
1083 + $this->json_response(
1084 + __( 'Quiz deleted successfully', 'tutor' ),
1085 + $quiz_id
1086 + );
1087 + }
1088 +
1089 + /**
1090 + * Get answers by quiz id
1091 + *
1092 + * @since 1.0.0
1093 + *
1094 + * @param int $question_id question id.
1095 + * @param mixed $question_type type of question.
1096 + * @param boolean $is_correct only correct answers or not.
1097 + *
1098 + * @return wpdb:get_results
1099 + */
1100 + private function get_answers_by_q_id( $question_id, $question_type, $is_correct = false ) {
1101 + global $wpdb;
1102 +
1103 + $correct_clause = $is_correct ? ' AND is_correct=1 ' : '';
1104 + //phpcs:disable
1105 + return $wpdb->get_results(
1106 + $wpdb->prepare(
1107 + "SELECT * FROM {$wpdb->prefix}tutor_quiz_question_answers
1108 + WHERE belongs_question_id = %d
1109 + AND belongs_question_type = %s
1110 + {$correct_clause}
1111 + ORDER BY answer_order ASC;
1112 + ",
1113 + $question_id,
1114 + esc_sql( $question_type )
1115 + )
1116 + );
1117 + //phpcs:enable
1118 + }
1119 +
1120 + /**
1121 + * Rendering quiz for frontend
1122 + *
1123 + * @since 1.0.0
1124 + *
1125 + * @return void send wp_json response
1126 + */
1127 + public function tutor_render_quiz_content() {
1128 +
1129 + tutor_utils()->checking_nonce();
1130 +
1131 + $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1132 +
1133 + if ( ! tutor_utils()->has_enrolled_content_access( 'quiz', $quiz_id ) ) {
1134 + wp_send_json_error( array( 'message' => __( 'Access Denied.', 'tutor' ) ) );
1135 + }
1136 +
1137 + ob_start();
1138 + global $post;
1139 +
1140 + $post = get_post( $quiz_id ); //phpcs:ignore
1141 + setup_postdata( $post );
1142 +
1143 + single_quiz_contents();
1144 + wp_reset_postdata();
1145 +
1146 + $html = ob_get_clean();
1147 + wp_send_json_success( array( 'html' => $html ) );
1148 + }
1149 +
1150 + /**
1151 + * Get attempt details
1152 + *
1153 + * @since 1.0.0
1154 + *
1155 + * @param int $attempt_id required attempt id to get details.
1156 + *
1157 + * @return mixed object on success, null on failure
1158 + */
1159 + public static function attempt_details( int $attempt_id ) {
1160 + global $wpdb;
1161 + $attempt_details = $wpdb->get_row(
1162 + $wpdb->prepare(
1163 + "SELECT *
1164 + FROM {$wpdb->prefix}tutor_quiz_attempts
1165 + WHERE attempt_id = %d
1166 + ",
1167 + $attempt_id
1168 + )
1169 + );
1170 + return $attempt_details;
1171 + }
1172 +
1173 + /**
1174 + * Update quiz attempt info
1175 + *
1176 + * @since 1.0.0
1177 + *
1178 + * @param int $attempt_id attempt id.
1179 + * @param mixed $attempt_info serialize data.
1180 + *
1181 + * @return bool, true on success, false on failure
1182 + */
1183 + public static function update_attempt_info( int $attempt_id, $attempt_info ) {
1184 + global $wpdb;
1185 + $table = $wpdb->prefix . 'tutor_quiz_attempts';
1186 + $update_info = $wpdb->update(
1187 + $table,
1188 + array( 'attempt_info' => $attempt_info ),
1189 + array( 'attempt_id' => $attempt_id )
1190 + );
1191 + return $update_info ? true : false;
1192 + }
1193 +
1194 + /**
1195 + * Attempt delete ajax request handler
1196 + *
1197 + * @since 2.1.0
1198 + *
1199 + * @return void wp_json response
1200 + */
1201 + public function attempt_delete() {
1202 + tutor_utils()->checking_nonce();
1203 +
1204 + $attempt_id = Input::post( 'id', 0, Input::TYPE_INT );
1205 + $attempt = tutor_utils()->get_attempt( $attempt_id );
1206 + if ( ! $attempt ) {
1207 + wp_send_json_error( __( 'Invalid attempt ID', 'tutor' ) );
1208 + }
1209 +
1210 + $user_id = get_current_user_id();
1211 + $course_id = $attempt->course_id;
1212 +
1213 + if ( tutor_utils()->can_user_edit_course( $user_id, $course_id ) ) {
1214 + QuizModel::delete_quiz_attempt( $attempt_id );
1215 + wp_send_json_success( __( 'Attempt deleted successfully!', 'tutor' ) );
1216 + } else {
1217 + wp_send_json_error( tutor_utils()->error_message() );
1218 + }
1219 + }
1220 +
1221 + /**
1222 + * Get all quiz attempts for a user in a specific course.
1223 + *
1224 + * @since 3.8.1
1225 + *
1226 + * @param int $course_id The ID of the course.
1227 + * @param int $user_id The ID of the user.
1228 + *
1229 + * @return array Returns an array of quiz attempt objects with their answers, or an empty array on error.
1230 + */
1231 + public function get_quiz_attempts_and_answers_by_course_id( int $course_id ): array {
1232 + global $wpdb;
1233 +
1234 + $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempts, array( 'course_id' => $course_id ), 'course_id', -1 );
1235 +
1236 + if ( empty( $results ) ) {
1237 + return array();
1238 + }
1239 +
1240 + return array_map(
1241 + function ( $item ) {
1242 + $item->quiz_attempt_answers = $this->get_quiz_attempt_answers_by_attempt_id( $item->attempt_id );
1243 + return $item;
1244 + },
1245 + $results
1246 + );
1247 + }
1248 +
1249 + /**
1250 + * Get all quiz attempt answers for a specific quiz attempt.
1251 + *
1252 + * @since 3.8.1
1253 + *
1254 + * @param int $attempt_id The ID of the quiz attempt.
1255 + *
1256 + * @return array Returns an array of quiz attempt answers objects, or an empty array on error.
1257 + */
1258 + private function get_quiz_attempt_answers_by_attempt_id( int $attempt_id ): array {
1259 + global $wpdb;
1260 +
1261 + $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempt_answers, array( 'quiz_attempt_id' => $attempt_id ), 'quiz_attempt_id', -1 );
1262 +
1263 + if ( empty( $results ) ) {
1264 + return array();
1265 + }
1266 +
1267 + return $results;
1268 + }
1269 + }
1270 +