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