PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 4.0.0
Tutor LMS – eLearning and online course solution v4.0.0
4.0.0 3.9.15 3.9.14 3.9.13 3.9.12 3.9.11 trunk 1.0.0 1.0.0-alpha 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.2.0 1.2.1 1.2.11 1.2.12 1.2.13 1.2.20 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 1.7.6 1.7.7 1.7.8 1.7.9 1.8.0 1.8.1 1.8.10 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.15 1.9.16 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.1.1 2.1.10 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.3.0 2.4.0 2.5.0 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.1.0 3.2.0 3.2.1 3.2.2 3.2.3 3.3.0 3.3.1 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.7.0 3.7.1 3.7.2 3.7.3 3.7.4 3.8.0 3.8.1 3.8.2 3.8.3 3.9.0 3.9.1 3.9.10 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9
tutor / classes / Quiz.php
tutor / classes Last commit date
Addons.php 1 day ago Admin.php 1 day ago Ajax.php 1 day ago Announcements.php 1 day ago Assets.php 1 day ago Backend_Page_Trait.php 1 year ago BaseController.php 1 year ago Config.php 1 day ago Container.php 11 months ago Course.php 1 day ago Course_Embed.php 3 years ago Course_Filter.php 1 day ago Course_List.php 1 day ago Course_Settings_Tabs.php 1 day ago Course_Widget.php 1 year ago Custom_Validation.php 1 day ago Dashboard.php 1 day ago Earnings.php 9 months ago FormHandler.php 1 day ago Frontend.php 1 day ago Gutenberg.php 1 year ago Icon.php 1 day ago Input.php 1 day ago Instructor.php 1 day ago Instructors_List.php 1 day ago Lesson.php 1 day ago Options_V2.php 1 day ago Permalink.php 1 day ago Post_types.php 2 days ago Private_Course_Access.php 1 day ago Q_And_A.php 1 day ago Question_Answers_List.php 11 months ago Quiz.php 1 day ago QuizBuilder.php 1 day ago Quiz_Attempts_List.php 1 day ago RestAPI.php 2 years ago Reviews.php 1 day ago Rewrite_Rules.php 2 years ago SampleCourse.php 1 day ago Shortcode.php 1 day ago Singleton.php 1 year ago Student.php 1 day ago Students_List.php 1 year ago Taxonomies.php 1 year ago Template.php 1 day ago Theme_Compatibility.php 3 years ago Tools.php 1 year ago Tools_V2.php 4 weeks ago Tutor.php 1 day ago TutorEDD.php 1 day ago Tutor_Base.php 2 years ago Tutor_Setup.php 1 day ago Upgrader.php 1 day ago User.php 1 day ago UserPreference.php 1 day ago Utils.php 1 day ago Video_Stream.php 3 years ago WhatsNew.php 10 months ago Withdraw.php 1 day ago Withdraw_Requests_List.php 11 months ago WooCommerce.php 1 day ago
Quiz.php
2280 lines
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 defined( 'ABSPATH' ) || exit;
14
15 use Tutor\Components\Constants\Size;
16 use Tutor\Components\Constants\Variant;
17 use Tutor\Components\Button;
18 use Tutor\Components\ConfirmationModal;
19 use Tutor\Components\Modal;
20 use Tutor\Components\SvgIcon;
21 use Tutor\Components\Table;
22 use Tutor\Helpers\HttpHelper;
23 use Tutor\Helpers\QueryHelper;
24 use Tutor\Models\CourseModel;
25 use Tutor\Models\QuizModel;
26 use Tutor\Traits\JsonResponse;
27 use WP_Post;
28
29 /**
30 * Manage quiz operations.
31 *
32 * @since 1.0.0
33 */
34 class Quiz {
35 use JsonResponse;
36
37 /**
38 * Quiz post type
39 *
40 * @since 4.0.0
41 *
42 * @var string
43 */
44 private $post_type;
45
46 const META_QUIZ_OPTION = 'tutor_quiz_option';
47
48 /**
49 * Quiz feedback mode: show result after the attempt.
50 *
51 * @since 4.0.0
52 */
53 const QUIZ_FEEDBACK_MODE_REVEAL = 'reveal';
54
55 /**
56 * Quiz feedback mode: reattempt quiz any number of times.
57 *
58 * @since 4.0.0
59 */
60 const QUIZ_FEEDBACK_MODE_RETRY = 'retry';
61
62 /**
63 * Quiz feedback mode: answers shown after quiz is finished.
64 *
65 * @since 4.0.0
66 */
67 const QUIZ_FEEDBACK_MODE_DEFAULT = 'default';
68
69 /**
70 * URL Query param
71 *
72 * @since 4.0.0
73 */
74 const ACTION_VIEW_DETAILS = 'view_details';
75
76 /**
77 * Allowed attrs
78 *
79 * @var array
80 */
81 private $allowed_attributes = array(
82 'src' => array(),
83 'style' => array(),
84 'class' => array(),
85 'id' => array(),
86 'href' => array(),
87 'alt' => array(),
88 'title' => array(),
89 'type' => array(),
90 'controls' => array(),
91 'muted' => array(),
92 'loop' => array(),
93 'poster' => array(),
94 'preload' => array(),
95 'autoplay' => array(),
96 'width' => array(),
97 'height' => array(),
98 );
99
100 /**
101 * Allowed HTML tags
102 *
103 * @var array
104 */
105 private $allowed_html = array( 'img', 'b', 'i', 'br', 'a', 'audio', 'video', 'source' );
106
107 /**
108 * Register hooks
109 *
110 * @since 1.0.0
111 * @since 4.0.0 $register_hooks param added
112 *
113 * @param bool $register_hooks To register hooks.
114 *
115 * @return void
116 */
117 public function __construct( $register_hooks = true ) {
118 $this->post_type = tutor()->quiz_post_type;
119 $this->prepare_allowed_html();
120
121 if ( ! $register_hooks ) {
122 return;
123 }
124
125 add_action( 'wp_ajax_tutor_quiz_timeout', array( $this, 'tutor_quiz_timeout' ) );
126
127 // User take the quiz.
128 add_action( 'template_redirect', array( $this, 'start_the_quiz' ) );
129 add_action( 'template_redirect', array( $this, 'answering_quiz' ) );
130 add_action( 'template_redirect', array( $this, 'finishing_quiz_attempt' ) );
131
132 /**
133 * Instructor quiz review and feedback Ajax API.
134 */
135 add_action( 'wp_ajax_review_quiz_answer', array( $this, 'review_quiz_answer' ) );
136 add_action( 'wp_ajax_tutor_review_quiz_answers', array( $this, 'review_quiz_answers' ) );
137 add_action( 'wp_ajax_tutor_instructor_feedback', array( $this, 'tutor_instructor_feedback' ) );
138
139 /**
140 * New quiz builder Ajax API.
141 */
142 add_action( 'wp_ajax_tutor_quiz_details', array( $this, 'ajax_quiz_details' ) );
143 add_action( 'wp_ajax_tutor_quiz_delete', array( $this, 'ajax_quiz_delete' ) );
144
145 /**
146 * Frontend Stuff
147 */
148 add_action( 'wp_ajax_tutor_render_quiz_content', array( $this, 'tutor_render_quiz_content' ) );
149
150 /**
151 * Quiz abandon action
152 *
153 * @since 1.9.6
154 */
155 add_action( 'wp_ajax_tutor_quiz_abandon', array( $this, 'tutor_quiz_abandon' ) );
156
157 /**
158 * Delete quiz attempt
159 *
160 * @since 2.1.0
161 */
162 add_action( 'wp_ajax_tutor_attempt_delete', array( $this, 'attempt_delete' ) );
163
164 add_action( 'tutor_quiz/answer/review/after', array( $this, 'do_auto_course_complete' ), 10, 3 );
165
166 // Add quiz title as nav item & render single content on the learning area.
167 add_action( "tutor_learning_area_nav_item_{$this->post_type}", array( $this, 'render_nav_item' ), 10, 2 );
168 add_action( "tutor_single_content_{$this->post_type}", array( $this, 'render_single_content' ) );
169
170 /**
171 * Slugs listed in tutor_quiz_templates_not_in_core have no file under wp-content/plugins/tutor/templates/.
172 * Without this, tutor_load_template() still runs from generic quiz templates and tutor_get_template()
173 * prints "The file you are trying to load does not exist…". Returning false here exits before that lookup.
174 * Add-ons ship their own files and load them outside this path (e.g. direct include from the add-on).
175 *
176 * @since 4.0.0
177 */
178 add_filter( 'should_tutor_load_template', array( $this, 'skip_addon_only_question_partials' ), 5, 3 );
179 }
180
181 /**
182 * Skip loading templates that are not packaged with core Tutor LMS.
183 *
184 * @since 4.0.0
185 *
186 * @param bool $load Whether to load the template.
187 * @param string $template Template name in dot notation.
188 * @param array $variables Template variables.
189 *
190 * @return bool
191 */
192 public function skip_addon_only_question_partials( $load, $template, $variables ) {
193 $addons_only = apply_filters(
194 'tutor_quiz_templates_not_in_core',
195 array(
196 'learning-area.quiz.questions.draw-image',
197 'learning-area.quiz.questions.scale',
198 'learning-area.quiz.questions.pin-image',
199 'learning-area.quiz.questions.coordinates',
200 'learning-area.quiz.questions.puzzle',
201 'shared.components.quiz.attempt-details.questions.draw-image',
202 'shared.components.quiz.attempt-details.questions.scale',
203 'shared.components.quiz.attempt-details.questions.pin-image',
204 'shared.components.quiz.attempt-details.questions.coordinates',
205 'shared.components.quiz.attempt-details.questions.puzzle',
206 ),
207 $template,
208 $variables
209 );
210
211 if ( in_array( $template, $addons_only, true ) ) {
212 return false;
213 }
214
215 return $load;
216 }
217
218 /**
219 * Get quiz time units options.
220 *
221 * @since 2.6.0
222 *
223 * @return array
224 */
225 public static function quiz_time_units() {
226 $time_units = array(
227 'seconds' => __( 'Seconds', 'tutor' ),
228 'minutes' => __( 'Minutes', 'tutor' ),
229 'hours' => __( 'Hours', 'tutor' ),
230 'days' => __( 'Days', 'tutor' ),
231 'weeks' => __( 'Weeks', 'tutor' ),
232 );
233
234 return apply_filters( 'tutor_quiz_time_units', $time_units );
235 }
236
237 /**
238 * Get quiz default settings.
239 *
240 * @since 3.0.0
241 *
242 * @return array
243 */
244 public static function get_default_quiz_settings() {
245 $settings = array(
246 'time_limit' => array(
247 'time_type' => 'minutes',
248 'time_value' => 0,
249 ),
250 'attempts_allowed' => 10,
251 'answers_reveal_duration' => 5,
252 'auto_start_delay' => 5,
253 'enable_answer_reveal' => '0',
254 'enable_pagination' => '0',
255 'hide_question_number_overview' => 0,
256 'hide_previous_button' => '0',
257 'hide_quiz_time_display' => 0,
258 'limit_attempts_allowed' => '0',
259 'max_questions_for_answer' => 10,
260 'open_ended_answer_characters_limit' => 500,
261 'pass_is_required' => 0,
262 'pagination_type' => 'shape',
263 'passing_grade' => 80,
264 'question_layout_view' => '',
265 'questions_order' => 'rand',
266 'quiz_auto_start' => 0,
267 'short_answer_characters_limit' => 200,
268 );
269
270 return apply_filters( 'tutor_quiz_default_settings', $settings );
271 }
272
273 /**
274 * Normalize quiz settings.
275 *
276 * V4 stores reveal and pagination as explicit flags, but older quizzes may
277 * still contain legacy keys.
278 *
279 * @since 4.0.0
280 *
281 * @param array $settings Quiz settings.
282 *
283 * @return array
284 */
285 public static function normalize_quiz_settings( array $settings ): array {
286 $defaults = self::get_default_quiz_settings();
287 $has_enable_pagination = array_key_exists( 'enable_pagination', $settings );
288 $has_enable_answer_reveal = array_key_exists( 'enable_answer_reveal', $settings );
289 $has_limit_attempts_allowed = array_key_exists( 'limit_attempts_allowed', $settings );
290
291 $settings = wp_parse_args( $settings, $defaults );
292
293 $settings['time_limit'] = wp_parse_args(
294 is_array( $settings['time_limit'] ?? null ) ? $settings['time_limit'] : array(),
295 $defaults['time_limit']
296 );
297
298 $question_layout_view = (string) ( $settings['question_layout_view'] ?? '' );
299 $question_layout_view = '' !== $question_layout_view ? $question_layout_view : 'single_question';
300
301 $supported_pagination_types = array( 'shape', 'radio', 'number' );
302 $pagination_type = $settings['pagination_type'] ?? $settings['question_pagination_style'] ?? 'shape';
303 $pagination_type = in_array( $pagination_type, $supported_pagination_types, true ) ? $pagination_type : 'shape';
304
305 $enable_pagination = $has_enable_pagination
306 ? '1' === (string) $settings['enable_pagination']
307 : 'question_pagination' === $question_layout_view;
308
309 $enable_answer_reveal = $has_enable_answer_reveal
310 ? '1' === (string) $settings['enable_answer_reveal']
311 : self::QUIZ_FEEDBACK_MODE_REVEAL === (string) ( $settings['feedback_mode'] ?? '' );
312 $limit_attempts_allowed = $has_limit_attempts_allowed
313 ? '1' === (string) $settings['limit_attempts_allowed']
314 : self::QUIZ_FEEDBACK_MODE_RETRY === (string) ( $settings['feedback_mode'] ?? '' );
315
316 if ( 'question_pagination' === $question_layout_view ) {
317 $question_layout_view = 'single_question';
318 $enable_pagination = true;
319 }
320
321 if ( 'question_below_each_other' === $question_layout_view ) {
322 $settings['hide_question_number_overview'] = '0';
323 $enable_answer_reveal = false;
324 }
325
326 if ( $enable_pagination ) {
327 $settings['hide_previous_button'] = '0';
328 }
329
330 $settings['question_layout_view'] = $question_layout_view;
331 $settings['enable_pagination'] = $enable_pagination ? '1' : '0';
332 $settings['pagination_type'] = $pagination_type;
333 $settings['enable_answer_reveal'] = $enable_answer_reveal ? '1' : '0';
334 $settings['limit_attempts_allowed'] = $limit_attempts_allowed ? '1' : '0';
335
336 unset( $settings['feedback_mode'], $settings['question_pagination_style'] );
337
338 return apply_filters( 'tutor_quiz_normalized_settings', $settings );
339 }
340
341 /**
342 * Determine if quiz retry is allowed for the current attempt count.
343 *
344 * @since 4.0.0
345 *
346 * @param mixed $limit_attempts_allowed If attempts are limited.
347 * @param int $attempts_allowed Allowed attempts.
348 * @param int $attempted_count Attempted count.
349 *
350 * @return bool
351 */
352 public static function can_retry_quiz( $limit_attempts_allowed, int $attempts_allowed, int $attempted_count ): bool {
353 $limit_attempts_allowed = '1' === (string) $limit_attempts_allowed;
354
355 if ( ! $limit_attempts_allowed ) {
356 return false;
357 }
358
359 return 0 === $attempts_allowed || $attempted_count < $attempts_allowed;
360 }
361
362 /**
363 * Get effective attempt count for quiz display/runtime.
364 *
365 * @since 4.0.0
366 *
367 * @param bool $limit_attempts_allowed If attempts are limited.
368 * @param int $attempts_allowed Allowed attempts.
369 *
370 * @return int
371 */
372 public static function get_effective_attempts_allowed( bool $limit_attempts_allowed, int $attempts_allowed ): int {
373 return $limit_attempts_allowed ? $attempts_allowed : 1;
374 }
375
376 /**
377 * Get question default settings.
378 *
379 * @since 3.0.0
380 *
381 * @param string $type type of question.
382 *
383 * @return array
384 */
385 public static function get_default_question_settings( $type ) {
386 $settings = array(
387 'question_type' => $type,
388 'question_mark' => 1,
389 'answer_required' => 0,
390 'randomize_question' => 0,
391 'show_question_mark' => 0,
392 );
393
394 return apply_filters( 'tutor_question_default_settings', $settings );
395 }
396
397 /**
398 * Get quiz modes
399 *
400 * @since 2.6.0
401 *
402 * @return array
403 */
404 public static function quiz_modes() {
405 $modes = array(
406 array(
407 'key' => 'default',
408 'value' => __( 'Default', 'tutor' ),
409 'description' => __( 'Answers shown after quiz is finished', 'tutor' ),
410 ),
411 array(
412 'key' => 'reveal',
413 'value' => __( 'Reveal Mode', 'tutor' ),
414 'description' => __( 'Show result after the attempt.', 'tutor' ),
415 ),
416 array(
417 'key' => 'retry',
418 'value' => __( 'Retry Mode', 'tutor' ),
419 'description' => __( 'Reattempt quiz any number of times. Define Attempts Allowed below.', 'tutor' ),
420 ),
421 );
422
423 return apply_filters( 'tutor_quiz_modes', $modes );
424 }
425
426 /**
427 * Get quiz modes
428 *
429 * @since 2.6.0
430 *
431 * @return array
432 */
433 public static function quiz_question_layouts() {
434 $layouts = array(
435 '' => __( 'Set question layout view', 'tutor' ),
436 'single_question' => __( 'Single Question', 'tutor' ),
437 'question_pagination' => __( 'Question Pagination', 'tutor' ),
438 'question_below_each_other' => __( 'Question below each other', 'tutor' ),
439 );
440
441 return apply_filters( 'tutor_quiz_layouts', $layouts );
442 }
443
444 /**
445 * Get quiz modes
446 *
447 * @since 2.6.0
448 *
449 * @return array
450 */
451 public static function quiz_question_orders() {
452 $orders = array(
453 'rand' => __( 'Random', 'tutor' ),
454 'sorting' => __( 'Sorting', 'tutor' ),
455 'asc' => __( 'Ascending', 'tutor' ),
456 'desc' => __( 'Descending', 'tutor' ),
457 );
458
459 return apply_filters( 'tutor_quiz_layouts', $orders );
460 }
461
462 /**
463 * Prepare allowed HTML
464 *
465 * @since 1.0.0
466 *
467 * @return void
468 */
469 private function prepare_allowed_html() {
470
471 $allowed = array();
472
473 foreach ( $this->allowed_html as $tag ) {
474 $allowed[ $tag ] = $this->allowed_attributes;
475 }
476
477 $this->allowed_html = $allowed;
478 }
479
480 /**
481 * Instructor feedback ajax request handler
482 *
483 * @since 1.0.0
484 *
485 * @return void | send json response
486 */
487 public function tutor_instructor_feedback() {
488 tutor_utils()->checking_nonce();
489
490 // Check if user is privileged.
491 if ( ! User::has_any_role( array( User::ADMIN, User::INSTRUCTOR ) ) ) {
492 wp_send_json_error( tutor_utils()->error_message() );
493 }
494
495 $attempt_details = self::attempt_details( Input::post( 'attempt_id', 0, Input::TYPE_INT ) );
496 $feedback = Input::post( 'feedback', '', Input::TYPE_KSES_POST );
497 $attempt_info = isset( $attempt_details->attempt_info ) ? $attempt_details->attempt_info : false;
498 $course_id = tutor_utils()->avalue_dot( 'course_id', $attempt_details, 0 );
499 $is_instructor = tutor_utils()->is_instructor_of_this_course( get_current_user_id(), $course_id );
500 if ( ! current_user_can( 'manage_options' ) && ! $is_instructor ) {
501 wp_send_json_error( tutor_utils()->error_message() );
502 }
503
504 if ( $attempt_info ) {
505 //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
506 $unserialized = unserialize( $attempt_details->attempt_info );
507 if ( is_array( $unserialized ) ) {
508 $unserialized['instructor_feedback'] = $feedback;
509
510 //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
511 $update = self::update_attempt_info( $attempt_details->attempt_id, serialize( $unserialized ) );
512 if ( $update ) {
513 do_action( 'tutor_quiz/attempt/submitted/feedback', $attempt_details->attempt_id );
514 wp_send_json_success();
515 } else {
516 wp_send_json_error();
517 }
518 } else {
519 wp_send_json_error( __( 'Invalid quiz info', 'tutor' ) );
520 }
521 }
522 wp_send_json_error();
523 }
524
525 /**
526 * Start Quiz from here...
527 *
528 * @since 1.0.0
529 *
530 * @return void
531 */
532 public function start_the_quiz() {
533 if ( 'tutor_start_quiz' !== Input::post( 'tutor_action' ) ) {
534 return;
535 }
536
537 tutor_utils()->checking_nonce();
538
539 if ( ! is_user_logged_in() ) {
540 die( esc_html__( 'Please sign in to do this operation', 'tutor' ) );
541 }
542
543 $user_id = get_current_user_id();
544 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
545 $course = CourseModel::get_course_by_quiz( $quiz_id );
546
547 self::quiz_attempt( $course->ID, $quiz_id, $user_id );
548 wp_safe_redirect( get_permalink( $quiz_id ) );
549 die();
550 }
551
552 /**
553 * Manage quiz attempt
554 *
555 * @since 2.6.1
556 *
557 * @param integer $course_id course id.
558 * @param integer $quiz_id quiz id.
559 * @param integer $user_id user id.
560 * @param string $attempt_status attempt status.
561 *
562 * @return int inserted id|0
563 */
564 public static function quiz_attempt( int $course_id, int $quiz_id, int $user_id, $attempt_status = 'attempt_started' ) {
565 global $wpdb;
566
567 if ( ! $course_id ) {
568 die( 'There is something went wrong with course, please check if quiz attached with a course' );
569 }
570
571 do_action( 'tutor_quiz/start/before', $quiz_id, $user_id );
572
573 $date = date( 'Y-m-d H:i:s', tutor_time() ); //phpcs:ignore
574
575 $tutor_quiz_option = (array) tutor_utils()->get_quiz_option( $quiz_id );
576
577 $time_limit = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_value' );
578 $time_limit_seconds = 0;
579 $time_type = 'seconds';
580 if ( $time_limit ) {
581 $time_type = tutor_utils()->get_quiz_option( $quiz_id, 'time_limit.time_type' );
582
583 switch ( $time_type ) {
584 case 'seconds':
585 $time_limit_seconds = $time_limit;
586 break;
587 case 'minutes':
588 $time_limit_seconds = $time_limit * 60;
589 break;
590 case 'hours':
591 $time_limit_seconds = $time_limit * 60 * 60;
592 break;
593 case 'days':
594 $time_limit_seconds = $time_limit * 60 * 60 * 24;
595 break;
596 case 'weeks':
597 $time_limit_seconds = $time_limit * 60 * 60 * 24 * 7;
598 break;
599 }
600 }
601
602 $max_question_allowed = tutor_utils()->max_questions_for_take_quiz( $quiz_id );
603 $tutor_quiz_option['time_limit']['time_limit_seconds'] = $time_limit_seconds;
604
605 $attempt_data = array(
606 'course_id' => $course_id,
607 'quiz_id' => $quiz_id,
608 'user_id' => $user_id,
609 'total_questions' => $max_question_allowed,
610 'total_answered_questions' => 0,
611 'attempt_info' => maybe_serialize( $tutor_quiz_option ),
612 'attempt_status' => $attempt_status,
613 'attempt_ip' => tutor_utils()->get_ip(),
614 'attempt_started_at' => $date,
615 );
616
617 $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_data );
618 $attempt_id = (int) $wpdb->insert_id;
619
620 if ( $attempt_id ) {
621 do_action( 'tutor_quiz/start/after', $quiz_id, $user_id, $attempt_id );
622 return $attempt_id;
623 } else {
624 return 0;
625 }
626 }
627
628 /**
629 * Answering quiz
630 *
631 * @since 1.0.0
632 *
633 * @return void
634 */
635 public function answering_quiz() {
636 if ( 'tutor_answering_quiz_question' !== Input::post( 'tutor_action' ) ) {
637 return;
638 }
639
640 self::tutor_quiz_attempt_submit();
641
642 wp_safe_redirect( get_the_permalink() );
643 die();
644 }
645
646 /**
647 * Quiz abandon submission handler
648 *
649 * @since 1.9.6
650 *
651 * @return void JSON response
652 */
653 public function tutor_quiz_abandon() {
654 if ( 'tutor_answering_quiz_question' !== Input::post( 'tutor_action' ) ) {
655 return;
656 }
657
658 tutor_utils()->checking_nonce();
659 // submit quiz attempts.
660 if ( self::tutor_quiz_attempt_submit() ) {
661 wp_send_json_success();
662 } else {
663 wp_send_json_error();
664 }
665 }
666
667 /**
668 * Validate quiz attempt
669 *
670 * @since 3.9.13
671 *
672 * @param int $attempt_id attempt id.
673 * @param int $user_id user id.
674 *
675 * @return object|false attempt object if valid otherwise false
676 */
677 private static function validate_attempt( $attempt_id, $user_id ) {
678 $attempt = tutor_utils()->get_attempt( $attempt_id );
679
680 if ( ! $attempt || ! is_object( $attempt ) || (int) $attempt->user_id !== (int) $user_id ) {
681 return false;
682 }
683
684 return $attempt;
685 }
686
687 /**
688 * This is a unified method for handling normal quiz submit or abandon submit
689 * It will handle ajax or normal form submit and can be used with different hooks
690 *
691 * @since 1.9.6
692 *
693 * @return bool true if quiz attempt submit successfully otherwise false
694 */
695 public static function tutor_quiz_attempt_submit() {
696 if ( ! is_user_logged_in() ) {
697 die( 'Please sign in to do this operation' );
698 }
699
700 tutor_utils()->checking_nonce();
701
702 $user_id = get_current_user_id();
703 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
704 $attempt = self::validate_attempt( $attempt_id, $user_id );
705
706 if ( ! $attempt ) {
707 die( 'Operation not allowed, attempt not found or permission denied' );
708 }
709
710 if ( QuizModel::ATTEMPT_TIMEOUT === $attempt->attempt_status ) {
711 return false;
712 }
713
714 // Sanitize data by helper method.
715 $attempt_answers = isset( $_POST['attempt'] ) ? tutor_sanitize_data( $_POST['attempt'] ) : false; //phpcs:ignore
716 $attempt_answers = is_array( $attempt_answers ) ? $attempt_answers : array();
717
718 self::manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $attempt->course_id, $user_id );
719 return true;
720 }
721
722 /**
723 * Manage attempt answers
724 *
725 * Evaluate each attempt answer and update the attempts table & insert in the attempt_answers table.
726 *
727 * @since 2.6.1
728 *
729 * @param array $attempt_answers attempt answers.
730 * @param object $attempt single attempt.
731 * @param int $attempt_id attempt id.
732 * @param int $course_id course id.
733 * @param int $user_id user id.
734 *
735 * @return void
736 */
737 public static function manage_attempt_answers( $attempt_answers, $attempt, $attempt_id, $course_id, $user_id ) {
738 if ( ! is_array( $attempt_answers ) || ! self::validate_attempt( $attempt_id, $user_id ) ) {
739 return;
740 }
741
742 global $wpdb;
743 // Before hook.
744 do_action( 'tutor_quiz/attempt_analysing/before', $attempt_id );
745
746 // Single quiz can have multiple question. So multiple answer should be saved.
747 foreach ( $attempt_answers as $posted_attempt_id => $attempt_answer ) {
748 if ( ! self::validate_attempt( $posted_attempt_id, $user_id ) ) {
749 continue;
750 }
751
752 // Get total marks of all question comes.
753 $question_ids = tutor_utils()->avalue_dot( 'quiz_question_ids', $attempt_answer );
754 $question_ids = array_filter( $question_ids, fn ( $id ) => is_numeric( $id ) && intval( $id ) > 0 );
755
756 // Calculate and set the total marks in attempt table for this question.
757 if ( tutor_utils()->count( $question_ids ) ) {
758 $question_ids_string = QueryHelper::prepare_in_clause( $question_ids );
759
760 // Get total marks of the questions from question table.
761 //phpcs:disable
762 $query = $wpdb->prepare(
763 "SELECT SUM(question_mark)
764 FROM {$wpdb->prefix}tutor_quiz_questions
765 WHERE 1 = %d
766 AND question_id IN({$question_ids_string});
767 ",
768 1
769 );
770 $total_question_marks = $wpdb->get_var( $query );
771 //phpcs:enable
772
773 $total_question_marks = apply_filters( 'tutor_filter_update_before_question_mark', $total_question_marks, $question_ids, $user_id, $attempt_id );
774
775 // Set the the total mark in the attempt table for the question.
776 $wpdb->update(
777 $wpdb->prefix . 'tutor_quiz_attempts',
778 array( 'total_marks' => $total_question_marks ),
779 array( 'attempt_id' => $attempt_id )
780 );
781 }
782
783 $total_marks = 0;
784 $review_required = false;
785 $quiz_answers = tutor_utils()->avalue_dot( 'quiz_question', $attempt_answer );
786
787 if ( tutor_utils()->count( $quiz_answers ) ) {
788
789 foreach ( $quiz_answers as $question_id => $answers ) {
790 $question = QuizModel::get_quiz_question_by_id( $question_id );
791 $question_type = $question->question_type;
792
793 $is_answer_was_correct = false;
794 $given_answer = '';
795
796 if ( 'true_false' === $question_type || 'single_choice' === $question_type ) {
797 $given_answer = $answers;
798 $is_answer_was_correct = (bool) $wpdb->get_var(
799 $wpdb->prepare(
800 "SELECT is_correct
801 FROM {$wpdb->prefix}tutor_quiz_question_answers
802 WHERE answer_id = %d
803 ",
804 $answers
805 )
806 );
807
808 } elseif ( 'multiple_choice' === $question_type ) {
809
810 $given_answer = (array) ( $answers );
811 $given_answer = array_filter( $given_answer, fn ( $id ) => is_numeric( $id ) && intval( $id ) > 0 );
812
813 $get_original_answers = (array) $wpdb->get_col(
814 $wpdb->prepare(
815 "SELECT
816 answer_id
817 FROM
818 {$wpdb->prefix}tutor_quiz_question_answers
819 WHERE belongs_question_id = %d
820 AND belongs_question_type = %s
821 AND is_correct = 1 ;
822 ",
823 $question->question_id,
824 $question_type
825 )
826 );
827
828 if ( count( array_diff( $get_original_answers, $given_answer ) ) === 0 && count( $get_original_answers ) === count( $given_answer ) ) {
829 $is_answer_was_correct = true;
830 }
831 $given_answer = maybe_serialize( $answers );
832
833 } elseif ( 'fill_in_the_blank' === $question_type ) {
834
835 $get_original_answer = $wpdb->get_row(
836 $wpdb->prepare(
837 "SELECT *
838 FROM {$wpdb->prefix}tutor_quiz_question_answers
839 WHERE belongs_question_id = %d
840 AND belongs_question_type = %s ;
841 ",
842 $question->question_id,
843 $question_type
844 )
845 );
846
847 /**
848 * Answers stored in DB
849 */
850 $gap_answer = (array) explode( '|', $get_original_answer->answer_two_gap_match );
851 $gap_answer = maybe_serialize(
852 array_map(
853 function ( $ans ) {
854 return wp_slash( trim( $ans ) );
855 },
856 $gap_answer
857 )
858 );
859
860 /**
861 * Answers from user input
862 */
863 $given_answer = is_array( $answers ) ? array_map( 'sanitize_text_field', $answers ) : array( sanitize_text_field( $answers ) );
864 $given_answer = maybe_serialize( $given_answer );
865
866 /**
867 * Compare answer's by making both case-insensitive.
868 */
869 if ( strtolower( $given_answer ) === strtolower( $gap_answer ) ) {
870 $is_answer_was_correct = true;
871 }
872 } elseif ( 'open_ended' === $question_type || 'short_answer' === $question_type ) {
873 $review_required = true;
874 $given_answer = wp_kses_post( $answers );
875
876 } elseif ( 'ordering' === $question_type || 'matching' === $question_type || 'image_matching' === $question_type ) {
877 $answers = (array) tutor_utils()->avalue_dot( 'answers', $answers );
878
879 $given_answer = (array) array_map( 'sanitize_text_field', $answers );
880 $given_answer = maybe_serialize( $given_answer );
881
882 $get_original_answers = (array) $wpdb->get_col(
883 $wpdb->prepare(
884 "SELECT answer_id
885 FROM {$wpdb->prefix}tutor_quiz_question_answers
886 WHERE belongs_question_id = %d
887 AND belongs_question_type = %s
888 ORDER BY answer_order ASC ;
889 ",
890 $question->question_id,
891 $question_type
892 )
893 );
894
895 $get_original_answers = array_map( 'sanitize_text_field', $get_original_answers );
896
897 if ( maybe_serialize( $get_original_answers ) == $given_answer ) {
898 $is_answer_was_correct = true;
899 }
900 } elseif ( 'image_answering' === $question_type ) {
901 $image_inputs = tutor_utils()->avalue_dot( 'answer_id', $answers );
902 $image_inputs = (array) array_map( 'sanitize_text_field', $image_inputs );
903 $given_answer = maybe_serialize( $image_inputs );
904 $is_answer_was_correct = false;
905 /**
906 * For the image_answering question type result
907 * remain pending in spite of correct answer & required
908 * review of admin/instructor. Since it's
909 * pending we need to mark it as incorrect. Otherwise if
910 * mark it correct then earned mark will be updated. then
911 * again when instructor/admin review & mark it as correct
912 * extra mark is adding. In this case, student
913 * getting double mark for the same question.
914 *
915 * For now code is commenting will be removed later on
916 *
917 * @since 2.1.5
918 */
919
920 //phpcs:disable
921
922 // $db_answer = $wpdb->get_col(
923 // $wpdb->prepare(
924 // "SELECT answer_title
925 // FROM {$wpdb->prefix}tutor_quiz_question_answers
926 // WHERE belongs_question_id = %d
927 // AND belongs_question_type = 'image_answering'
928 // ORDER BY answer_order asc ;",
929 // $question_id
930 // )
931 // );
932
933 // if ( is_array( $db_answer ) && count( $db_answer ) ) {
934 // $is_answer_was_correct = ( strtolower( maybe_serialize( array_values( $image_inputs ) ) ) == strtolower( maybe_serialize( $db_answer ) ) );
935 // }
936 //phpcs:enable
937 } else {
938 $custom_answer_data = array(
939 'given_answer' => $given_answer,
940 'is_answer_was_correct' => $is_answer_was_correct,
941 );
942 $custom_answer_data = apply_filters( 'tutor_quiz_process_custom_question_answer', $custom_answer_data, $question_type, $answers, $question, $question_id, $attempt_id );
943 $given_answer = $custom_answer_data['given_answer'];
944 $is_answer_was_correct = $custom_answer_data['is_answer_was_correct'];
945 }
946
947 $question_mark = $is_answer_was_correct ? $question->question_mark : 0;
948 $total_marks += $question_mark;
949
950 $answers_data = array(
951 'user_id' => $user_id,
952 'quiz_id' => $attempt->quiz_id,
953 'question_id' => $question_id,
954 'quiz_attempt_id' => $attempt_id,
955 'given_answer' => $given_answer,
956 'question_mark' => $question->question_mark,
957 'achieved_mark' => $question_mark,
958 'minus_mark' => 0,
959 'is_correct' => $is_answer_was_correct ? 1 : 0,
960 );
961
962 /**
963 * Check if question_type open ended or short ans the set
964 * is_correct default value null before saving
965 */
966 if ( in_array( $question_type, array( 'open_ended', 'short_answer', 'image_answering' ) ) ) {
967 $answers_data['is_correct'] = null;
968 $review_required = true;
969 }
970
971 $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id );
972
973 // Filter total marks after grading. Runs after answers_data is built and graded,
974 // so add-ons (e.g. H5P, pin_image, draw_image) can add their achieved marks.
975 $total_marks = apply_filters( 'tutor_filter_quiz_total_marks', $total_marks, $question_id, $question_type, $user_id, $attempt_id, $answers_data );
976
977 $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data );
978 }
979 }
980
981 $attempt_info = array(
982 'total_answered_questions' => tutor_utils()->count( $quiz_answers ),
983 'earned_marks' => $total_marks,
984 'attempt_status' => 'attempt_ended',
985 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
986 );
987
988 if ( $review_required ) {
989 $attempt_info['attempt_status'] = 'review_required';
990 }
991
992 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_info, array( 'attempt_id' => $attempt_id ) );
993
994 QuizModel::update_attempt_result( $attempt_id );
995 }
996
997 // After hook.
998 do_action( 'tutor_quiz/attempt_ended', $attempt_id, $course_id, $user_id );
999 }
1000
1001
1002 /**
1003 * Quiz attempt will be finish here
1004 *
1005 * @since 1.0.0
1006 *
1007 * @return void
1008 */
1009 public function finishing_quiz_attempt() {
1010
1011 if ( Input::post( 'tutor_action' ) !== 'tutor_finish_quiz_attempt' ) {
1012 return;
1013 }
1014 // Checking nonce.
1015 tutor_utils()->checking_nonce();
1016
1017 if ( ! is_user_logged_in() ) {
1018 die( 'Please sign in to do this operation' );
1019 }
1020
1021 global $wpdb;
1022
1023 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1024 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
1025 $attempt_id = $attempt->attempt_id;
1026
1027 $attempt_info = array(
1028 'total_answered_questions' => 0,
1029 'earned_marks' => 0,
1030 'attempt_status' => 'attempt_ended',
1031 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
1032 );
1033
1034 do_action( 'tutor_quiz_before_finish', $attempt_id, $quiz_id, $attempt->user_id );
1035 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_info, array( 'attempt_id' => $attempt_id ) );
1036 do_action( 'tutor_quiz_finished', $attempt_id, $quiz_id, $attempt->user_id );
1037
1038 wp_redirect( tutor_utils()->input_old( '_wp_http_referer' ) );
1039 }
1040
1041 /**
1042 * Get quiz total marks.
1043 *
1044 * @since 3.0.0
1045 *
1046 * @param int $quiz_id quiz id.
1047 *
1048 * @return int|float
1049 */
1050 public static function get_quiz_total_marks( $quiz_id ) {
1051 global $wpdb;
1052
1053 $total_marks = $wpdb->get_var(
1054 $wpdb->prepare(
1055 "SELECT SUM(question_mark) total_marks
1056 FROM {$wpdb->prefix}tutor_quiz_questions
1057 WHERE quiz_id=%d",
1058 $quiz_id
1059 )
1060 );
1061
1062 return floatval( $total_marks );
1063 }
1064
1065 /**
1066 * Quiz timeout by ajax
1067 *
1068 * @since 1.0.0
1069 *
1070 * @return void
1071 */
1072 public function tutor_quiz_timeout() {
1073 tutils()->checking_nonce();
1074
1075 global $wpdb;
1076
1077 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1078 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
1079
1080 if ( $attempt ) {
1081 $attempt_id = $attempt->attempt_id;
1082
1083 $data = array(
1084 'attempt_status' => 'attempt_timeout',
1085 'total_marks' => self::get_quiz_total_marks( $quiz_id ),
1086 'earned_marks' => 0,
1087 'attempt_ended_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ),
1088 );
1089
1090 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $data, array( 'attempt_id' => $attempt->attempt_id ) );
1091
1092 do_action( 'tutor_quiz_timeout', $attempt_id, $quiz_id, $attempt->user_id );
1093
1094 wp_send_json_success();
1095 }
1096
1097 wp_send_json_error( __( 'Quiz has been timeout already', 'tutor' ) );
1098 }
1099
1100 /**
1101 * Review quiz answer
1102 *
1103 * @since 1.0.0
1104 *
1105 * @return void
1106 */
1107 public function review_quiz_answer() {
1108
1109 tutor_utils()->checking_nonce();
1110
1111 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
1112 $context = Input::post( 'context' );
1113 $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT );
1114 $question_id = Input::post( 'question_id', 0, Input::TYPE_INT );
1115 $mark_as = Input::post( 'mark_as' );
1116
1117 if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) {
1118 wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
1119 }
1120
1121 if ( $attempt_answer_id && ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer_id ) ) {
1122 wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
1123 }
1124
1125 $attempt_answer = $this->resolve_attempt_answer_for_review( $attempt_id, $attempt_answer_id, $question_id );
1126 $review_data = $attempt_answer ? $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as ) : null;
1127
1128 if ( ! $review_data ) {
1129 wp_send_json_error( array( 'message' => __( 'Review update failed', 'tutor' ) ) );
1130 }
1131
1132 QuizModel::update_attempt_result( $attempt_id );
1133
1134 ob_start();
1135 tutor_load_template_from_custom_path(
1136 tutor()->path . '/views/quiz/attempt-details.php',
1137 array(
1138 'attempt_id' => $attempt_id,
1139 'user_id' => $review_data['student_id'],
1140 'context' => $context,
1141 'back_url' => Input::post( 'back_url' ),
1142 )
1143 );
1144 wp_send_json_success( array( 'html' => ob_get_clean() ) );
1145 }
1146
1147 /**
1148 * Review quiz answers in bulk for v4 dashboard flow.
1149 *
1150 * @since 4.0.0
1151 *
1152 * @return void
1153 */
1154 public function review_quiz_answers() {
1155 tutor_utils()->checking_nonce();
1156
1157 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
1158 $review_statuses = Input::post( 'review_statuses', array(), Input::TYPE_ARRAY );
1159
1160 $this->review_quiz_answers_bulk( $attempt_id, $review_statuses );
1161 }
1162
1163 /**
1164 * Review quiz answers in bulk for v4 dashboard flow.
1165 *
1166 * @since 4.0.0
1167 *
1168 * @param int $attempt_id Attempt ID.
1169 * @param array $review_statuses Review statuses keyed by question ID.
1170 *
1171 * @return void
1172 */
1173 private function review_quiz_answers_bulk( int $attempt_id, array $review_statuses ) {
1174 if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) {
1175 $this->response_fail( __( 'Access Denied', 'tutor' ), 403 );
1176 }
1177
1178 $attempt_answers = QuizModel::get_quiz_answers_by_attempt_id( $attempt_id );
1179 $answers_by_question_id = array();
1180
1181 if ( is_array( $attempt_answers ) ) {
1182 foreach ( $attempt_answers as $attempt_answer ) {
1183 $question_id = (int) ( $attempt_answer->question_id ?? 0 );
1184
1185 if ( $question_id > 0 ) {
1186 $answers_by_question_id[ $question_id ] = $attempt_answer;
1187 }
1188 }
1189 }
1190
1191 foreach ( $review_statuses as $question_id => $mark_as ) {
1192 $question_id = (int) $question_id;
1193 $mark_as = (string) $mark_as;
1194
1195 if ( ! in_array( $mark_as, array( 'correct', 'incorrect' ), true ) ) {
1196 continue;
1197 }
1198
1199 $attempt_answer = $answers_by_question_id[ $question_id ] ?? null;
1200
1201 if ( ! $attempt_answer ) {
1202 $attempt_answer = $this->resolve_attempt_answer_for_review( $attempt_id, 0, $question_id );
1203 }
1204
1205 if ( ! $attempt_answer ) {
1206 continue;
1207 }
1208
1209 $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as );
1210 }
1211
1212 QuizModel::update_attempt_result( $attempt_id );
1213
1214 $this->response_success( __( 'Review updated successfully', 'tutor' ) );
1215 }
1216
1217 /**
1218 * Get attempt answer record by ID.
1219 *
1220 * @since 4.0.0
1221 *
1222 * @param int $attempt_answer_id Attempt answer ID.
1223 *
1224 * @return object|null
1225 */
1226 public static function get_attempt_answer( int $attempt_answer_id ) {
1227 global $wpdb;
1228
1229 return $wpdb->get_row(
1230 $wpdb->prepare(
1231 "SELECT *
1232 FROM {$wpdb->prefix}tutor_quiz_attempt_answers
1233 WHERE attempt_answer_id = %d",
1234 $attempt_answer_id
1235 )
1236 );
1237 }
1238
1239 /**
1240 * Get attempt answer by attempt and question IDs.
1241 *
1242 * @since 4.0.0
1243 *
1244 * @param int $attempt_id Attempt ID.
1245 * @param int $question_id Question ID.
1246 *
1247 * @return object|null
1248 */
1249 private function get_attempt_answer_by_attempt_and_question( int $attempt_id, int $question_id ) {
1250 if ( $attempt_id <= 0 || $question_id <= 0 ) {
1251 return null;
1252 }
1253
1254 return QueryHelper::get_row(
1255 'tutor_quiz_attempt_answers',
1256 array(
1257 'quiz_attempt_id' => $attempt_id,
1258 'question_id' => $question_id,
1259 ),
1260 'attempt_answer_id',
1261 'ASC'
1262 );
1263 }
1264
1265 /**
1266 * Create a placeholder attempt answer for a skipped question.
1267 *
1268 * @since 4.0.0
1269 *
1270 * @param int $attempt_id Attempt ID.
1271 * @param int $question_id Question ID.
1272 *
1273 * @return object|null
1274 */
1275 private function create_skipped_attempt_answer( int $attempt_id, int $question_id ) {
1276 $attempt = tutor_utils()->get_attempt( $attempt_id );
1277 if ( ! $attempt ) {
1278 return null;
1279 }
1280
1281 $question = QuizModel::get_quiz_question_by_id( $question_id );
1282 if ( ! $question || (int) $question->quiz_id !== (int) $attempt->quiz_id ) {
1283 return null;
1284 }
1285
1286 $attempt_answer = $this->get_attempt_answer_by_attempt_and_question( $attempt_id, $question_id );
1287 if ( $attempt_answer ) {
1288 return $attempt_answer;
1289 }
1290
1291 try {
1292 $inserted_id = QueryHelper::insert(
1293 'tutor_quiz_attempt_answers',
1294 array(
1295 'user_id' => (int) $attempt->user_id,
1296 'quiz_id' => (int) $attempt->quiz_id,
1297 'question_id' => $question_id,
1298 'quiz_attempt_id' => $attempt_id,
1299 'given_answer' => '',
1300 'question_mark' => $question->question_mark,
1301 'achieved_mark' => 0,
1302 'minus_mark' => 0,
1303 'is_correct' => 0,
1304 )
1305 );
1306 } catch ( \Exception $exception ) {
1307 return null;
1308 }
1309
1310 if ( $inserted_id <= 0 ) {
1311 return null;
1312 }
1313
1314 return self::get_attempt_answer( $inserted_id );
1315 }
1316
1317 /**
1318 * Resolve the attempt answer used for instructor review.
1319 *
1320 * @since 4.0.0
1321 *
1322 * @param int $attempt_id Attempt ID.
1323 * @param int $attempt_answer_id Attempt answer ID.
1324 * @param int $question_id Question ID.
1325 *
1326 * @return object|null
1327 */
1328 private function resolve_attempt_answer_for_review( int $attempt_id, int $attempt_answer_id = 0, int $question_id = 0 ) {
1329 $attempt_answer = $attempt_answer_id ? self::get_attempt_answer( $attempt_answer_id ) : null;
1330
1331 if ( $attempt_answer ) {
1332 return $attempt_answer;
1333 }
1334
1335 if ( $question_id <= 0 ) {
1336 return null;
1337 }
1338
1339 $attempt_answer = $this->get_attempt_answer_by_attempt_and_question( $attempt_id, $question_id );
1340
1341 if ( $attempt_answer ) {
1342 return $attempt_answer;
1343 }
1344
1345 return $this->create_skipped_attempt_answer( $attempt_id, $question_id );
1346 }
1347
1348 /**
1349 * Apply quiz answer review update.
1350 *
1351 * @since 4.0.0
1352 *
1353 * @param int $attempt_id Attempt ID.
1354 * @param object $attempt_answer Attempt answer row.
1355 * @param string $mark_as Review status.
1356 *
1357 * @return array|null
1358 */
1359 private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, string $mark_as ) {
1360 global $wpdb;
1361
1362 if ( ! $attempt_answer || ! in_array( $mark_as, array( 'correct', 'incorrect' ), true ) ) {
1363 return null;
1364 }
1365
1366 $attempt = tutor_utils()->get_attempt( $attempt_id );
1367
1368 if ( ! $attempt ) {
1369 return null;
1370 }
1371
1372 $attempt_answer_id = (int) $attempt_answer->attempt_answer_id;
1373 $question = QuizModel::get_quiz_question_by_id( $attempt_answer->question_id );
1374 $course_id = (int) $attempt->course_id;
1375 $student_id = (int) $attempt->user_id;
1376 $previous_ans = $attempt_answer->is_correct;
1377
1378 do_action( 'tutor_quiz_review_answer_before', $attempt_answer_id, $attempt_id, $mark_as );
1379
1380 $mark_as = apply_filters( 'tutor_quiz_review_mark_as', $mark_as, $attempt_answer_id, $attempt_id, $question );
1381
1382 if ( 'correct' === $mark_as ) {
1383 $attempt_update_data = array();
1384 $answer_update_data = array(
1385 'achieved_mark' => $attempt_answer->question_mark,
1386 'is_correct' => 1,
1387 );
1388
1389 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
1390
1391 if ( 0 == $previous_ans || null == $previous_ans ) {
1392 $attempt_update_data = array(
1393 'earned_marks' => $attempt->earned_marks + $attempt_answer->question_mark,
1394 'is_manually_reviewed' => 1,
1395 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
1396 );
1397 }
1398
1399 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
1400 $attempt_update_data['attempt_status'] = 'attempt_ended';
1401 }
1402
1403 if ( ! empty( $attempt_update_data ) ) {
1404 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
1405 }
1406 } elseif ( 'incorrect' === $mark_as ) {
1407 $attempt_update_data = array();
1408 $answer_update_data = array(
1409 'achieved_mark' => '0.00',
1410 'is_correct' => 0,
1411 );
1412
1413 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
1414
1415 if ( 1 == $previous_ans ) {
1416 $attempt_update_data = array(
1417 'earned_marks' => $attempt->earned_marks - $attempt_answer->question_mark,
1418 'is_manually_reviewed' => 1,
1419 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
1420 );
1421 }
1422
1423 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
1424 $attempt_update_data['attempt_status'] = 'attempt_ended';
1425 }
1426
1427 if ( ! empty( $attempt_update_data ) ) {
1428 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
1429 }
1430 }
1431
1432 do_action( 'tutor_quiz_review_answer_after', $attempt_answer_id, $attempt_id, $mark_as );
1433 do_action( 'tutor_quiz/answer/review/after', $attempt_answer_id, $course_id, $student_id );
1434
1435 return array(
1436 'course_id' => $course_id,
1437 'student_id' => $student_id,
1438 );
1439 }
1440
1441 /**
1442 * Do auto course complete after review a quiz attempt.
1443 *
1444 * @since 2.4.0
1445 *
1446 * @param int $attempt_answer_id attempt answer id.
1447 * @param int $course_id course id.
1448 * @param int $user_id student id.
1449 *
1450 * @return void
1451 */
1452 public function do_auto_course_complete( $attempt_answer_id, $course_id, $user_id ) {
1453 if ( CourseModel::can_autocomplete_course( $course_id, $user_id ) ) {
1454 CourseModel::mark_course_as_completed( $course_id, $user_id );
1455 Course::set_review_popup_data( $user_id, $course_id );
1456 }
1457 }
1458
1459 /**
1460 * Get a quiz details by id
1461 *
1462 * @return void
1463 */
1464 public function ajax_quiz_details() {
1465 tutor_utils()->check_nonce();
1466
1467 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1468 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1469 $this->json_response(
1470 tutor_utils()->error_message(),
1471 null,
1472 HttpHelper::STATUS_FORBIDDEN
1473 );
1474 }
1475
1476 $data = QuizModel::get_quiz_details( $quiz_id );
1477
1478 $data = apply_filters( 'tutor_quiz_details_response', $data, $quiz_id );
1479
1480 $this->json_response(
1481 __( 'Quiz data fetched successfully', 'tutor' ),
1482 $data
1483 );
1484 }
1485
1486 /**
1487 * Delete quiz by id
1488 *
1489 * @since 1.0.0
1490 * @since 3.0.0 refactor and response change.
1491 *
1492 * @return void
1493 */
1494 public function ajax_quiz_delete() {
1495 if ( ! tutor_utils()->is_nonce_verified() ) {
1496 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
1497 }
1498
1499 global $wpdb;
1500
1501 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1502 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1503 $this->json_response(
1504 tutor_utils()->error_message(),
1505 null,
1506 HttpHelper::STATUS_FORBIDDEN
1507 );
1508 }
1509
1510 $post = get_post( $quiz_id );
1511 if ( 'tutor_quiz' !== $post->post_type ) {
1512 $this->json_response(
1513 __( 'Invalid quiz', 'tutor' ),
1514 null,
1515 HttpHelper::STATUS_BAD_REQUEST
1516 );
1517 }
1518
1519 do_action( 'tutor_delete_quiz_before', $quiz_id );
1520
1521 // Collect file paths from all question types that store files before deleting rows (files deleted after DB for safety).
1522 $attempts_for_quiz = QueryHelper::get_all( 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ), 'attempt_id', -1 );
1523 $attempt_file_paths = array();
1524 if ( ! empty( $attempts_for_quiz ) ) {
1525 $attempt_ids = array_map(
1526 function ( $row ) {
1527 return (int) $row->attempt_id;
1528 },
1529 $attempts_for_quiz
1530 );
1531 $attempt_file_paths = apply_filters( 'tutor_quiz/attempt_file_paths_for_deletion', array(), $attempt_ids );
1532 $attempt_file_paths = is_array( $attempt_file_paths ) ? array_values( array_filter( array_unique( $attempt_file_paths ) ) ) : array();
1533 }
1534
1535 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ) );
1536 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $quiz_id ) );
1537
1538 QuizModel::delete_files_by_paths( $attempt_file_paths );
1539
1540 // Collect instructor file paths before deleting question data (e.g. draw_image / pin_image masks).
1541 /**
1542 * Filter to get file paths for quiz deletion.
1543 * Pro and other add-ons register their question types via this filter.
1544 *
1545 * @param string[] $file_paths Paths collected so far.
1546 * @param int $quiz_id Quiz post ID.
1547 */
1548 $quiz_file_paths = apply_filters( 'tutor_quiz_quiz_file_paths_for_deletion', array(), $quiz_id );
1549
1550 $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $quiz_id ) );
1551
1552 if ( is_array( $questions_ids ) && count( $questions_ids ) ) {
1553 $in_question_ids = QueryHelper::prepare_in_clause( $questions_ids );
1554 //phpcs:disable
1555 $wpdb->query(
1556 "DELETE
1557 FROM {$wpdb->prefix}tutor_quiz_question_answers
1558 WHERE belongs_question_id IN({$in_question_ids})
1559 "
1560 );
1561 //phpcs:enable
1562 }
1563
1564 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $quiz_id ) );
1565
1566 QuizModel::delete_files_by_paths( $quiz_file_paths );
1567
1568 wp_delete_post( $quiz_id, true );
1569
1570 do_action( 'tutor_delete_quiz_after', $quiz_id );
1571
1572 $this->json_response(
1573 __( 'Quiz deleted successfully', 'tutor' ),
1574 $quiz_id
1575 );
1576 }
1577
1578 /**
1579 * Get answers by quiz id
1580 *
1581 * @since 1.0.0
1582 *
1583 * @param int $question_id question id.
1584 * @param mixed $question_type type of question.
1585 * @param boolean $is_correct only correct answers or not.
1586 *
1587 * @return wpdb:get_results
1588 */
1589 private function get_answers_by_q_id( $question_id, $question_type, $is_correct = false ) {
1590 global $wpdb;
1591
1592 $correct_clause = $is_correct ? ' AND is_correct=1 ' : '';
1593 //phpcs:disable
1594 return $wpdb->get_results(
1595 $wpdb->prepare(
1596 "SELECT * FROM {$wpdb->prefix}tutor_quiz_question_answers
1597 WHERE belongs_question_id = %d
1598 AND belongs_question_type = %s
1599 {$correct_clause}
1600 ORDER BY answer_order ASC;
1601 ",
1602 $question_id,
1603 esc_sql( $question_type )
1604 )
1605 );
1606 //phpcs:enable
1607 }
1608
1609 /**
1610 * Rendering quiz for frontend
1611 *
1612 * @since 1.0.0
1613 *
1614 * @return void send wp_json response
1615 */
1616 public function tutor_render_quiz_content() {
1617
1618 tutor_utils()->checking_nonce();
1619
1620 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1621
1622 if ( ! tutor_utils()->has_enrolled_content_access( 'quiz', $quiz_id ) ) {
1623 wp_send_json_error( array( 'message' => __( 'Access Denied.', 'tutor' ) ) );
1624 }
1625
1626 ob_start();
1627 global $post;
1628
1629 $post = get_post( $quiz_id ); //phpcs:ignore
1630 setup_postdata( $post );
1631
1632 single_quiz_contents();
1633 wp_reset_postdata();
1634
1635 $html = ob_get_clean();
1636 wp_send_json_success( array( 'html' => $html ) );
1637 }
1638
1639 /**
1640 * Get attempt details
1641 *
1642 * @since 1.0.0
1643 *
1644 * @param int $attempt_id required attempt id to get details.
1645 *
1646 * @return mixed object on success, null on failure
1647 */
1648 public static function attempt_details( int $attempt_id ) {
1649 global $wpdb;
1650 $attempt_details = $wpdb->get_row(
1651 $wpdb->prepare(
1652 "SELECT *
1653 FROM {$wpdb->prefix}tutor_quiz_attempts
1654 WHERE attempt_id = %d
1655 ",
1656 $attempt_id
1657 )
1658 );
1659 return $attempt_details;
1660 }
1661
1662 /**
1663 * Update quiz attempt info
1664 *
1665 * @since 1.0.0
1666 *
1667 * @param int $attempt_id attempt id.
1668 * @param mixed $attempt_info serialize data.
1669 *
1670 * @return bool, true on success, false on failure
1671 */
1672 public static function update_attempt_info( int $attempt_id, $attempt_info ) {
1673 global $wpdb;
1674 $table = $wpdb->prefix . 'tutor_quiz_attempts';
1675 $update_info = $wpdb->update(
1676 $table,
1677 array( 'attempt_info' => $attempt_info ),
1678 array( 'attempt_id' => $attempt_id )
1679 );
1680 return $update_info ? true : false;
1681 }
1682
1683 /**
1684 * Attempt delete ajax request handler
1685 *
1686 * @since 2.1.0
1687 *
1688 * @return void wp_json response
1689 */
1690 public function attempt_delete() {
1691 tutor_utils()->checking_nonce();
1692
1693 $attempt_id = Input::post( 'id', 0, Input::TYPE_INT );
1694 $attempt = tutor_utils()->get_attempt( $attempt_id );
1695 if ( ! $attempt ) {
1696 wp_send_json_error( __( 'Invalid attempt ID', 'tutor' ) );
1697 }
1698
1699 $user_id = get_current_user_id();
1700 $course_id = $attempt->course_id;
1701
1702 if ( tutor_utils()->can_user_edit_course( $user_id, $course_id ) ) {
1703 QuizModel::delete_quiz_attempt( $attempt_id );
1704 wp_send_json_success( __( 'Attempt deleted successfully!', 'tutor' ) );
1705 } else {
1706 wp_send_json_error( tutor_utils()->error_message() );
1707 }
1708 }
1709
1710 /**
1711 * Get all quiz attempts for a user in a specific course.
1712 *
1713 * @since 3.8.1
1714 *
1715 * @param int $course_id The ID of the course.
1716 *
1717 * @return array Returns an array of quiz attempt objects with their answers, or an empty array on error.
1718 */
1719 public function get_quiz_attempts_and_answers_by_course_id( int $course_id ): array {
1720 global $wpdb;
1721
1722 $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempts, array( 'course_id' => $course_id ), 'course_id', -1 );
1723
1724 if ( empty( $results ) ) {
1725 return array();
1726 }
1727
1728 return array_map(
1729 function ( $item ) {
1730 $item->quiz_attempt_answers = $this->get_quiz_attempt_answers_by_attempt_id( $item->attempt_id );
1731 return $item;
1732 },
1733 $results
1734 );
1735 }
1736
1737 /**
1738 * Get all quiz attempt answers for a specific quiz attempt.
1739 *
1740 * @since 3.8.1
1741 *
1742 * @param int $attempt_id The ID of the quiz attempt.
1743 *
1744 * @return array Returns an array of quiz attempt answers objects, or an empty array on error.
1745 */
1746 private function get_quiz_attempt_answers_by_attempt_id( int $attempt_id ): array {
1747 global $wpdb;
1748
1749 $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempt_answers, array( 'quiz_attempt_id' => $attempt_id ), 'quiz_attempt_id', -1 );
1750
1751 if ( empty( $results ) ) {
1752 return array();
1753 }
1754
1755 return $results;
1756 }
1757
1758 /**
1759 * Render quiz title as nav item to show on the learning area
1760 *
1761 * @since 4.0.0
1762 *
1763 * @param WP_Post $quiz Quiz post object.
1764 * @param bool $can_access Can user access this content.
1765 *
1766 * @return void
1767 */
1768 public function render_nav_item( WP_Post $quiz, bool $can_access ): void {
1769 tutor_load_template(
1770 'learning-area.quiz.nav-item',
1771 array(
1772 'quiz' => $quiz,
1773 'can_access' => $can_access,
1774 )
1775 );
1776 }
1777
1778 /**
1779 * Render content for the a single quiz
1780 *
1781 * @since 4.0.0
1782 *
1783 * @param WP_Post $quiz Quiz post object.
1784 *
1785 * @return void
1786 */
1787 public function render_single_content( WP_Post $quiz ): void {
1788 tutor_load_template(
1789 'learning-area.quiz.content',
1790 array(
1791 'quiz' => $quiz,
1792 )
1793 );
1794 }
1795
1796 /**
1797 * Render quiz summary
1798 *
1799 * @since 4.0.0
1800 *
1801 * @param int $total_questions Total questions.
1802 * @param string $quiz_item_readable Readable time.
1803 * @param int $total_marks Total Marks.
1804 * @param string $passing_grade Passing grade.
1805 * @param string $earned_marks Earned marks.
1806 * @param string $attempts_allowed Total Attempts allowed.
1807 *
1808 * @return void
1809 */
1810 public static function render_quiz_summary( $total_questions, $quiz_item_readable, $total_marks, $passing_grade, $earned_marks, $attempts_allowed ) {
1811 $quiz_summary = array(
1812 array(
1813 'columns' => array(
1814 array(
1815 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1816 ' . SvgIcon::make()->name( Icon::QUESTION_CIRCLE )->size( 20 )->get() . __( 'Questions', 'tutor' ) . '
1817 </div>',
1818 ),
1819 array( 'content' => $total_questions ),
1820 ),
1821 ),
1822 );
1823
1824 if ( ! empty( $quiz_item_readable ) ) {
1825 $quiz_summary[] = array(
1826 'columns' => array(
1827 array(
1828 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1829 ' . SvgIcon::make()->name( Icon::TIME )->size( 20 )->get() . __( 'Quiz Time', 'tutor' ) . '
1830 </div>',
1831 ),
1832 array( 'content' => $quiz_item_readable ),
1833 ),
1834 );
1835 }
1836
1837 $quiz_summary[] = array(
1838 'columns' => array(
1839 array(
1840 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1841 ' . SvgIcon::make()->name( Icon::PRIME_CHECK_CIRCLE )->size( 20 )->get() . __( 'Total Marks', 'tutor' ) . '
1842 </div>',
1843 ),
1844 array( 'content' => $total_marks ),
1845 ),
1846 );
1847
1848 $quiz_summary[] = array(
1849 'columns' => array(
1850 array(
1851 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1852 ' . SvgIcon::make()->name( Icon::PASSED )->size( 20 )->get() . __( 'Passing Grade', 'tutor' ) . '
1853 </div>',
1854 ),
1855 array( 'content' => $passing_grade . '%' ),
1856 ),
1857 );
1858
1859 if ( $earned_marks ) {
1860 $quiz_summary[] = array(
1861 'columns' => array(
1862 array(
1863 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1864 ' . SvgIcon::make()->name( Icon::STAR )->size( 20 )->get() . __( 'Earned Grade', 'tutor' ) . '
1865 </div>',
1866 ),
1867 array( 'content' => $earned_marks . '%' ),
1868 ),
1869 );
1870 }
1871
1872 if ( 1 !== $attempts_allowed ) {
1873 $quiz_summary[] = array(
1874 'columns' => array(
1875 array(
1876 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1877 ' . SvgIcon::make()->name( Icon::TARGET )->size( 20 )->get() . __( 'Total Attempts', 'tutor' ) . '
1878 </div>',
1879 ),
1880 array( 'content' => 0 === $attempts_allowed ? __( 'No Limit', 'tutor' ) : $attempts_allowed ),
1881 ),
1882 );
1883 }
1884
1885 Table::make()->contents( $quiz_summary )->render();
1886 }
1887
1888 /**
1889 * Render quiz attempts
1890 *
1891 * @since 4.0.0
1892 *
1893 * @param int $quiz_id Quiz ID.
1894 *
1895 * @return void
1896 */
1897 public static function render_quiz_attempts( $quiz_id ) {
1898 $quiz_id = tutor_utils()->get_post_id( $quiz_id );
1899 if ( ! $quiz_id ) {
1900 return;
1901 }
1902
1903 $user_id = get_current_user_id();
1904 $quiz_model = new QuizModel();
1905 $attempts = $quiz_model->quiz_attempts( $quiz_id, $user_id );
1906
1907 if ( empty( $attempts ) ) {
1908 return;
1909 }
1910
1911 $attempts_list = QuizModel::format_quiz_attempts( $attempts, '' );
1912
1913 if ( empty( $attempts_list ) ) {
1914 return;
1915 }
1916
1917 $attempts_count = count( $attempts_list );
1918 $quiz_attempt_obj = new Quiz_Attempts_List( false );
1919 ?>
1920 <div class="tutor-quiz-attempts tutor-border tutor-rounded-2xl">
1921 <div class="tutor-quiz-attempts-header">
1922 <div class="tutor-quiz-attempts-header-item">
1923 <?php esc_html_e( 'Attempts', 'tutor' ); ?>
1924 </div>
1925 <div class="tutor-quiz-attempts-header-item">
1926 <?php esc_html_e( 'Marks', 'tutor' ); ?>
1927 </div>
1928 <div class="tutor-quiz-attempts-header-item">
1929 <?php esc_html_e( 'Time', 'tutor' ); ?>
1930 </div>
1931 <div class="tutor-quiz-attempts-header-item">
1932 <?php esc_html_e( 'Result', 'tutor' ); ?>
1933 </div>
1934 </div>
1935
1936 <div class="tutor-quiz-attempts-list">
1937 <?php
1938 foreach ( $attempts_list as $index => $attempt ) {
1939 $attempt_number = $attempts_count - $index;
1940 ?>
1941 <div class="tutor-quiz-attempts-item-wrapper">
1942 <?php
1943 tutor_load_template(
1944 'shared.components.student-quiz-attempt-row',
1945 array(
1946 'attempt' => $attempt,
1947 'attempt_number' => $attempt_number,
1948 'quiz_id' => $attempt['quiz_id'] ?? 0,
1949 'course_id' => $attempt['course_id'] ?? 0,
1950 'quiz_attempt_obj' => $quiz_attempt_obj,
1951 'is_previous' => false,
1952 'is_learning_area' => true,
1953 )
1954 );
1955 ?>
1956 </div>
1957 <?php
1958 }
1959 ?>
1960 </div>
1961 </div>
1962 <?php
1963 }
1964
1965 /**
1966 * Render quiz actions
1967 *
1968 * @since 4.0.0
1969 *
1970 * @param int $quiz_id Quiz ID.
1971 *
1972 * @return void
1973 */
1974 public static function render_quiz_actions( $quiz_id ) {
1975 $quiz_id = tutor_utils()->get_post_id( $quiz_id );
1976 if ( ! $quiz_id ) {
1977 return;
1978 }
1979
1980 $quiz_settings = tutor_utils()->get_quiz_option( $quiz_id );
1981 $user_id = get_current_user_id();
1982 $quiz_model = new QuizModel();
1983 $attempts = $quiz_model->quiz_attempts( $quiz_id, $user_id );
1984 $attempted_count = (int) tutor_utils()->count( $attempts );
1985 $limit_attempts_allowed = '1' === (string) ( $quiz_settings['limit_attempts_allowed'] ?? '0' );
1986 $attempts_allowed = (int) ( $quiz_settings['attempts_allowed'] ?? 0 );
1987 $can_start_quiz = 0 === $attempted_count || self::can_retry_quiz( $limit_attempts_allowed, $attempts_allowed, $attempted_count );
1988 $quiz_auto_start = $quiz_settings['quiz_auto_start'] ?? 0;
1989 $auto_start_delay = (int) ( $quiz_settings['auto_start_delay'] ?? 5 );
1990 $should_auto_start = 1 === (int) $quiz_auto_start && 0 === (int) $attempted_count;
1991
1992 global $tutor_current_post, $tutor_course_id;
1993 $current_content_id = $tutor_current_post ? $tutor_current_post->ID : $quiz_id;
1994 $course_id = $tutor_course_id ? $tutor_course_id : tutor_utils()->get_course_id_by_subcontent( $current_content_id );
1995 $contents = tutor_utils()->get_course_prev_next_contents_by_id( $current_content_id );
1996 $next_id = $contents ? $contents->next_id : 0;
1997 $skip_url = get_the_permalink( $next_id ? $next_id : $course_id );
1998 $skip_modal_id = 'tutor-quiz-skip-to-next';
1999 $auto_start_modal_id = 'tutor-quiz-autostart-modal';
2000 $retry_modal_id = 'tutor-quiz-retry-modal-' . $quiz_id;
2001 $show_retry_modal = $attempted_count > 0;
2002
2003 $can_skip_quiz = ( 0 === $attempted_count );
2004 $show_continue = ( $attempted_count > 0 && $next_id );
2005 $has_any_action = $can_skip_quiz || $show_continue || $can_start_quiz;
2006
2007 if ( ! $has_any_action ) {
2008 return;
2009 }
2010 ?>
2011 <div class="tutor-learning-area-footer">
2012 <?php
2013 if ( $can_skip_quiz ) {
2014 Button::make()
2015 ->label( __( 'Skip Quiz', 'tutor' ) )
2016 ->variant( Variant::GHOST )
2017 ->attr( '@click', "TutorCore.modal.showModal('$skip_modal_id')" )
2018 ->render();
2019
2020 $skip_modal_confirm_button = Button::make()
2021 ->tag( 'a' )
2022 ->label( __( 'Yes, Skip This', 'tutor' ) )
2023 ->variant( Variant::DESTRUCTIVE )
2024 ->size( Size::SMALL )
2025 ->attr( 'href', esc_url( $skip_url ) )
2026 ->get();
2027
2028 $skip_modal_cancel_button = Button::make()
2029 ->label( __( 'Cancel', 'tutor' ) )
2030 ->variant( Variant::SECONDARY )
2031 ->size( Size::SMALL )
2032 ->attr( '@click', "TutorCore.modal.closeModal('$skip_modal_id')" )
2033 ->get();
2034
2035 ConfirmationModal::make()
2036 ->id( $skip_modal_id )
2037 ->icon( tutor_utils()->get_themed_svg( 'images/illustrations/warning.svg' ), 80, 80, ConfirmationModal::ICON_TYPE_HTML )
2038 ->title( __( 'Do You Want to Skip This Quiz?', 'tutor' ) )
2039 ->message( __( 'Are you sure you want to skip this quiz? Please confirm your choice.', 'tutor' ) )
2040 ->confirm_button( $skip_modal_confirm_button )
2041 ->cancel_button( $skip_modal_cancel_button )
2042 ->render();
2043 }
2044 ?>
2045
2046 <?php if ( $can_start_quiz ) : ?>
2047 <?php if ( $show_retry_modal ) : ?>
2048 <?php
2049 Button::make()
2050 ->label( __( 'Retry Quiz', 'tutor' ) )
2051 ->variant( $show_continue ? Variant::GHOST : Variant::PRIMARY )
2052 ->attr( 'type', 'button' )
2053 ->attr(
2054 '@click',
2055 sprintf(
2056 'TutorCore.modal.showModal("%s", { data: %s });',
2057 $retry_modal_id,
2058 wp_json_encode(
2059 array(
2060 'quizID' => $quiz_id,
2061 'redirectURL' => get_post_permalink( $quiz_id ),
2062 )
2063 )
2064 )
2065 )
2066 ->render();
2067 ?>
2068 <?php else : ?>
2069 <form
2070 x-data="tutorQuizAutoStart({
2071 quizID: <?php echo esc_attr( $quiz_id ); ?>,
2072 autoStart: <?php echo $should_auto_start ? 'true' : 'false'; ?>,
2073 autoStartModalId: '<?php echo esc_attr( $auto_start_modal_id ); ?>',
2074 countdownSeconds: <?php echo esc_attr( $auto_start_delay ); ?>,
2075 })"
2076 @submit.prevent="handleStartQuiz()"
2077 >
2078 <?php
2079 Button::make()
2080 ->label( __( 'Start Quiz', 'tutor' ) )
2081 ->attr( 'x-bind:disabled', 'startQuizMutation?.isPending' )
2082 ->attr( ':class', "{ 'tutor-btn-loading': startQuizMutation?.isPending }" )
2083 ->render();
2084 ?>
2085 </form>
2086 <?php endif; ?>
2087 <?php endif; ?>
2088
2089 <?php
2090 if ( $show_continue ) {
2091 Button::make()
2092 ->tag( 'a' )
2093 ->label( __( 'Continue Lesson', 'tutor' ) )
2094 ->attr( 'href', esc_url( get_the_permalink( $next_id ) ) )
2095 ->render();
2096 }
2097 ?>
2098 </div>
2099
2100 <?php if ( $show_retry_modal ) : ?>
2101 <div x-data="tutorQuizRetryAttempt()">
2102 <?php
2103 ConfirmationModal::make()
2104 ->id( $retry_modal_id )
2105 ->title( __( 'Retry Quiz?', 'tutor' ) )
2106 ->icon( tutor_utils()->get_themed_svg( 'images/illustrations/quiz-retry.svg' ), 80, 80, ConfirmationModal::ICON_TYPE_HTML )
2107 ->message( __( 'Retrying this quiz will reset your current attempt. Your answers and score from this attempt will be lost.', 'tutor' ) )
2108 ->confirm_handler( 'retryMutation?.mutate({...payload?.data})' )
2109 ->confirm_text( __( 'Retry Quiz', 'tutor' ) )
2110 ->mutation_state( 'retryMutation' )
2111 ->render();
2112 ?>
2113 </div>
2114 <?php endif; ?>
2115
2116 <?php
2117 Modal::make()
2118 ->id( $auto_start_modal_id )
2119 ->closeable( false )
2120 ->width( '268px' )
2121 ->template(
2122 tutor()->path . 'templates/learning-area/quiz/modals/auto-start.php',
2123 array(
2124 'countdown_seconds' => $auto_start_delay,
2125 'modal_id' => $auto_start_modal_id,
2126 )
2127 )
2128 ->render();
2129 ?>
2130 <?php
2131 }
2132
2133 /**
2134 * Render individual question template
2135 *
2136 * @since 4.0.0
2137 *
2138 * @param object $question Question data.
2139 * @param int $index Question index.
2140 *
2141 * @return void
2142 */
2143 public static function render_question( $question, $index = 0 ) {
2144 $question_settings = maybe_unserialize( $question->question_settings );
2145 $question_type = $question->question_type;
2146 $rand_choice = ! empty( $question_settings['randomize_question'] )
2147 && '1' === $question_settings['randomize_question'];
2148
2149 // Normalize question type + settings.
2150 switch ( $question->question_type ) {
2151 case 'short_answer':
2152 $question->question_type = 'open_ended';
2153 break;
2154
2155 case 'single_choice':
2156 $question->question_type = 'multiple_choice';
2157 $question_settings['has_multiple_correct_answer'] = '0';
2158 break;
2159
2160 case 'image_matching':
2161 $question->question_type = 'matching';
2162 $question_settings['is_image_matching'] = '1';
2163 break;
2164 }
2165
2166 $template = str_replace( '_', '-', $question->question_type );
2167 $answers = self::prepare_question_answers( (int) $question->question_id, $question_type, $rand_choice );
2168
2169 $question->index = $index;
2170 $question->question_settings = $question_settings;
2171 $question->question_answers = $answers['question_answers'];
2172 $question->question_randomized_answers = $answers['question_randomized_answers'];
2173
2174 tutor_load_template(
2175 'learning-area.quiz.question',
2176 array(
2177 'question' => $question,
2178 'question_settings' => $question_settings,
2179 'question_type' => $template,
2180 )
2181 );
2182 }
2183
2184 /**
2185 * Get question answers prepared for render.
2186 *
2187 * @since 4.0.0
2188 *
2189 * @param int $question_id Question ID.
2190 * @param string $question_type Original question type.
2191 * @param bool $rand_choice Whether randomized choices are enabled.
2192 *
2193 * @return array{question_answers: array, question_randomized_answers: array}
2194 */
2195 private static function prepare_question_answers( int $question_id, string $question_type, bool $rand_choice ): array {
2196 $question_answers = QuizModel::get_answers_by_quiz_question( $question_id );
2197 $question_randomized_answers = array();
2198
2199 // Ordering questions always use a randomized answer list.
2200 // Matching and image matching keep the drop-zone items ordered and only shuffle the draggable choices.
2201
2202 if ( 'ordering' === $question_type ) {
2203 $question_answers = QuizModel::get_answers_by_quiz_question( $question_id, true );
2204 } elseif ( 'matching' === $question_type || 'image_matching' === $question_type ) {
2205 $question_randomized_answers = QuizModel::get_answers_by_quiz_question( $question_id, $rand_choice );
2206 } elseif ( $rand_choice ) {
2207 $question_answers = QuizModel::get_answers_by_quiz_question( $question_id, true );
2208 }
2209
2210 $question_answers = array_map(
2211 static fn( $answer ) => (array) $answer,
2212 $question_answers
2213 );
2214 $question_randomized_answers = array_map(
2215 static fn( $answer ) => (array) $answer,
2216 $question_randomized_answers
2217 );
2218
2219 return array(
2220 'question_answers' => $question_answers,
2221 'question_randomized_answers' => $question_randomized_answers,
2222 );
2223 }
2224
2225 /**
2226 * Renders the assignment status icon for the learning area navigation.
2227 *
2228 * @since 4.0.0
2229 *
2230 * @param WP_Post $quiz The assignment post object.
2231 * @param bool $can_access Whether the current user can access the assignment.
2232 * @param int $tutor_current_content_id Current content id.
2233 *
2234 * @return void
2235 */
2236 public static function render_sidebar_nav( WP_Post $quiz, $can_access, $tutor_current_content_id ) {
2237 $quiz_title = $quiz->post_title;
2238
2239 $active_class = $tutor_current_content_id === $quiz->ID ? 'active' : '';
2240 $disabled_class = $can_access ? '' : 'disabled';
2241
2242 $quiz_status = '';
2243 $icon_name = Icon::QUIZ_2;
2244 if ( ! $can_access ) {
2245 $icon_name = Icon::LOCK_STROKE_2;
2246 } else {
2247 $last_attempt = ( new QuizModel() )->get_first_or_last_attempt( $quiz->ID );
2248 $attempt_ended = is_object( $last_attempt ) && QuizModel::ATTEMPT_STARTED !== $last_attempt->attempt_status;
2249
2250 $quiz_result = QuizModel::get_quiz_result( $quiz->ID );
2251 if ( $attempt_ended && QuizModel::ATTEMPT_STARTED !== $last_attempt->attempt_status ) {
2252 if ( QuizModel::RESULT_FAIL === $quiz_result ) {
2253 $icon_name = Icon::CROSS_COLORIZE;
2254 $quiz_status = QuizModel::RESULT_FAIL;
2255 } elseif ( QuizModel::RESULT_PENDING === $quiz_result ) {
2256 $icon_name = Icon::INFO_COLORIZE;
2257 $quiz_status = QuizModel::RESULT_PENDING;
2258 } elseif ( QuizModel::RESULT_PASS === $quiz_result ) {
2259 $icon_name = Icon::COMPLETED_COLORIZE;
2260 }
2261 }
2262 }
2263 ?>
2264
2265 <a
2266 href="<?php echo esc_url( $can_access ? get_permalink( $quiz->ID ) : '#' ); ?>"
2267 title="<?php echo esc_attr( $quiz_title ); ?>"
2268 class="<?php echo esc_html( sprintf( 'tutor-learning-nav-item %s %s %s', $active_class, $disabled_class, $quiz_status ) ); ?>"
2269 <?php echo ! $can_access ? 'aria-disabled="true"' : ''; ?>
2270 >
2271 <?php SvgIcon::make()->name( $icon_name )->size( 20 )->render(); ?>
2272 <div class="tutor-overflow-hidden">
2273 <div class="tutor-truncate"><?php echo esc_html( $quiz_title ); ?></div>
2274 <div class="tutor-tiny-2 tutor-text-subdued"><?php esc_html_e( 'Quiz', 'tutor' ); ?></div>
2275 </div>
2276 </a>
2277 <?php
2278 }
2279 }
2280