PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 4.0.1
Tutor LMS – eLearning and online course solution v4.0.1
4.0.1 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 4 days ago Admin.php 4 days ago Ajax.php 4 days ago Announcements.php 4 days ago Assets.php 1 day ago Backend_Page_Trait.php 1 year ago BaseController.php 1 year ago Config.php 4 days ago Container.php 11 months ago Course.php 4 days ago Course_Embed.php 3 years ago Course_Filter.php 4 days ago Course_List.php 4 days ago Course_Settings_Tabs.php 4 days ago Course_Widget.php 1 year ago Custom_Validation.php 4 days ago Dashboard.php 4 days ago Earnings.php 9 months ago FormHandler.php 4 days ago Frontend.php 4 days ago Gutenberg.php 1 year ago Icon.php 4 days ago Input.php 4 days ago Instructor.php 4 days ago Instructors_List.php 4 days ago Lesson.php 4 days ago Options_V2.php 4 days ago Permalink.php 4 days ago Post_types.php 5 days ago Private_Course_Access.php 4 days ago Q_And_A.php 4 days ago Question_Answers_List.php 11 months ago Quiz.php 1 day ago QuizBuilder.php 4 days ago Quiz_Attempts_List.php 1 day ago RestAPI.php 1 day ago Reviews.php 4 days ago Rewrite_Rules.php 2 years ago SampleCourse.php 4 days ago Shortcode.php 4 days ago Singleton.php 1 year ago Student.php 4 days ago Students_List.php 1 year ago Taxonomies.php 1 year ago Template.php 4 days ago Theme_Compatibility.php 3 years ago Tools.php 1 year ago Tools_V2.php 1 month ago Tutor.php 4 days ago TutorEDD.php 4 days ago Tutor_Base.php 2 years ago Tutor_Setup.php 4 days ago Upgrader.php 4 days ago User.php 4 days ago UserPreference.php 4 days ago Utils.php 1 day ago Video_Stream.php 3 years ago WhatsNew.php 1 day ago Withdraw.php 4 days ago Withdraw_Requests_List.php 11 months ago WooCommerce.php 4 days ago
Quiz.php
2285 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 ( QuizModel::QUESTION_TYPE_TRUE_FALSE === $question_type || QuizModel::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 ( QuizModel::QUESTION_TYPE_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
832 $given_answer = maybe_serialize( $given_answer );
833
834 } elseif ( QuizModel::QUESTION_TYPE_FILL_IN_THE_BLANK === $question_type ) {
835
836 $get_original_answer = $wpdb->get_row(
837 $wpdb->prepare(
838 "SELECT *
839 FROM {$wpdb->prefix}tutor_quiz_question_answers
840 WHERE belongs_question_id = %d
841 AND belongs_question_type = %s ;
842 ",
843 $question->question_id,
844 $question_type
845 )
846 );
847
848 /**
849 * Answers stored in DB
850 */
851 $gap_answer = (array) explode( '|', $get_original_answer->answer_two_gap_match );
852 $gap_answer = maybe_serialize(
853 array_map(
854 function ( $ans ) {
855 return wp_slash( trim( $ans ) );
856 },
857 $gap_answer
858 )
859 );
860
861 /**
862 * Answers from user input
863 */
864 $given_answer = is_array( $answers ) ? array_map( 'sanitize_text_field', $answers ) : array( sanitize_text_field( $answers ) );
865 $given_answer = maybe_serialize( $given_answer );
866
867 /**
868 * Compare answer's by making both case-insensitive.
869 */
870 if ( strtolower( $given_answer ) === strtolower( $gap_answer ) ) {
871 $is_answer_was_correct = true;
872 }
873 } elseif ( QuizModel::QUESTION_TYPE_OPEN_ENDED === $question_type || QuizModel::QUESTION_TYPE_SHORT_ANSWER === $question_type ) {
874 $review_required = true;
875 $given_answer = wp_kses_post( $answers );
876
877 } elseif ( QuizModel::QUESTION_TYPE_ORDERING === $question_type || QuizModel::QUESTION_TYPE_MATCHING === $question_type || QuizModel::QUESTION_TYPE_IMAGE_MATCHING === $question_type ) {
878 $answers = (array) tutor_utils()->avalue_dot( 'answers', $answers );
879
880 $given_answer = (array) array_map( 'sanitize_text_field', $answers );
881 $given_answer = maybe_serialize( $given_answer );
882
883 $get_original_answers = (array) $wpdb->get_col(
884 $wpdb->prepare(
885 "SELECT answer_id
886 FROM {$wpdb->prefix}tutor_quiz_question_answers
887 WHERE belongs_question_id = %d
888 AND belongs_question_type = %s
889 ORDER BY answer_order ASC ;
890 ",
891 $question->question_id,
892 $question_type
893 )
894 );
895
896 $get_original_answers = array_map( 'sanitize_text_field', $get_original_answers );
897
898 if ( maybe_serialize( $get_original_answers ) == $given_answer ) {
899 $is_answer_was_correct = true;
900 }
901 } elseif ( QuizModel::QUESTION_TYPE_IMAGE_ANSWERING === $question_type ) {
902 $image_inputs = tutor_utils()->avalue_dot( 'answer_id', $answers );
903 $image_inputs = (array) array_map( 'sanitize_text_field', $image_inputs );
904 $given_answer = maybe_serialize( $image_inputs );
905 $is_answer_was_correct = false;
906 /**
907 * For the image_answering question type result
908 * remain pending in spite of correct answer & required
909 * review of admin/instructor. Since it's
910 * pending we need to mark it as incorrect. Otherwise if
911 * mark it correct then earned mark will be updated. then
912 * again when instructor/admin review & mark it as correct
913 * extra mark is adding. In this case, student
914 * getting double mark for the same question.
915 *
916 * For now code is commenting will be removed later on
917 *
918 * @since 2.1.5
919 */
920
921 //phpcs:disable
922
923 // $db_answer = $wpdb->get_col(
924 // $wpdb->prepare(
925 // "SELECT answer_title
926 // FROM {$wpdb->prefix}tutor_quiz_question_answers
927 // WHERE belongs_question_id = %d
928 // AND belongs_question_type = 'image_answering'
929 // ORDER BY answer_order asc ;",
930 // $question_id
931 // )
932 // );
933
934 // if ( is_array( $db_answer ) && count( $db_answer ) ) {
935 // $is_answer_was_correct = ( strtolower( maybe_serialize( array_values( $image_inputs ) ) ) == strtolower( maybe_serialize( $db_answer ) ) );
936 // }
937 //phpcs:enable
938 } else {
939 $custom_answer_data = array(
940 'given_answer' => $given_answer,
941 'is_answer_was_correct' => $is_answer_was_correct,
942 );
943 $custom_answer_data = apply_filters( 'tutor_quiz_process_custom_question_answer', $custom_answer_data, $question_type, $answers, $question, $question_id, $attempt_id );
944 $given_answer = $custom_answer_data['given_answer'];
945 $is_answer_was_correct = $custom_answer_data['is_answer_was_correct'];
946 }
947
948 $question_mark = $is_answer_was_correct ? $question->question_mark : 0;
949 $total_marks += $question_mark;
950
951 $answers_data = array(
952 'user_id' => $user_id,
953 'quiz_id' => $attempt->quiz_id,
954 'question_id' => $question_id,
955 'quiz_attempt_id' => $attempt_id,
956 'given_answer' => $given_answer,
957 'question_mark' => $question->question_mark,
958 'achieved_mark' => $question_mark,
959 'minus_mark' => 0,
960 'is_correct' => $is_answer_was_correct ? 1 : 0,
961 );
962
963 /**
964 * Check if question_type open ended or short ans the set
965 * is_correct default value null before saving
966 */
967 if ( in_array( $question_type, QuizModel::get_manual_review_types(), true ) ) {
968 $answers_data['is_correct'] = null;
969 $review_required = true;
970 }
971
972 $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id );
973
974 // Filter total marks after grading. Runs after answers_data is built and graded,
975 // so add-ons (e.g. H5P, pin_image, draw_image) can add their achieved marks.
976 $total_marks = apply_filters( 'tutor_filter_quiz_total_marks', $total_marks, $question_id, $question_type, $user_id, $attempt_id, $answers_data );
977
978 $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data );
979 }
980 }
981
982 $attempt_info = array(
983 'total_answered_questions' => tutor_utils()->count( $quiz_answers ),
984 'earned_marks' => $total_marks,
985 'attempt_status' => 'attempt_ended',
986 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
987 );
988
989 if ( $review_required ) {
990 $attempt_info['attempt_status'] = 'review_required';
991 }
992
993 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_info, array( 'attempt_id' => $attempt_id ) );
994
995 QuizModel::update_attempt_result( $attempt_id );
996 }
997
998 // After hook.
999 do_action( 'tutor_quiz/attempt_ended', $attempt_id, $course_id, $user_id );
1000 }
1001
1002
1003 /**
1004 * Quiz attempt will be finish here
1005 *
1006 * @since 1.0.0
1007 *
1008 * @return void
1009 */
1010 public function finishing_quiz_attempt() {
1011
1012 if ( Input::post( 'tutor_action' ) !== 'tutor_finish_quiz_attempt' ) {
1013 return;
1014 }
1015 // Checking nonce.
1016 tutor_utils()->checking_nonce();
1017
1018 if ( ! is_user_logged_in() ) {
1019 die( 'Please sign in to do this operation' );
1020 }
1021
1022 global $wpdb;
1023
1024 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1025 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
1026 $attempt_id = $attempt->attempt_id;
1027
1028 $attempt_info = array(
1029 'total_answered_questions' => 0,
1030 'earned_marks' => 0,
1031 'attempt_status' => 'attempt_ended',
1032 'attempt_ended_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
1033 );
1034
1035 do_action( 'tutor_quiz_before_finish', $attempt_id, $quiz_id, $attempt->user_id );
1036 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $attempt_info, array( 'attempt_id' => $attempt_id ) );
1037 do_action( 'tutor_quiz_finished', $attempt_id, $quiz_id, $attempt->user_id );
1038
1039 wp_redirect( tutor_utils()->input_old( '_wp_http_referer' ) );
1040 }
1041
1042 /**
1043 * Get quiz total marks.
1044 *
1045 * @since 3.0.0
1046 *
1047 * @param int $quiz_id quiz id.
1048 *
1049 * @return int|float
1050 */
1051 public static function get_quiz_total_marks( $quiz_id ) {
1052 global $wpdb;
1053
1054 $total_marks = $wpdb->get_var(
1055 $wpdb->prepare(
1056 "SELECT SUM(question_mark) total_marks
1057 FROM {$wpdb->prefix}tutor_quiz_questions
1058 WHERE quiz_id=%d",
1059 $quiz_id
1060 )
1061 );
1062
1063 return floatval( $total_marks );
1064 }
1065
1066 /**
1067 * Quiz timeout by ajax
1068 *
1069 * @since 1.0.0
1070 *
1071 * @return void
1072 */
1073 public function tutor_quiz_timeout() {
1074 tutils()->checking_nonce();
1075
1076 global $wpdb;
1077
1078 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1079 $attempt = tutor_utils()->is_started_quiz( $quiz_id );
1080
1081 if ( $attempt ) {
1082 $attempt_id = $attempt->attempt_id;
1083
1084 $data = array(
1085 'attempt_status' => 'attempt_timeout',
1086 'total_marks' => self::get_quiz_total_marks( $quiz_id ),
1087 'earned_marks' => 0,
1088 'attempt_ended_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ),
1089 );
1090
1091 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempts', $data, array( 'attempt_id' => $attempt->attempt_id ) );
1092
1093 do_action( 'tutor_quiz_timeout', $attempt_id, $quiz_id, $attempt->user_id );
1094
1095 wp_send_json_success();
1096 }
1097
1098 wp_send_json_error( __( 'Quiz has been timeout already', 'tutor' ) );
1099 }
1100
1101 /**
1102 * Review quiz answer
1103 *
1104 * @since 1.0.0
1105 *
1106 * @return void
1107 */
1108 public function review_quiz_answer() {
1109
1110 tutor_utils()->checking_nonce();
1111
1112 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
1113 $context = Input::post( 'context' );
1114 $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT );
1115 $question_id = Input::post( 'question_id', 0, Input::TYPE_INT );
1116 $mark_as = Input::post( 'mark_as' );
1117
1118 if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) {
1119 wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
1120 }
1121
1122 if ( $attempt_answer_id && ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer_id ) ) {
1123 wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
1124 }
1125
1126 $attempt_answer = $this->resolve_attempt_answer_for_review( $attempt_id, $attempt_answer_id, $question_id );
1127 $review_data = $attempt_answer ? $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as ) : null;
1128
1129 if ( ! $review_data ) {
1130 wp_send_json_error( array( 'message' => __( 'Review update failed', 'tutor' ) ) );
1131 }
1132
1133 QuizModel::update_attempt_result( $attempt_id );
1134
1135 ob_start();
1136 tutor_load_template_from_custom_path(
1137 tutor()->path . '/views/quiz/attempt-details.php',
1138 array(
1139 'attempt_id' => $attempt_id,
1140 'user_id' => $review_data['student_id'],
1141 'context' => $context,
1142 'back_url' => Input::post( 'back_url' ),
1143 )
1144 );
1145 wp_send_json_success( array( 'html' => ob_get_clean() ) );
1146 }
1147
1148 /**
1149 * Review quiz answers in bulk for v4 dashboard flow.
1150 *
1151 * @since 4.0.0
1152 *
1153 * @return void
1154 */
1155 public function review_quiz_answers() {
1156 tutor_utils()->checking_nonce();
1157
1158 $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
1159 $review_statuses = Input::post( 'review_statuses', array(), Input::TYPE_ARRAY );
1160
1161 $this->review_quiz_answers_bulk( $attempt_id, $review_statuses );
1162 }
1163
1164 /**
1165 * Review quiz answers in bulk for v4 dashboard flow.
1166 *
1167 * @since 4.0.0
1168 *
1169 * @param int $attempt_id Attempt ID.
1170 * @param array $review_statuses Review statuses keyed by question ID.
1171 *
1172 * @return void
1173 */
1174 private function review_quiz_answers_bulk( int $attempt_id, array $review_statuses ) {
1175 if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) {
1176 $this->response_fail( __( 'Access Denied', 'tutor' ), 403 );
1177 }
1178
1179 $attempt_answers = QuizModel::get_quiz_answers_by_attempt_id( $attempt_id );
1180 $answers_by_question_id = array();
1181
1182 if ( is_array( $attempt_answers ) ) {
1183 foreach ( $attempt_answers as $attempt_answer ) {
1184 $question_id = (int) ( $attempt_answer->question_id ?? 0 );
1185
1186 if ( $question_id > 0 ) {
1187 $answers_by_question_id[ $question_id ] = $attempt_answer;
1188 }
1189 }
1190 }
1191
1192 foreach ( $review_statuses as $question_id => $mark_as ) {
1193 $question_id = (int) $question_id;
1194 $mark_as = (string) $mark_as;
1195
1196 if ( ! in_array( $mark_as, array( 'correct', 'incorrect' ), true ) ) {
1197 continue;
1198 }
1199
1200 $attempt_answer = $answers_by_question_id[ $question_id ] ?? null;
1201
1202 if ( ! $attempt_answer ) {
1203 $attempt_answer = $this->resolve_attempt_answer_for_review( $attempt_id, 0, $question_id );
1204 }
1205
1206 if ( ! $attempt_answer ) {
1207 continue;
1208 }
1209
1210 $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as );
1211 }
1212
1213 QuizModel::update_attempt_result( $attempt_id );
1214
1215 $this->response_success( __( 'Review updated successfully', 'tutor' ) );
1216 }
1217
1218 /**
1219 * Get attempt answer record by ID.
1220 *
1221 * @since 4.0.0
1222 *
1223 * @param int $attempt_answer_id Attempt answer ID.
1224 *
1225 * @return object|null
1226 */
1227 public static function get_attempt_answer( int $attempt_answer_id ) {
1228 global $wpdb;
1229
1230 return $wpdb->get_row(
1231 $wpdb->prepare(
1232 "SELECT *
1233 FROM {$wpdb->prefix}tutor_quiz_attempt_answers
1234 WHERE attempt_answer_id = %d",
1235 $attempt_answer_id
1236 )
1237 );
1238 }
1239
1240 /**
1241 * Get attempt answer by attempt and question IDs.
1242 *
1243 * @since 4.0.0
1244 *
1245 * @param int $attempt_id Attempt ID.
1246 * @param int $question_id Question ID.
1247 *
1248 * @return object|null
1249 */
1250 private function get_attempt_answer_by_attempt_and_question( int $attempt_id, int $question_id ) {
1251 if ( $attempt_id <= 0 || $question_id <= 0 ) {
1252 return null;
1253 }
1254
1255 return QueryHelper::get_row(
1256 'tutor_quiz_attempt_answers',
1257 array(
1258 'quiz_attempt_id' => $attempt_id,
1259 'question_id' => $question_id,
1260 ),
1261 'attempt_answer_id',
1262 'ASC'
1263 );
1264 }
1265
1266 /**
1267 * Create a placeholder attempt answer for a skipped question.
1268 *
1269 * @since 4.0.0
1270 *
1271 * @param int $attempt_id Attempt ID.
1272 * @param int $question_id Question ID.
1273 *
1274 * @return object|null
1275 */
1276 private function create_skipped_attempt_answer( int $attempt_id, int $question_id ) {
1277 $attempt = tutor_utils()->get_attempt( $attempt_id );
1278 if ( ! $attempt ) {
1279 return null;
1280 }
1281
1282 $question = QuizModel::get_quiz_question_by_id( $question_id );
1283 if ( ! $question || (int) $question->quiz_id !== (int) $attempt->quiz_id ) {
1284 return null;
1285 }
1286
1287 $attempt_answer = $this->get_attempt_answer_by_attempt_and_question( $attempt_id, $question_id );
1288 if ( $attempt_answer ) {
1289 return $attempt_answer;
1290 }
1291
1292 try {
1293 $inserted_id = QueryHelper::insert(
1294 'tutor_quiz_attempt_answers',
1295 array(
1296 'user_id' => (int) $attempt->user_id,
1297 'quiz_id' => (int) $attempt->quiz_id,
1298 'question_id' => $question_id,
1299 'quiz_attempt_id' => $attempt_id,
1300 'given_answer' => '',
1301 'question_mark' => $question->question_mark,
1302 'achieved_mark' => 0,
1303 'minus_mark' => 0,
1304 'is_correct' => 0,
1305 )
1306 );
1307 } catch ( \Exception $exception ) {
1308 return null;
1309 }
1310
1311 if ( $inserted_id <= 0 ) {
1312 return null;
1313 }
1314
1315 return self::get_attempt_answer( $inserted_id );
1316 }
1317
1318 /**
1319 * Resolve the attempt answer used for instructor review.
1320 *
1321 * @since 4.0.0
1322 *
1323 * @param int $attempt_id Attempt ID.
1324 * @param int $attempt_answer_id Attempt answer ID.
1325 * @param int $question_id Question ID.
1326 *
1327 * @return object|null
1328 */
1329 private function resolve_attempt_answer_for_review( int $attempt_id, int $attempt_answer_id = 0, int $question_id = 0 ) {
1330 $attempt_answer = $attempt_answer_id ? self::get_attempt_answer( $attempt_answer_id ) : null;
1331
1332 if ( $attempt_answer ) {
1333 return $attempt_answer;
1334 }
1335
1336 if ( $question_id <= 0 ) {
1337 return null;
1338 }
1339
1340 $attempt_answer = $this->get_attempt_answer_by_attempt_and_question( $attempt_id, $question_id );
1341
1342 if ( $attempt_answer ) {
1343 return $attempt_answer;
1344 }
1345
1346 return $this->create_skipped_attempt_answer( $attempt_id, $question_id );
1347 }
1348
1349 /**
1350 * Apply quiz answer review update.
1351 *
1352 * @since 4.0.0
1353 *
1354 * @param int $attempt_id Attempt ID.
1355 * @param object $attempt_answer Attempt answer row.
1356 * @param string $mark_as Review status.
1357 *
1358 * @return array|null
1359 */
1360 private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, string $mark_as ) {
1361 global $wpdb;
1362
1363 if ( ! $attempt_answer || ! in_array( $mark_as, array( 'correct', 'incorrect' ), true ) ) {
1364 return null;
1365 }
1366
1367 $attempt = tutor_utils()->get_attempt( $attempt_id );
1368
1369 if ( ! $attempt ) {
1370 return null;
1371 }
1372
1373 $attempt_answer_id = (int) $attempt_answer->attempt_answer_id;
1374 $question = QuizModel::get_quiz_question_by_id( $attempt_answer->question_id );
1375 $course_id = (int) $attempt->course_id;
1376 $student_id = (int) $attempt->user_id;
1377 $previous_ans = $attempt_answer->is_correct;
1378
1379 do_action( 'tutor_quiz_review_answer_before', $attempt_answer_id, $attempt_id, $mark_as );
1380
1381 $mark_as = apply_filters( 'tutor_quiz_review_mark_as', $mark_as, $attempt_answer_id, $attempt_id, $question );
1382
1383 if ( 'correct' === $mark_as ) {
1384 $attempt_update_data = array();
1385 $answer_update_data = array(
1386 'achieved_mark' => $attempt_answer->question_mark,
1387 'is_correct' => 1,
1388 );
1389
1390 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
1391
1392 if ( 0 == $previous_ans || null == $previous_ans ) {
1393 $attempt_update_data = array(
1394 'earned_marks' => $attempt->earned_marks + $attempt_answer->question_mark,
1395 'is_manually_reviewed' => 1,
1396 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
1397 );
1398 }
1399
1400 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
1401 $attempt_update_data['attempt_status'] = 'attempt_ended';
1402 }
1403
1404 if ( ! empty( $attempt_update_data ) ) {
1405 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
1406 }
1407 } elseif ( 'incorrect' === $mark_as ) {
1408 $attempt_update_data = array();
1409 $answer_update_data = array(
1410 'achieved_mark' => '0.00',
1411 'is_correct' => 0,
1412 );
1413
1414 $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) );
1415
1416 if ( 1 == $previous_ans ) {
1417 $attempt_update_data = array(
1418 'earned_marks' => $attempt->earned_marks - $attempt_answer->question_mark,
1419 'is_manually_reviewed' => 1,
1420 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore
1421 );
1422 }
1423
1424 if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) {
1425 $attempt_update_data['attempt_status'] = 'attempt_ended';
1426 }
1427
1428 if ( ! empty( $attempt_update_data ) ) {
1429 $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) );
1430 }
1431 }
1432
1433 do_action( 'tutor_quiz_review_answer_after', $attempt_answer_id, $attempt_id, $mark_as );
1434 do_action( 'tutor_quiz/answer/review/after', $attempt_answer_id, $course_id, $student_id );
1435
1436 return array(
1437 'course_id' => $course_id,
1438 'student_id' => $student_id,
1439 );
1440 }
1441
1442 /**
1443 * Do auto course complete after review a quiz attempt.
1444 *
1445 * @since 2.4.0
1446 *
1447 * @param int $attempt_answer_id attempt answer id.
1448 * @param int $course_id course id.
1449 * @param int $user_id student id.
1450 *
1451 * @return void
1452 */
1453 public function do_auto_course_complete( $attempt_answer_id, $course_id, $user_id ) {
1454 if ( CourseModel::can_autocomplete_course( $course_id, $user_id ) ) {
1455 CourseModel::mark_course_as_completed( $course_id, $user_id );
1456 Course::set_review_popup_data( $user_id, $course_id );
1457 }
1458 }
1459
1460 /**
1461 * Get a quiz details by id
1462 *
1463 * @return void
1464 */
1465 public function ajax_quiz_details() {
1466 tutor_utils()->check_nonce();
1467
1468 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1469 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1470 $this->json_response(
1471 tutor_utils()->error_message(),
1472 null,
1473 HttpHelper::STATUS_FORBIDDEN
1474 );
1475 }
1476
1477 $data = QuizModel::get_quiz_details( $quiz_id );
1478
1479 $data = apply_filters( 'tutor_quiz_details_response', $data, $quiz_id );
1480
1481 $this->json_response(
1482 __( 'Quiz data fetched successfully', 'tutor' ),
1483 $data
1484 );
1485 }
1486
1487 /**
1488 * Delete quiz by id
1489 *
1490 * @since 1.0.0
1491 * @since 3.0.0 refactor and response change.
1492 *
1493 * @return void
1494 */
1495 public function ajax_quiz_delete() {
1496 if ( ! tutor_utils()->is_nonce_verified() ) {
1497 $this->json_response( tutor_utils()->error_message( 'nonce' ), null, HttpHelper::STATUS_BAD_REQUEST );
1498 }
1499
1500 global $wpdb;
1501
1502 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1503 if ( ! tutor_utils()->can_user_manage( 'quiz', $quiz_id ) ) {
1504 $this->json_response(
1505 tutor_utils()->error_message(),
1506 null,
1507 HttpHelper::STATUS_FORBIDDEN
1508 );
1509 }
1510
1511 $post = get_post( $quiz_id );
1512 if ( 'tutor_quiz' !== $post->post_type ) {
1513 $this->json_response(
1514 __( 'Invalid quiz', 'tutor' ),
1515 null,
1516 HttpHelper::STATUS_BAD_REQUEST
1517 );
1518 }
1519
1520 do_action( 'tutor_delete_quiz_before', $quiz_id );
1521
1522 // Collect file paths from all question types that store files before deleting rows (files deleted after DB for safety).
1523 $attempts_for_quiz = QueryHelper::get_all( 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ), 'attempt_id', -1 );
1524 $attempt_file_paths = array();
1525 if ( ! empty( $attempts_for_quiz ) ) {
1526 $attempt_ids = array_map(
1527 function ( $row ) {
1528 return (int) $row->attempt_id;
1529 },
1530 $attempts_for_quiz
1531 );
1532 $attempt_file_paths = apply_filters( 'tutor_quiz/attempt_file_paths_for_deletion', array(), $attempt_ids );
1533 $attempt_file_paths = is_array( $attempt_file_paths ) ? array_values( array_filter( array_unique( $attempt_file_paths ) ) ) : array();
1534 }
1535
1536 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ) );
1537 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $quiz_id ) );
1538
1539 QuizModel::delete_files_by_paths( $attempt_file_paths );
1540
1541 // Collect instructor file paths before deleting question data (e.g. draw_image / pin_image masks).
1542 /**
1543 * Filter to get file paths for quiz deletion.
1544 * Pro and other add-ons register their question types via this filter.
1545 *
1546 * @param string[] $file_paths Paths collected so far.
1547 * @param int $quiz_id Quiz post ID.
1548 */
1549 $quiz_file_paths = apply_filters( 'tutor_quiz_quiz_file_paths_for_deletion', array(), $quiz_id );
1550
1551 $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $quiz_id ) );
1552
1553 if ( is_array( $questions_ids ) && count( $questions_ids ) ) {
1554 $in_question_ids = QueryHelper::prepare_in_clause( $questions_ids );
1555 //phpcs:disable
1556 $wpdb->query(
1557 "DELETE
1558 FROM {$wpdb->prefix}tutor_quiz_question_answers
1559 WHERE belongs_question_id IN({$in_question_ids})
1560 "
1561 );
1562 //phpcs:enable
1563 }
1564
1565 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $quiz_id ) );
1566
1567 QuizModel::delete_files_by_paths( $quiz_file_paths );
1568
1569 wp_delete_post( $quiz_id, true );
1570
1571 do_action( 'tutor_delete_quiz_after', $quiz_id );
1572
1573 $this->json_response(
1574 __( 'Quiz deleted successfully', 'tutor' ),
1575 $quiz_id
1576 );
1577 }
1578
1579 /**
1580 * Get answers by quiz id
1581 *
1582 * @since 1.0.0
1583 *
1584 * @param int $question_id question id.
1585 * @param mixed $question_type type of question.
1586 * @param boolean $is_correct only correct answers or not.
1587 *
1588 * @return wpdb:get_results
1589 */
1590 private function get_answers_by_q_id( $question_id, $question_type, $is_correct = false ) {
1591 global $wpdb;
1592
1593 $correct_clause = $is_correct ? ' AND is_correct=1 ' : '';
1594 //phpcs:disable
1595 return $wpdb->get_results(
1596 $wpdb->prepare(
1597 "SELECT * FROM {$wpdb->prefix}tutor_quiz_question_answers
1598 WHERE belongs_question_id = %d
1599 AND belongs_question_type = %s
1600 {$correct_clause}
1601 ORDER BY answer_order ASC;
1602 ",
1603 $question_id,
1604 esc_sql( $question_type )
1605 )
1606 );
1607 //phpcs:enable
1608 }
1609
1610 /**
1611 * Rendering quiz for frontend
1612 *
1613 * @since 1.0.0
1614 *
1615 * @return void send wp_json response
1616 */
1617 public function tutor_render_quiz_content() {
1618
1619 tutor_utils()->checking_nonce();
1620
1621 $quiz_id = Input::post( 'quiz_id', 0, Input::TYPE_INT );
1622
1623 if ( ! tutor_utils()->has_enrolled_content_access( 'quiz', $quiz_id ) ) {
1624 wp_send_json_error( array( 'message' => __( 'Access Denied.', 'tutor' ) ) );
1625 }
1626
1627 ob_start();
1628 global $post;
1629
1630 $post = get_post( $quiz_id ); //phpcs:ignore
1631 setup_postdata( $post );
1632
1633 single_quiz_contents();
1634 wp_reset_postdata();
1635
1636 $html = ob_get_clean();
1637 wp_send_json_success( array( 'html' => $html ) );
1638 }
1639
1640 /**
1641 * Get attempt details
1642 *
1643 * @since 1.0.0
1644 *
1645 * @param int $attempt_id required attempt id to get details.
1646 *
1647 * @return mixed object on success, null on failure
1648 */
1649 public static function attempt_details( int $attempt_id ) {
1650 global $wpdb;
1651 $attempt_details = $wpdb->get_row(
1652 $wpdb->prepare(
1653 "SELECT *
1654 FROM {$wpdb->prefix}tutor_quiz_attempts
1655 WHERE attempt_id = %d
1656 ",
1657 $attempt_id
1658 )
1659 );
1660 return $attempt_details;
1661 }
1662
1663 /**
1664 * Update quiz attempt info
1665 *
1666 * @since 1.0.0
1667 *
1668 * @param int $attempt_id attempt id.
1669 * @param mixed $attempt_info serialize data.
1670 *
1671 * @return bool, true on success, false on failure
1672 */
1673 public static function update_attempt_info( int $attempt_id, $attempt_info ) {
1674 global $wpdb;
1675 $table = $wpdb->prefix . 'tutor_quiz_attempts';
1676 $update_info = $wpdb->update(
1677 $table,
1678 array( 'attempt_info' => $attempt_info ),
1679 array( 'attempt_id' => $attempt_id )
1680 );
1681 return $update_info ? true : false;
1682 }
1683
1684 /**
1685 * Attempt delete ajax request handler
1686 *
1687 * @since 2.1.0
1688 *
1689 * @return void wp_json response
1690 */
1691 public function attempt_delete() {
1692 tutor_utils()->checking_nonce();
1693
1694 $attempt_id = Input::post( 'id', 0, Input::TYPE_INT );
1695 $attempt = tutor_utils()->get_attempt( $attempt_id );
1696 if ( ! $attempt ) {
1697 wp_send_json_error( __( 'Invalid attempt ID', 'tutor' ) );
1698 }
1699
1700 $user_id = get_current_user_id();
1701 $course_id = $attempt->course_id;
1702
1703 if ( tutor_utils()->can_user_edit_course( $user_id, $course_id ) ) {
1704 QuizModel::delete_quiz_attempt( $attempt_id );
1705 wp_send_json_success( __( 'Attempt deleted successfully!', 'tutor' ) );
1706 } else {
1707 wp_send_json_error( tutor_utils()->error_message() );
1708 }
1709 }
1710
1711 /**
1712 * Get all quiz attempts for a user in a specific course.
1713 *
1714 * @since 3.8.1
1715 *
1716 * @param int $course_id The ID of the course.
1717 *
1718 * @return array Returns an array of quiz attempt objects with their answers, or an empty array on error.
1719 */
1720 public function get_quiz_attempts_and_answers_by_course_id( int $course_id ): array {
1721 global $wpdb;
1722
1723 $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempts, array( 'course_id' => $course_id ), 'course_id', -1 );
1724
1725 if ( empty( $results ) ) {
1726 return array();
1727 }
1728
1729 return array_map(
1730 function ( $item ) {
1731 $item->quiz_attempt_answers = $this->get_quiz_attempt_answers_by_attempt_id( $item->attempt_id );
1732 return $item;
1733 },
1734 $results
1735 );
1736 }
1737
1738 /**
1739 * Get all quiz attempt answers for a specific quiz attempt.
1740 *
1741 * @since 3.8.1
1742 *
1743 * @param int $attempt_id The ID of the quiz attempt.
1744 *
1745 * @return array Returns an array of quiz attempt answers objects, or an empty array on error.
1746 */
1747 private function get_quiz_attempt_answers_by_attempt_id( int $attempt_id ): array {
1748 global $wpdb;
1749
1750 $results = QueryHelper::get_all( $wpdb->tutor_quiz_attempt_answers, array( 'quiz_attempt_id' => $attempt_id ), 'quiz_attempt_id', -1 );
1751
1752 if ( empty( $results ) ) {
1753 return array();
1754 }
1755
1756 return $results;
1757 }
1758
1759 /**
1760 * Render quiz title as nav item to show on the learning area
1761 *
1762 * @since 4.0.0
1763 *
1764 * @param WP_Post $quiz Quiz post object.
1765 * @param bool $can_access Can user access this content.
1766 *
1767 * @return void
1768 */
1769 public function render_nav_item( WP_Post $quiz, bool $can_access ): void {
1770 tutor_load_template(
1771 'learning-area.quiz.nav-item',
1772 array(
1773 'quiz' => $quiz,
1774 'can_access' => $can_access,
1775 )
1776 );
1777 }
1778
1779 /**
1780 * Render content for the a single quiz
1781 *
1782 * @since 4.0.0
1783 *
1784 * @param WP_Post $quiz Quiz post object.
1785 *
1786 * @return void
1787 */
1788 public function render_single_content( WP_Post $quiz ): void {
1789 tutor_load_template(
1790 'learning-area.quiz.content',
1791 array(
1792 'quiz' => $quiz,
1793 )
1794 );
1795 }
1796
1797 /**
1798 * Render quiz summary
1799 *
1800 * @since 4.0.0
1801 *
1802 * @param int $total_questions Total questions.
1803 * @param string $quiz_item_readable Readable time.
1804 * @param int $total_marks Total Marks.
1805 * @param string $passing_grade Passing grade.
1806 * @param string $earned_marks Earned marks.
1807 * @param string $attempts_allowed Total Attempts allowed.
1808 *
1809 * @return void
1810 */
1811 public static function render_quiz_summary( $total_questions, $quiz_item_readable, $total_marks, $passing_grade, $earned_marks, $attempts_allowed ) {
1812 $quiz_summary = array(
1813 array(
1814 'columns' => array(
1815 array(
1816 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1817 ' . SvgIcon::make()->name( Icon::QUESTION_CIRCLE )->size( 20 )->get() . __( 'Questions', 'tutor' ) . '
1818 </div>',
1819 ),
1820 array( 'content' => $total_questions ),
1821 ),
1822 ),
1823 );
1824
1825 if ( ! empty( $quiz_item_readable ) ) {
1826 $quiz_summary[] = array(
1827 'columns' => array(
1828 array(
1829 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1830 ' . SvgIcon::make()->name( Icon::TIME )->size( 20 )->get() . __( 'Quiz Time', 'tutor' ) . '
1831 </div>',
1832 ),
1833 array( 'content' => $quiz_item_readable ),
1834 ),
1835 );
1836 }
1837
1838 $quiz_summary[] = array(
1839 'columns' => array(
1840 array(
1841 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1842 ' . SvgIcon::make()->name( Icon::PRIME_CHECK_CIRCLE )->size( 20 )->get() . __( 'Total Marks', 'tutor' ) . '
1843 </div>',
1844 ),
1845 array( 'content' => $total_marks ),
1846 ),
1847 );
1848
1849 $quiz_summary[] = array(
1850 'columns' => array(
1851 array(
1852 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1853 ' . SvgIcon::make()->name( Icon::PASSED )->size( 20 )->get() . __( 'Passing Grade', 'tutor' ) . '
1854 </div>',
1855 ),
1856 array( 'content' => $passing_grade . '%' ),
1857 ),
1858 );
1859
1860 if ( $earned_marks ) {
1861 $quiz_summary[] = array(
1862 'columns' => array(
1863 array(
1864 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1865 ' . SvgIcon::make()->name( Icon::STAR )->size( 20 )->get() . __( 'Earned Grade', 'tutor' ) . '
1866 </div>',
1867 ),
1868 array( 'content' => $earned_marks . '%' ),
1869 ),
1870 );
1871 }
1872
1873 if ( 1 !== $attempts_allowed ) {
1874 $quiz_summary[] = array(
1875 'columns' => array(
1876 array(
1877 'content' => '<div class="tutor-flex tutor-gap-3 tutor-items-center">
1878 ' . SvgIcon::make()->name( Icon::TARGET )->size( 20 )->get() . __( 'Total Attempts', 'tutor' ) . '
1879 </div>',
1880 ),
1881 array( 'content' => 0 === $attempts_allowed ? __( 'No Limit', 'tutor' ) : $attempts_allowed ),
1882 ),
1883 );
1884 }
1885
1886 Table::make()->contents( $quiz_summary )->render();
1887 }
1888
1889 /**
1890 * Render quiz attempts
1891 *
1892 * @since 4.0.0
1893 *
1894 * @param int $quiz_id Quiz ID.
1895 *
1896 * @return void
1897 */
1898 public static function render_quiz_attempts( $quiz_id ) {
1899 $quiz_id = tutor_utils()->get_post_id( $quiz_id );
1900 if ( ! $quiz_id ) {
1901 return;
1902 }
1903
1904 $user_id = get_current_user_id();
1905 $quiz_model = new QuizModel();
1906 $attempts = $quiz_model->quiz_attempts( $quiz_id, $user_id );
1907
1908 if ( empty( $attempts ) ) {
1909 return;
1910 }
1911
1912 $attempts_list = QuizModel::format_quiz_attempts( $attempts, '' );
1913
1914 if ( empty( $attempts_list ) ) {
1915 return;
1916 }
1917
1918 $attempts_count = count( $attempts_list );
1919 $quiz_attempt_obj = new Quiz_Attempts_List( false );
1920 ?>
1921 <div class="tutor-quiz-attempts tutor-border tutor-rounded-2xl">
1922 <div class="tutor-quiz-attempts-header">
1923 <div class="tutor-quiz-attempts-header-item">
1924 <?php esc_html_e( 'Attempts', 'tutor' ); ?>
1925 </div>
1926 <div class="tutor-quiz-attempts-header-item">
1927 <?php esc_html_e( 'Marks', 'tutor' ); ?>
1928 </div>
1929 <div class="tutor-quiz-attempts-header-item">
1930 <?php esc_html_e( 'Time', 'tutor' ); ?>
1931 </div>
1932 <div class="tutor-quiz-attempts-header-item">
1933 <?php esc_html_e( 'Result', 'tutor' ); ?>
1934 </div>
1935 </div>
1936
1937 <div class="tutor-quiz-attempts-list">
1938 <?php
1939 foreach ( $attempts_list as $index => $attempt ) {
1940 $attempt_number = $attempts_count - $index;
1941 ?>
1942 <div class="tutor-quiz-attempts-item-wrapper">
1943 <?php
1944 tutor_load_template(
1945 'shared.components.student-quiz-attempt-row',
1946 array(
1947 'attempt' => $attempt,
1948 'attempt_number' => $attempt_number,
1949 'quiz_id' => $attempt['quiz_id'] ?? 0,
1950 'course_id' => $attempt['course_id'] ?? 0,
1951 'quiz_attempt_obj' => $quiz_attempt_obj,
1952 'is_previous' => false,
1953 'is_learning_area' => true,
1954 )
1955 );
1956 ?>
1957
1958 <div class="tutor-quiz-item-actions">
1959 <?php $quiz_attempt_obj->render_details_button( $attempt, true ); ?>
1960 </div>
1961 </div>
1962 <?php
1963 }
1964 ?>
1965 </div>
1966 </div>
1967 <?php
1968 }
1969
1970 /**
1971 * Render quiz actions
1972 *
1973 * @since 4.0.0
1974 *
1975 * @param int $quiz_id Quiz ID.
1976 *
1977 * @return void
1978 */
1979 public static function render_quiz_actions( $quiz_id ) {
1980 $quiz_id = tutor_utils()->get_post_id( $quiz_id );
1981 if ( ! $quiz_id ) {
1982 return;
1983 }
1984
1985 $quiz_settings = tutor_utils()->get_quiz_option( $quiz_id );
1986 $user_id = get_current_user_id();
1987 $quiz_model = new QuizModel();
1988 $attempts = $quiz_model->quiz_attempts( $quiz_id, $user_id );
1989 $attempted_count = (int) tutor_utils()->count( $attempts );
1990 $limit_attempts_allowed = '1' === (string) ( $quiz_settings['limit_attempts_allowed'] ?? '0' );
1991 $attempts_allowed = (int) ( $quiz_settings['attempts_allowed'] ?? 0 );
1992 $can_start_quiz = 0 === $attempted_count || self::can_retry_quiz( $limit_attempts_allowed, $attempts_allowed, $attempted_count );
1993 $quiz_auto_start = $quiz_settings['quiz_auto_start'] ?? 0;
1994 $auto_start_delay = (int) ( $quiz_settings['auto_start_delay'] ?? 5 );
1995 $should_auto_start = 1 === (int) $quiz_auto_start && 0 === (int) $attempted_count;
1996
1997 global $tutor_current_post, $tutor_course_id;
1998 $current_content_id = $tutor_current_post ? $tutor_current_post->ID : $quiz_id;
1999 $course_id = $tutor_course_id ? $tutor_course_id : tutor_utils()->get_course_id_by_subcontent( $current_content_id );
2000 $contents = tutor_utils()->get_course_prev_next_contents_by_id( $current_content_id );
2001 $next_id = $contents ? $contents->next_id : 0;
2002 $skip_url = get_the_permalink( $next_id ? $next_id : $course_id );
2003 $skip_modal_id = 'tutor-quiz-skip-to-next';
2004 $auto_start_modal_id = 'tutor-quiz-autostart-modal';
2005 $retry_modal_id = 'tutor-quiz-retry-modal-' . $quiz_id;
2006 $show_retry_modal = $attempted_count > 0;
2007
2008 $can_skip_quiz = ( 0 === $attempted_count );
2009 $show_continue = ( $attempted_count > 0 && $next_id );
2010 $has_any_action = $can_skip_quiz || $show_continue || $can_start_quiz;
2011
2012 if ( ! $has_any_action ) {
2013 return;
2014 }
2015 ?>
2016 <div class="tutor-learning-area-footer">
2017 <?php
2018 if ( $can_skip_quiz ) {
2019 Button::make()
2020 ->label( __( 'Skip Quiz', 'tutor' ) )
2021 ->variant( Variant::GHOST )
2022 ->attr( '@click', "TutorCore.modal.showModal('$skip_modal_id')" )
2023 ->render();
2024
2025 $skip_modal_confirm_button = Button::make()
2026 ->tag( 'a' )
2027 ->label( __( 'Yes, Skip This', 'tutor' ) )
2028 ->variant( Variant::DESTRUCTIVE )
2029 ->size( Size::SMALL )
2030 ->attr( 'href', esc_url( $skip_url ) )
2031 ->get();
2032
2033 $skip_modal_cancel_button = Button::make()
2034 ->label( __( 'Cancel', 'tutor' ) )
2035 ->variant( Variant::SECONDARY )
2036 ->size( Size::SMALL )
2037 ->attr( '@click', "TutorCore.modal.closeModal('$skip_modal_id')" )
2038 ->get();
2039
2040 ConfirmationModal::make()
2041 ->id( $skip_modal_id )
2042 ->icon( tutor_utils()->get_themed_svg( 'images/illustrations/warning.svg' ), 80, 80, ConfirmationModal::ICON_TYPE_HTML )
2043 ->title( __( 'Do You Want to Skip This Quiz?', 'tutor' ) )
2044 ->message( __( 'Are you sure you want to skip this quiz? Please confirm your choice.', 'tutor' ) )
2045 ->confirm_button( $skip_modal_confirm_button )
2046 ->cancel_button( $skip_modal_cancel_button )
2047 ->render();
2048 }
2049 ?>
2050
2051 <?php if ( $can_start_quiz ) : ?>
2052 <?php if ( $show_retry_modal ) : ?>
2053 <?php
2054 Button::make()
2055 ->label( __( 'Retry Quiz', 'tutor' ) )
2056 ->variant( $show_continue ? Variant::GHOST : Variant::PRIMARY )
2057 ->attr( 'type', 'button' )
2058 ->attr(
2059 '@click',
2060 sprintf(
2061 'TutorCore.modal.showModal("%s", { data: %s });',
2062 $retry_modal_id,
2063 wp_json_encode(
2064 array(
2065 'quizID' => $quiz_id,
2066 'redirectURL' => get_post_permalink( $quiz_id ),
2067 )
2068 )
2069 )
2070 )
2071 ->render();
2072 ?>
2073 <?php else : ?>
2074 <form
2075 x-data="tutorQuizAutoStart({
2076 quizID: <?php echo esc_attr( $quiz_id ); ?>,
2077 autoStart: <?php echo $should_auto_start ? 'true' : 'false'; ?>,
2078 autoStartModalId: '<?php echo esc_attr( $auto_start_modal_id ); ?>',
2079 countdownSeconds: <?php echo esc_attr( $auto_start_delay ); ?>,
2080 })"
2081 @submit.prevent="handleStartQuiz()"
2082 >
2083 <?php
2084 Button::make()
2085 ->label( __( 'Start Quiz', 'tutor' ) )
2086 ->attr( 'x-bind:disabled', 'startQuizMutation?.isPending' )
2087 ->attr( ':class', "{ 'tutor-btn-loading': startQuizMutation?.isPending }" )
2088 ->render();
2089 ?>
2090 </form>
2091 <?php endif; ?>
2092 <?php endif; ?>
2093
2094 <?php
2095 if ( $show_continue ) {
2096 Button::make()
2097 ->tag( 'a' )
2098 ->label( __( 'Continue Lesson', 'tutor' ) )
2099 ->attr( 'href', esc_url( get_the_permalink( $next_id ) ) )
2100 ->render();
2101 }
2102 ?>
2103 </div>
2104
2105 <?php if ( $show_retry_modal ) : ?>
2106 <div x-data="tutorQuizRetryAttempt()">
2107 <?php
2108 ConfirmationModal::make()
2109 ->id( $retry_modal_id )
2110 ->title( __( 'Retry Quiz?', 'tutor' ) )
2111 ->icon( tutor_utils()->get_themed_svg( 'images/illustrations/quiz-retry.svg' ), 80, 80, ConfirmationModal::ICON_TYPE_HTML )
2112 ->message( __( 'Retrying this quiz will reset your current attempt. Your answers and score from this attempt will be lost.', 'tutor' ) )
2113 ->confirm_handler( 'retryMutation?.mutate({...payload?.data})' )
2114 ->confirm_text( __( 'Retry Quiz', 'tutor' ) )
2115 ->mutation_state( 'retryMutation' )
2116 ->render();
2117 ?>
2118 </div>
2119 <?php endif; ?>
2120
2121 <?php
2122 Modal::make()
2123 ->id( $auto_start_modal_id )
2124 ->closeable( false )
2125 ->width( '268px' )
2126 ->template(
2127 tutor()->path . 'templates/learning-area/quiz/modals/auto-start.php',
2128 array(
2129 'countdown_seconds' => $auto_start_delay,
2130 'modal_id' => $auto_start_modal_id,
2131 )
2132 )
2133 ->render();
2134 ?>
2135 <?php
2136 }
2137
2138 /**
2139 * Render individual question template
2140 *
2141 * @since 4.0.0
2142 *
2143 * @param object $question Question data.
2144 * @param int $index Question index.
2145 *
2146 * @return void
2147 */
2148 public static function render_question( $question, $index = 0 ) {
2149 $question_settings = maybe_unserialize( $question->question_settings );
2150 $question_type = $question->question_type;
2151 $rand_choice = ! empty( $question_settings['randomize_question'] )
2152 && '1' === $question_settings['randomize_question'];
2153
2154 // Normalize question type + settings.
2155 switch ( $question->question_type ) {
2156 case 'short_answer':
2157 $question->question_type = 'open_ended';
2158 break;
2159
2160 case 'single_choice':
2161 $question->question_type = 'multiple_choice';
2162 $question_settings['has_multiple_correct_answer'] = '0';
2163 break;
2164
2165 case 'image_matching':
2166 $question->question_type = 'matching';
2167 $question_settings['is_image_matching'] = '1';
2168 break;
2169 }
2170
2171 $template = str_replace( '_', '-', $question->question_type );
2172 $answers = self::prepare_question_answers( (int) $question->question_id, $question_type, $rand_choice );
2173
2174 $question->index = $index;
2175 $question->question_settings = $question_settings;
2176 $question->question_answers = $answers['question_answers'];
2177 $question->question_randomized_answers = $answers['question_randomized_answers'];
2178
2179 tutor_load_template(
2180 'learning-area.quiz.question',
2181 array(
2182 'question' => $question,
2183 'question_settings' => $question_settings,
2184 'question_type' => $template,
2185 )
2186 );
2187 }
2188
2189 /**
2190 * Get question answers prepared for render.
2191 *
2192 * @since 4.0.0
2193 *
2194 * @param int $question_id Question ID.
2195 * @param string $question_type Original question type.
2196 * @param bool $rand_choice Whether randomized choices are enabled.
2197 *
2198 * @return array{question_answers: array, question_randomized_answers: array}
2199 */
2200 private static function prepare_question_answers( int $question_id, string $question_type, bool $rand_choice ): array {
2201 $question_answers = QuizModel::get_answers_by_quiz_question( $question_id );
2202 $question_randomized_answers = array();
2203
2204 // Ordering questions always use a randomized answer list.
2205 // Matching and image matching keep the drop-zone items ordered and only shuffle the draggable choices.
2206
2207 if ( 'ordering' === $question_type ) {
2208 $question_answers = QuizModel::get_answers_by_quiz_question( $question_id, true );
2209 } elseif ( 'matching' === $question_type || 'image_matching' === $question_type ) {
2210 $question_randomized_answers = QuizModel::get_answers_by_quiz_question( $question_id, $rand_choice );
2211 } elseif ( $rand_choice ) {
2212 $question_answers = QuizModel::get_answers_by_quiz_question( $question_id, true );
2213 }
2214
2215 $question_answers = array_map(
2216 static fn( $answer ) => (array) $answer,
2217 $question_answers
2218 );
2219 $question_randomized_answers = array_map(
2220 static fn( $answer ) => (array) $answer,
2221 $question_randomized_answers
2222 );
2223
2224 return array(
2225 'question_answers' => $question_answers,
2226 'question_randomized_answers' => $question_randomized_answers,
2227 );
2228 }
2229
2230 /**
2231 * Renders the assignment status icon for the learning area navigation.
2232 *
2233 * @since 4.0.0
2234 *
2235 * @param WP_Post $quiz The assignment post object.
2236 * @param bool $can_access Whether the current user can access the assignment.
2237 * @param int $tutor_current_content_id Current content id.
2238 *
2239 * @return void
2240 */
2241 public static function render_sidebar_nav( WP_Post $quiz, $can_access, $tutor_current_content_id ) {
2242 $quiz_title = $quiz->post_title;
2243
2244 $active_class = $tutor_current_content_id === $quiz->ID ? 'active' : '';
2245 $disabled_class = $can_access ? '' : 'disabled';
2246
2247 $quiz_status = '';
2248 $icon_name = Icon::QUIZ_2;
2249 if ( ! $can_access ) {
2250 $icon_name = Icon::LOCK_STROKE_2;
2251 } else {
2252 $last_attempt = ( new QuizModel() )->get_first_or_last_attempt( $quiz->ID );
2253 $attempt_ended = is_object( $last_attempt ) && QuizModel::ATTEMPT_STARTED !== $last_attempt->attempt_status;
2254
2255 $quiz_result = QuizModel::get_quiz_result( $quiz->ID );
2256 if ( $attempt_ended && QuizModel::ATTEMPT_STARTED !== $last_attempt->attempt_status ) {
2257 if ( QuizModel::RESULT_FAIL === $quiz_result ) {
2258 $icon_name = Icon::CROSS_COLORIZE;
2259 $quiz_status = QuizModel::RESULT_FAIL;
2260 } elseif ( QuizModel::RESULT_PENDING === $quiz_result ) {
2261 $icon_name = Icon::INFO_COLORIZE;
2262 $quiz_status = QuizModel::RESULT_PENDING;
2263 } elseif ( QuizModel::RESULT_PASS === $quiz_result ) {
2264 $icon_name = Icon::COMPLETED_COLORIZE;
2265 }
2266 }
2267 }
2268 ?>
2269
2270 <a
2271 href="<?php echo esc_url( $can_access ? get_permalink( $quiz->ID ) : '#' ); ?>"
2272 title="<?php echo esc_attr( $quiz_title ); ?>"
2273 class="<?php echo esc_html( sprintf( 'tutor-learning-nav-item %s %s %s', $active_class, $disabled_class, $quiz_status ) ); ?>"
2274 <?php echo ! $can_access ? 'aria-disabled="true"' : ''; ?>
2275 >
2276 <?php SvgIcon::make()->name( $icon_name )->size( 20 )->render(); ?>
2277 <div class="tutor-overflow-hidden">
2278 <div class="tutor-truncate"><?php echo esc_html( $quiz_title ); ?></div>
2279 <div class="tutor-tiny-2 tutor-text-subdued"><?php esc_html_e( 'Quiz', 'tutor' ); ?></div>
2280 </div>
2281 </a>
2282 <?php
2283 }
2284 }
2285