PluginProbe
Tutor LMS – eLearning and online course solution / 3.9.2
Tutor LMS – eLearning and online course solution v3.9.2
4.0.7 4.0.6 4.0.5 4.0.4 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 All 191 releases
tutor / models / QuizModel.php

QuizModel.php in Tutor LMS – eLearning and online course solution 3.9.2, at models/QuizModel.php

1,224 lines 33.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Quiz Model
4 *
5 * @package Tutor\Models
6 * @author Themeum <support@themeum.com>
7 * @link https://themeum.com
8 * @since 2.0.10
9 */
10
11 namespace Tutor\Models;
12
13 use Tutor\Cache\TutorCache;
14 use Tutor\Helpers\QueryHelper;
15 use TUTOR\Quiz;
16
17 /**
18 * Class QuizModel
19 *
20 * @since 2.0.10
21 */
22 class QuizModel {
23
24 const ATTEMPT_STARTED = 'attempt_started';
25 const ATTEMPT_ENDED = 'attempt_ended';
26 const REVIEW_REQUIRED = 'review_required';
27 const ATTEMPT_TIMEOUT = 'attempt_timeout';
28
29 const RESULT_PASS = 'pass';
30 const RESULT_FAIL = 'fail';
31 const RESULT_PENDING = 'pending';
32
33 /**
34 * Get quiz table name
35 *
36 * @since 2.1.0
37 *
38 * @return string
39 */
40 public function get_table(): string {
41 global $wpdb;
42 return $wpdb->prefix . 'tutor_quiz_attempts';
43 }
44
45 /**
46 * Get manual review types.
47 *
48 * @since 3.8.0
49 *
50 * @return array
51 */
52 public static function get_manual_review_types() {
53 return array( 'open_ended', 'short_answer' );
54 }
55
56 /**
57 * Get total number of quiz
58 *
59 * @since 2.0.2
60 *
61 * @since 3.7.1 Course ids param added
62 *
63 * @param array $course_ids Array of course ids.
64 *
65 * @return int
66 */
67 public static function get_total_quiz( array $course_ids = array() ) {
68 global $wpdb;
69
70 $course_in_clause = '';
71 if ( count( $course_ids ) ) {
72 $prepare_ids = QueryHelper::prepare_in_clause( $course_ids );
73 $course_in_clause = "AND course.ID IN ({$prepare_ids})";
74 }
75
76 $sql = "SELECT COUNT(DISTINCT quiz.ID)
77 FROM {$wpdb->posts} quiz
78 INNER JOIN {$wpdb->posts} topic ON quiz.post_parent=topic.ID
79 INNER JOIN {$wpdb->posts} course ON topic.post_parent=course.ID
80 WHERE 1 = 1
81 {$course_in_clause}
82 AND course.post_type=%s
83 AND course.post_status = 'publish'
84 AND quiz.post_type='tutor_quiz'";
85
86 //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
87 return $wpdb->get_var( $wpdb->prepare( $sql, tutor()->course_post_type ) );
88 }
89
90 /**
91 * Get Attempt row by grade method settings
92 *
93 * @since 1.4.2
94 *
95 * @param int $quiz_id quiz id.
96 * @param int $user_id user id.
97 *
98 * @return array|bool|null|object
99 */
100 public function get_quiz_attempt( $quiz_id = 0, $user_id = 0 ) {
101 global $wpdb;
102
103 $quiz_id = tutils()->get_post_id( $quiz_id );
104 $user_id = tutils()->get_user_id( $user_id );
105
106 $attempt = false;
107
108 $quiz_grade_method = get_tutor_option( 'quiz_grade_method', 'highest_grade' );
109 $from_string = "FROM {$wpdb->tutor_quiz_attempts} WHERE quiz_id = %d AND user_id = %d AND attempt_status != 'attempt_started' ";
110
111 //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
112 if ( 'highest_grade' === $quiz_grade_method ) {
113 $attempt = $wpdb->get_row( $wpdb->prepare( "SELECT * {$from_string} ORDER BY earned_marks DESC LIMIT 1; ", $quiz_id, $user_id ) );
114 } elseif ( 'average_grade' === $quiz_grade_method ) {
115
116 $attempt = $wpdb->get_row(
117 $wpdb->prepare(
118 "SELECT {$wpdb->tutor_quiz_attempts}.*,
119 COUNT(attempt_id) AS attempt_count,
120 AVG(total_marks) AS total_marks,
121 AVG(earned_marks) AS earned_marks {$from_string}
122 ",
123 $quiz_id,
124 $user_id
125 )
126 );
127 } elseif ( 'first_attempt' === $quiz_grade_method ) {
128
129 $attempt = $wpdb->get_row( $wpdb->prepare( "SELECT * {$from_string} ORDER BY attempt_id ASC LIMIT 1; ", $quiz_id, $user_id ) );
130 } elseif ( 'last_attempt' === $quiz_grade_method ) {
131
132 $attempt = $wpdb->get_row( $wpdb->prepare( "SELECT * {$from_string} ORDER BY attempt_id DESC LIMIT 1; ", $quiz_id, $user_id ) );
133 }
134 //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
135
136 return $attempt;
137 }
138
139 /**
140 * Get all of the attempts by an user of a quiz
141 *
142 * @since 1.0.0
143 *
144 * @param int $quiz_id quiz ID.
145 * @param int $user_id user ID.
146 *
147 * @return array|bool|null|object
148 */
149 public function quiz_attempts( $quiz_id = 0, $user_id = 0 ) {
150 global $wpdb;
151
152 $quiz_id = tutor_utils()->get_post_id( $quiz_id );
153 $user_id = tutor_utils()->get_user_id( $user_id );
154
155 $cache_key = "tutor_quiz_attempts_for_{$user_id}_{$quiz_id}";
156 $attempts = TutorCache::get( $cache_key );
157
158 if ( false === $attempts ) {
159 $attempts = $wpdb->get_results(
160 $wpdb->prepare(
161 "SELECT *
162 FROM {$wpdb->prefix}tutor_quiz_attempts
163 WHERE quiz_id = %d
164 AND user_id = %d
165 ORDER BY attempt_id DESC
166 ",
167 $quiz_id,
168 $user_id
169 )
170 );
171 TutorCache::set( $cache_key, $attempts );
172 }
173
174 if ( is_array( $attempts ) && count( $attempts ) ) {
175 return $attempts;
176 }
177
178 return false;
179 }
180
181 /**
182 * Get Quiz question by question id
183 *
184 * @since 1.0.0
185 *
186 * @param int $question_id question ID.
187 *
188 * @return array|bool|object|void|null
189 */
190 public static function get_quiz_question_by_id( $question_id = 0 ) {
191 global $wpdb;
192
193 if ( $question_id ) {
194 $question = $wpdb->get_row(
195 $wpdb->prepare(
196 "SELECT *
197 FROM {$wpdb->prefix}tutor_quiz_questions
198 WHERE question_id = %d
199 LIMIT 0, 1;
200 ",
201 $question_id
202 )
203 );
204
205 return $question;
206 }
207
208 return false;
209 }
210
211 /**
212 * Get all ended attempts by an user of a quiz
213 *
214 * @since 1.4.1
215 *
216 * @param int $quiz_id quiz ID.
217 * @param int $user_id user ID.
218 *
219 * @return array|bool|null|object
220 */
221 public function quiz_ended_attempts( $quiz_id = 0, $user_id = 0 ) {
222 global $wpdb;
223
224 $quiz_id = tutor_utils()->get_post_id( $quiz_id );
225 $user_id = tutor_utils()->get_user_id( $user_id );
226
227 $attempts = $wpdb->get_results(
228 $wpdb->prepare(
229 "SELECT *
230 FROM {$wpdb->prefix}tutor_quiz_attempts
231 WHERE quiz_id = %d
232 AND user_id = %d
233 AND attempt_status != %s
234 ",
235 $quiz_id,
236 $user_id,
237 'attempt_started'
238 )
239 );
240
241 if ( is_array( $attempts ) && count( $attempts ) ) {
242 return $attempts;
243 }
244
245 return false;
246 }
247
248 /**
249 * Get the next question order ID
250 *
251 * @since 1.0.0
252 *
253 * @param integer $quiz_id quiz ID.
254 *
255 * @return int
256 */
257 public static function quiz_next_question_order_id( $quiz_id ) {
258 global $wpdb;
259
260 $last_order = (int) $wpdb->get_var(
261 $wpdb->prepare(
262 "SELECT MAX(question_order)
263 FROM {$wpdb->prefix}tutor_quiz_questions
264 WHERE quiz_id = %d ;
265 ",
266 $quiz_id
267 )
268 );
269
270 return $last_order + 1;
271 }
272
273 /**
274 * Get next quiz question ID
275 *
276 * @since 1.0.0
277 *
278 * @return int
279 */
280 public static function quiz_next_question_id() {
281 global $wpdb;
282
283 $last_order = (int) $wpdb->get_var( "SELECT MAX(question_id) FROM {$wpdb->prefix}tutor_quiz_questions;" );
284 return $last_order + 1;
285 }
286
287 /**
288 * Total number of quiz attempts
289 *
290 * @since 1.0.0
291 *
292 * @param string $search_term search term.
293 * @param integer $course_id course ID.
294 * @param string $tab tab.
295 * @param string $date_filter date filter.
296 *
297 * @return int
298 */
299 public static function get_total_quiz_attempts( $search_term = '', int $course_id = 0, string $tab = '', $date_filter = '' ) {
300 global $wpdb;
301
302 // Prepare search term.
303 $search_term = empty( $search_term ) ? '' : '%' . $wpdb->esc_like( $search_term ) . '%';
304
305 // Prepare course filter.
306 $course_filter = $course_id ? $wpdb->prepare( ' AND quiz_attempts.course_id = %d', $course_id ) : '';
307
308 // Prepare date filter.
309 $date_filter = empty( $date_filter ) ? '' : tutor_get_formated_date( 'Y-m-d', $date_filter );
310 $date_filter = empty( $date_filter ) ? '' : $wpdb->prepare( ' AND DATE(quiz_attempts.attempt_started_at) = %s ', $date_filter );
311
312 // Prepare user join and clause.
313 $user_join = '';
314 $user_clause = '';
315 if ( '' !== $search_term ) {
316 $user_join = "INNER JOIN {$wpdb->users} ON quiz_attempts.user_id = {$wpdb->users}.ID";
317 $user_clause = $wpdb->prepare(
318 " AND ( {$wpdb->users}.user_email LIKE %s OR {$wpdb->users}.display_name LIKE %s )",
319 $search_term,
320 $search_term
321 );
322 }
323
324 // Prepare tab filter.
325 $tab_clause = '';
326 if ( in_array( $tab, array( self::RESULT_PASS, self::RESULT_FAIL, self::RESULT_PENDING ), true ) ) {
327 $tab_clause = $wpdb->prepare( ' AND quiz_attempts.result = %s', $tab );
328 }
329
330 //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
331 $count = $wpdb->get_var(
332 $wpdb->prepare(
333 "SELECT COUNT(DISTINCT quiz_attempts.attempt_id) AS total
334 FROM {$wpdb->prefix}tutor_quiz_attempts quiz_attempts
335 INNER JOIN {$wpdb->posts} quiz ON quiz_attempts.quiz_id = quiz.ID
336 INNER JOIN {$wpdb->posts} AS course ON course.ID = quiz_attempts.course_id
337 {$user_join}
338 WHERE attempt_status != %s AND quiz_attempts.result IS NOT NULL
339 {$user_clause}
340 {$course_filter}
341 {$tab_clause}
342 {$date_filter}",
343 'attempt_started'
344 )
345 );
346 //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
347
348 return (int) $count;
349 }
350
351 /**
352 * Get the all quiz attempts
353 *
354 * @since 1.0.0
355 * @since 1.9.5 sorting params added.
356 * @since 3.8.0 refactor and query optimize.
357 *
358 * @param integer $start start.
359 * @param integer $limit limit.
360 * @param string $search_filter search filter.
361 * @param string $course_filter course filter.
362 * @param string $date_filter date filter.
363 * @param string $order_filter order filter.
364 * @param mixed $result_state result state.
365 * @param boolean $count_only count only or not.
366 * @param boolean $instructor_id_check need instructor id check or not.
367 *
368 * @return mixed
369 */
370 public static function get_quiz_attempts( $start = 0, $limit = 10, $search_filter = '', $course_filter = array(), $date_filter = '', $order_filter = 'DESC', $result_state = null, $count_only = false, $instructor_id_check = false ) {
371 global $wpdb;
372
373 $start = (int) $start;
374 $limit = (int) $limit;
375 $search_filter = sanitize_text_field( $search_filter );
376 $course_filter = sanitize_text_field( $course_filter );
377 $date_filter = sanitize_text_field( $date_filter );
378 $order_filter = sanitize_sql_orderby( $order_filter );
379
380 $search_term_raw = $search_filter;
381 $search_filter = '%' . $wpdb->esc_like( $search_filter ) . '%';
382
383 // Filter by course.
384 if ( '' !== $course_filter ) {
385 if ( ! is_array( $course_filter ) ) {
386 $course_filter = array( $course_filter );
387 }
388
389 $course_ids = QueryHelper::prepare_in_clause( array_map( 'intval', $course_filter ) );
390 $course_filter = " AND quiz_attempts.course_id IN ($course_ids) ";
391 }
392
393 // Filter by date.
394 $date_filter = '' !== $date_filter ? tutor_get_formated_date( 'Y-m-d', $date_filter ) : '';
395 $date_filter = '' !== $date_filter ? $wpdb->prepare( ' AND DATE(quiz_attempts.attempt_started_at) = %s ', $date_filter ) : '';
396
397 $result_clause = '';
398 $select_columns = $count_only ? 'COUNT(DISTINCT quiz_attempts.attempt_id)' : 'DISTINCT quiz_attempts.*, quiz.post_title, users.user_email, users.user_login, users.display_name';
399 $limit_offset = $count_only ? '' : $wpdb->prepare( ' LIMIT %d OFFSET %d', $limit, $start );
400
401 // Get attempts by instructor ID.
402 $instructor_clause = '';
403 if ( $instructor_id_check ) {
404 $current_user_id = get_current_user_id();
405 $instructor_id = tutor_utils()->has_user_role( 'administrator', $current_user_id ) ? null : $current_user_id;
406
407 if ( $instructor_id ) {
408 $instructor_clause = " INNER JOIN {$wpdb->usermeta} AS instructor_meta ON course.ID = instructor_meta.meta_value AND (instructor_meta.meta_key='_tutor_instructor_course_id' AND instructor_meta.user_id=$instructor_id) ";
409 }
410 }
411
412 $result_clause = '';
413 if ( in_array( $result_state, array( self::RESULT_PASS, self::RESULT_FAIL, self::RESULT_PENDING ), true ) ) {
414 $result_clause = $wpdb->prepare( ' AND quiz_attempts.result = %s', $result_state );
415 }
416
417 //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
418 $query = $wpdb->prepare(
419 "SELECT {$select_columns}
420 FROM {$wpdb->prefix}tutor_quiz_attempts quiz_attempts
421 INNER JOIN {$wpdb->posts} quiz ON quiz_attempts.quiz_id = quiz.ID
422 INNER JOIN {$wpdb->users} AS users ON quiz_attempts.user_id = users.ID
423 INNER JOIN {$wpdb->posts} AS course ON course.ID = quiz_attempts.course_id
424 {$instructor_clause}
425 WHERE quiz_attempts.attempt_ended_at IS NOT NULL
426 AND (
427 users.user_email = %s
428 OR users.display_name LIKE %s
429 OR quiz.post_title LIKE %s
430 OR course.post_title LIKE %s
431 )
432 AND quiz_attempts.attempt_ended_at IS NOT NULL
433 {$result_clause}
434 {$course_filter}
435 {$date_filter}
436 ORDER BY quiz_attempts.attempt_ended_at {$order_filter} {$limit_offset}",
437 $search_term_raw,
438 $search_filter,
439 $search_filter,
440 $search_filter
441 );
442 //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
443
444 //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
445 return $count_only ? $wpdb->get_var( $query ) : $wpdb->get_results( $query );
446 }
447
448 /**
449 * Delete quizattempt for user
450 *
451 * @since 1.9.5
452 *
453 * @param mixed $attempt_ids attempt ids.
454 *
455 * @return void
456 */
457 public static function delete_quiz_attempt( $attempt_ids ) {
458 global $wpdb;
459
460 // Singlular to array.
461 ! is_array( $attempt_ids ) ? $attempt_ids = array( $attempt_ids ) : 0;
462
463 if ( count( $attempt_ids ) ) {
464 $attempt_ids = implode( ',', $attempt_ids );
465
466 //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
467 // Deleting attempt (comment), child attempt and attempt meta (comment meta).
468 $wpdb->query( "DELETE FROM {$wpdb->prefix}tutor_quiz_attempts WHERE attempt_id IN($attempt_ids)" );
469 $wpdb->query( "DELETE FROM {$wpdb->prefix}tutor_quiz_attempt_answers WHERE quiz_attempt_id IN($attempt_ids)" );
470 //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
471
472 do_action( 'tutor_quiz/attempt_deleted', $attempt_ids );
473 }
474 }
475
476 /**
477 * Sorting params added on quiz attempt
478 *
479 * @since 1.9.5
480 *
481 * @param integer $start start.
482 * @param integer $limit limit.
483 * @param array $course_ids course ids.
484 * @param string $search_filter search filter.
485 * @param string $course_filter course filter.
486 * @param string $date_filter date filter.
487 * @param string $order_filter order filter.
488 * @param mixed $user_id user id.
489 * @param boolean $count_only is only count or not.
490 * @param boolean $all_attempt need all atempt or not.
491 *
492 * @return mixed
493 */
494 public static function get_quiz_attempts_by_course_ids( $start = 0, $limit = 10, $course_ids = array(), $search_filter = '', $course_filter = '', $date_filter = '', $order_filter = '', $user_id = null, $count_only = false, $all_attempt = false ) {
495 global $wpdb;
496 $search_filter = sanitize_text_field( $search_filter );
497 $course_filter = (int) sanitize_text_field( $course_filter );
498 $date_filter = sanitize_text_field( $date_filter );
499 $order_filter = sanitize_sql_orderby( $order_filter );
500
501 $course_ids = array_map(
502 function ( $id ) {
503 return "'" . esc_sql( $id ) . "'";
504 },
505 $course_ids
506 );
507
508 $course_ids_in = count( $course_ids ) ? ' AND quiz_attempts.course_id IN (' . implode( ', ', $course_ids ) . ') ' : '';
509
510 $search_filter = $search_filter ? '%' . $wpdb->esc_like( $search_filter ) . '%' : '';
511 $search_term_raw = $search_filter;
512 $search_filter = $search_filter ? "AND ( users.user_email = '{$search_term_raw}' OR users.display_name LIKE {$search_filter} OR quiz.post_title LIKE {$search_filter} OR course.post_title LIKE {$search_filter} )" : '';
513
514 $course_filter = 0 !== $course_filter ? " AND quiz_attempts.course_id = $course_filter " : '';
515 $date_filter = '' != $date_filter ? tutor_get_formated_date( 'Y-m-d', $date_filter ) : '';
516 $date_filter = '' != $date_filter ? " AND DATE(quiz_attempts.attempt_started_at) = '$date_filter' " : '';
517 $user_filter = $user_id ? ' AND user_id=\'' . esc_sql( $user_id ) . '\' ' : '';
518
519 $limit_offset = $count_only ? '' : " LIMIT {$start}, {$limit} ";
520 $select_col = $count_only ? ' COUNT(DISTINCT quiz_attempts.attempt_id) ' : ' quiz_attempts.*, users.*, quiz.* ';
521
522 $attempt_type = $all_attempt ? '' : " AND quiz_attempts.attempt_status != 'attempt_started' ";
523
524 $query = "SELECT {$select_col}
525 FROM {$wpdb->prefix}tutor_quiz_attempts AS quiz_attempts
526 INNER JOIN {$wpdb->posts} AS quiz
527 ON quiz_attempts.quiz_id = quiz.ID
528 INNER JOIN {$wpdb->users} AS users
529 ON quiz_attempts.user_id = users.ID
530 INNER JOIN {$wpdb->posts} AS course
531 ON course.ID = quiz_attempts.course_id
532 WHERE 1=1
533 {$attempt_type}
534 {$course_ids_in}
535 {$search_filter}
536 {$course_filter}
537 {$date_filter}
538 {$user_filter}
539 ORDER BY quiz_attempts.attempt_id {$order_filter} {$limit_offset};";
540
541 //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
542 return $count_only ? $wpdb->get_var( $query ) : $wpdb->get_results( $query );
543 }
544
545 /**
546 * Get answers list by quiz question
547 *
548 * @since 1.0.0
549 *
550 * @param int $question_id question ID.
551 * @param bool $rand rand.
552 *
553 * @return array|bool|null|object
554 */
555 public static function get_answers_by_quiz_question( $question_id, $rand = false ) {
556 global $wpdb;
557
558 $question = $wpdb->get_row(
559 $wpdb->prepare(
560 "SELECT *
561 FROM {$wpdb->prefix}tutor_quiz_questions
562 WHERE question_id = %d;
563 ",
564 $question_id
565 )
566 );
567
568 if ( ! $question ) {
569 return false;
570 }
571
572 $order = ' answer_order ASC ';
573
574 if ( $rand ) {
575 $order = ' RAND() ';
576 }
577
578 //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
579 $answers = $wpdb->get_results(
580 $wpdb->prepare(
581 "SELECT *
582 FROM {$wpdb->prefix}tutor_quiz_question_answers
583 WHERE belongs_question_id = %d
584 AND belongs_question_type = %s
585 ORDER BY {$order}
586 ",
587 $question_id,
588 $question->question_type
589 )
590 );
591 //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
592
593 return $answers;
594 }
595
596 /**
597 * Get quiz answers by attempt id
598 *
599 * @since 1.0.0
600 *
601 * @param mixed $attempt_id attempt ID.
602 * @param bool $add_index need index or not.
603 *
604 * @return array|null|object
605 */
606 public static function get_quiz_answers_by_attempt_id( $attempt_id, $add_index = false ) {
607 global $wpdb;
608
609 $ids = is_array( $attempt_id ) ? $attempt_id : array( $attempt_id );
610 $ids_in = implode( ',', $ids );
611
612 if ( empty( $ids_in ) ) {
613 // Prevent empty.
614 return array();
615 }
616
617 //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
618 $results = $wpdb->get_results(
619 "SELECT answers.*,
620 question.*
621 FROM {$wpdb->prefix}tutor_quiz_attempt_answers answers
622 LEFT JOIN {$wpdb->prefix}tutor_quiz_questions question
623 ON answers.question_id = question.question_id
624 WHERE answers.quiz_attempt_id IN ({$ids_in})
625 ORDER BY attempt_answer_id ASC;"
626 );
627 //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
628
629 if ( $add_index ) {
630 $new_array = array();
631
632 foreach ( $results as $result ) {
633 ! isset( $new_array[ $result->quiz_attempt_id ] ) ? $new_array[ $result->quiz_attempt_id ] = array() : 0;
634 $new_array[ $result->quiz_attempt_id ][] = $result;
635 }
636
637 return $new_array;
638 }
639
640 return $results;
641 }
642
643 /**
644 * Get single answer by answer_id
645 *
646 * @since 1.0.0
647 *
648 * @param array|init $answer_id answer id.
649 *
650 * @return array|null|object
651 */
652 public static function get_answer_by_id( $answer_id ) {
653 global $wpdb;
654
655 ! is_array( $answer_id ) ? $answer_id = array( $answer_id ) : 0;
656
657 $answer_id = array_map(
658 function ( $id ) {
659 return "'" . esc_sql( $id ) . "'";
660 },
661 $answer_id
662 );
663
664 $in_ids_string = implode( ', ', $answer_id );
665
666 //phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
667 $answer = $wpdb->get_results(
668 $wpdb->prepare(
669 "SELECT answer.*,
670 question.question_title,
671 question.question_type
672 FROM {$wpdb->prefix}tutor_quiz_question_answers answer
673 LEFT JOIN {$wpdb->prefix}tutor_quiz_questions question
674 ON answer.belongs_question_id = question.question_id
675 WHERE answer.answer_id IN (" . $in_ids_string . ')
676 AND 1 = %d;
677 ',
678 1
679 )
680 );
681 //phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
682
683 return $answer;
684 }
685
686 /**
687 * Get quiz attempt timing
688 *
689 * @since 1.0.0
690 *
691 * @param mixed $attempt_data attempt data.
692 * @return array
693 */
694 public static function get_quiz_attempt_timing( $attempt_data ) {
695 $attempt_duration = '';
696 $attempt_duration_taken = '';
697 $attempt_info = @unserialize( $attempt_data->attempt_info );
698 if ( is_array( $attempt_info ) ) {
699 // Allowed duration.
700 if ( isset( $attempt_info['time_limit'] ) ) {
701 //phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
702 $time_type = __( ucwords( tutor_utils()->array_get( 'time_limit.time_type', $attempt_info, 'minutes' ) ), 'tutor' );
703 $time_value = tutor_utils()->array_get( 'time_limit.time_value', $attempt_info, 0 );
704 $attempt_duration = $time_value . ' ' . $time_type;
705 }
706
707 // Taken duration.
708 $seconds = strtotime( $attempt_data->attempt_ended_at ) - strtotime( $attempt_data->attempt_started_at );
709 $attempt_duration_taken = tutor_utils()->seconds_to_time( $seconds );
710 }
711
712 return compact( 'attempt_duration', 'attempt_duration_taken' );
713 }
714
715 /**
716 * Check student is passed in a quiz or not.
717 * Quiz retry mode: student required at least one quiz passed in attempts
718 *
719 * @since 2.1.0
720 *
721 * @param int $quiz_id quiz ID.
722 * @param int $user_id user ID.
723 *
724 * @return boolean
725 */
726 public static function is_quiz_passed( $quiz_id, $user_id = 0 ) {
727 global $wpdb;
728
729 $user_id = tutor_utils()->get_user_id( $user_id );
730 $attempts = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}tutor_quiz_attempts WHERE user_id=%d AND quiz_id=%d", $user_id, $quiz_id ) );
731 $required_percentage = tutor_utils()->get_quiz_option( $quiz_id, 'passing_grade', 0 );
732
733 foreach ( $attempts as $attempt ) {
734 $earned_percentage = self::calculate_attempt_earned_percentage( $attempt );
735 if ( $earned_percentage >= $required_percentage ) {
736 return true;
737 }
738 }
739
740 return false;
741 }
742
743 /**
744 * Get all question type for a quiz
745 *
746 * @since 2.1.0
747 *
748 * @param integer $quiz_id quiz ID.
749 *
750 * @return array
751 */
752 public static function get_quiz_question_types( int $quiz_id ) {
753 global $wpdb;
754 $types = $wpdb->get_col(
755 $wpdb->prepare( "SELECT DISTINCT question_type FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id=%d", $quiz_id )
756 );
757
758 return $types;
759 }
760
761 /**
762 * Check a quiz attempt need manual review or not
763 *
764 * @since 2.1.0
765 *
766 * @param int $quiz_id quiz ID.
767 *
768 * @return boolean
769 */
770 public static function is_manual_review_required( $quiz_id ) {
771 $required = false;
772 $review_question_types = self::get_manual_review_types();
773 $question_types = self::get_quiz_question_types( $quiz_id );
774
775 foreach ( $review_question_types as $type ) {
776 if ( in_array( $type, $question_types, true ) ) {
777 $required = true;
778 break;
779 }
780 }
781
782 return $required;
783 }
784
785 /**
786 * Get last or first quiz attempt
787 *
788 * @since 2.1.0
789 * @since 2.1.3 user_id param added.
790 *
791 * @param integer $quiz_id quiz id to get attempt of.
792 * @param integer $user_id user ID who attempt the quiz.
793 * @param string $order ASC or DESC, default is DESC
794 * pass ASC to get first attempt.
795 *
796 * @return mixed object on success, null on failure
797 */
798 public function get_first_or_last_attempt( int $quiz_id, int $user_id = 0, string $order = 'DESC' ) {
799 $attempt = QueryHelper::get_row(
800 $this->get_table(),
801 array(
802 'quiz_id' => $quiz_id,
803 'user_id' => tutor_utils()->get_user_id( $user_id ),
804 ),
805 'attempt_id',
806 $order
807 );
808 return $attempt;
809 }
810
811 /**
812 * Get total number of quizzes by course id
813 *
814 * @since 2.2.0
815 *
816 * @param int|array $course_id Course id or array of course ids.
817 *
818 * @return int
819 */
820 public static function get_quiz_count_by_course( $course_id ) {
821 global $wpdb;
822
823 $and_clause = is_array( $course_id ) && count( $course_id ) ? ' AND post_parent IN (' . QueryHelper::prepare_in_clause( $course_id ) . ')' : "AND post_parent = $course_id";
824
825 //phpcs:disable
826 $count = $wpdb->get_var(
827 $wpdb->prepare(
828 "SELECT
829 COUNT(ID)
830 FROM {$wpdb->posts}
831 WHERE post_parent IN
832 (SELECT
833 ID
834 FROM {$wpdb->posts}
835 WHERE post_type = %s
836 {$and_clause}
837 AND post_status = %s
838 )
839 AND post_type = %s
840 AND post_status = %s",
841 'topics',
842 'publish',
843 'tutor_quiz',
844 'publish'
845 )
846 );
847 //phpcs:enable
848 return $count ? $count : 0;
849 }
850
851 /**
852 * Get final quiz result depending on all attempts.
853 *
854 * @since 2.4.0
855 *
856 * @param int $quiz_id quiz id.
857 * @param int $user_id user id.
858 *
859 * @return string pass, fail, pending
860 */
861 public static function get_quiz_result( $quiz_id, $user_id = 0 ) {
862 global $wpdb;
863
864 $all_pending = true;
865 $result = self::RESULT_PENDING;
866
867 $user_id = tutor_utils()->get_user_id( $user_id );
868 $attempt_list = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}tutor_quiz_attempts WHERE user_id=%d AND quiz_id=%d", $user_id, $quiz_id ) );
869
870 $total_pending_attempt = (int) $wpdb->get_var(
871 $wpdb->prepare(
872 "SELECT COUNT(attempt_id) total
873 FROM {$wpdb->prefix}tutor_quiz_attempts
874 WHERE result=%s AND quiz_id = %d AND user_id = %d
875 ",
876 self::RESULT_PENDING,
877 $quiz_id,
878 $user_id
879 )
880 );
881
882 if ( count( $attempt_list ) !== $total_pending_attempt ) {
883 $all_pending = false;
884 }
885
886 if ( false === $all_pending ) {
887 $required_percentage = tutor_utils()->get_quiz_option( $quiz_id, 'passing_grade', 0 );
888 foreach ( $attempt_list as $attempt ) {
889 $earned_percentage = self::calculate_attempt_earned_percentage( $attempt );
890 if ( $earned_percentage >= $required_percentage ) {
891 // If at least one attempt passed then quiz passed.
892 $result = self::RESULT_PASS;
893 break;
894 } else {
895 $result = self::RESULT_FAIL;
896 }
897 }
898 }
899
900 return $result;
901 }
902
903 /**
904 * Get quiz attempt details
905 *
906 * @since 2.6.1
907 *
908 * @param integer $attempt_id attempt id.
909 *
910 * @return mixed
911 */
912 public static function quiz_attempt_details( int $attempt_id ) {
913 global $wpdb;
914
915 $table_quiz_attempt_answers = $wpdb->prefix . 'tutor_quiz_attempt_answers';
916 $table_quiz_questions = $wpdb->prefix . 'tutor_quiz_questions';
917 $table_quiz_attempts = $wpdb->prefix . 'tutor_quiz_attempts';
918 $table_quiz_question_answers = $wpdb->prefix . 'tutor_quiz_question_answers';
919
920 $query = "SELECT
921 ques.question_id,
922 ques.question_title,
923 ques.question_type,
924 (
925 SELECT
926 GROUP_CONCAT(answer_title)
927 FROM
928 {$table_quiz_question_answers}
929 WHERE
930 belongs_question_id = ques.question_id
931 AND is_correct = 1
932 ) AS correct_answers,
933
934 (
935
936 SELECT
937
938 CASE
939 WHEN CHAR_LENGTH(att_ans.given_answer) = 1 AND att_ans.given_answer REGEXP '^[0-9]$' THEN
940 -- If given_answer is a single digit integer
941 (
942 SELECT
943 answer_title
944 FROM
945 {$table_quiz_question_answers}
946 WHERE
947 answer_id = CAST(att_ans.given_answer AS UNSIGNED)
948 )
949 WHEN CHAR_LENGTH(att_ans.given_answer) > 1 AND SUBSTRING(att_ans.given_answer, 1, 2) = 'a:' THEN
950 -- If given_answer is serialized array
951 (
952 att_ans.given_answer
953 )
954 ELSE
955 -- If given_answer is a serialized string
956 att_ans.given_answer
957 END
958 ) AS given_answer,
959 att_ans.question_mark,
960 att_ans.achieved_mark,
961 att_ans.is_correct,
962 (
963 SELECT
964 attempt_info
965 FROM {$table_quiz_attempts}
966 WHERE attempt_id = {$attempt_id}
967 LIMIT 1
968 ) AS attempt_info
969 FROM
970 {$table_quiz_attempt_answers} AS att_ans
971 JOIN {$table_quiz_questions} AS ques ON ques.question_id = att_ans.question_id
972 JOIN {$table_quiz_question_answers} AS ans ON ans.answer_id = att_ans.attempt_answer_id
973 WHERE
974 quiz_attempt_id = %d
975 LIMIT
976 50
977 ";
978
979 //phpcs:ignore
980 $result = $wpdb->get_results( $wpdb->prepare( $query, $attempt_id ) );
981
982 // If array and count result then loop with each result and prepare given answer.
983 if ( is_array( $result ) && count( $result ) ) {
984 foreach ( $result as $key => $value ) {
985 // Check if given answer is a serialized string.
986 if ( is_serialized( $value->given_answer ) ) {
987 $given_answers = tutor_utils()->get_answer_by_id( maybe_unserialize( $value->given_answer ) );
988 $result[ $key ]->given_answer = array_column( $given_answers, 'answer_title' );
989 } elseif ( is_numeric( $value->given_answer ) ) {
990 $given_answers = tutor_utils()->get_answer_by_id( maybe_unserialize( $value->given_answer ) );
991 $result[ $key ]->given_answer = array_column( $given_answers, 'answer_title' );
992 }
993 }
994 }
995
996 return $result;
997 }
998
999 /**
1000 * Get a question record.
1001 *
1002 * @since 3.0.0
1003 *
1004 * @param int $question_id quiz question id.
1005 *
1006 * @return array|object|null|void
1007 */
1008 public static function get_question( $question_id ) {
1009 global $wpdb;
1010 return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}tutor_quiz_questions WHERE question_id = %d", $question_id ) );
1011 }
1012
1013 /**
1014 * Get all answer's of a quiz question.
1015 *
1016 * @since 3.0.0
1017 *
1018 * @param int $question_id question id.
1019 * @param string $question_type question type.
1020 *
1021 * @return array
1022 */
1023 public static function get_question_answers( $question_id, $question_type = null ) {
1024 global $wpdb;
1025
1026 $query = "SELECT * FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id = %d";
1027
1028 if ( $question_type ) {
1029 $query .= ' AND belongs_question_type = %s ORDER BY answer_order ASC';
1030 //phpcs:ignore
1031 $answers = $wpdb->get_results( $wpdb->prepare( $query, $question_id, $question_type ) );
1032 } else {
1033 $query .= ' ORDER BY answer_order ASC';
1034 //phpcs:ignore
1035 $answers = $wpdb->get_results( $wpdb->prepare( $query, $question_id ) );
1036 }
1037
1038 foreach ( $answers as $answer ) {
1039 $answer->answer_title = stripslashes( $answer->answer_title );
1040 if ( $answer->image_id ) {
1041 $answer->image_url = wp_get_attachment_url( $answer->image_id );
1042 }
1043 }
1044
1045 return $answers;
1046 }
1047
1048 /**
1049 * Get next answer order SL no
1050 *
1051 * @since 3.0.0
1052 *
1053 * @param int $question_id question id.
1054 * @param int $question_type question type.
1055 *
1056 * @return int
1057 */
1058 public static function get_next_answer_order( $question_id, $question_type ) {
1059 global $wpdb;
1060 $max_id = (int) $wpdb->get_var(
1061 $wpdb->prepare(
1062 "SELECT MAX(answer_order) FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id = %d AND belongs_question_type = %s",//phpcs:ignore
1063 $question_id,
1064 $question_type
1065 )
1066 );
1067
1068 return $max_id + 1;
1069 }
1070
1071 /**
1072 * Get quiz details by quiz id.
1073 *
1074 * @since 3.0.0
1075 *
1076 * @param int $quiz_id quiz id.
1077 *
1078 * @return object
1079 */
1080 public static function get_quiz_details( $quiz_id ) {
1081 $quiz = get_post( $quiz_id );
1082 $quiz->quiz_option = get_post_meta( $quiz_id, Quiz::META_QUIZ_OPTION, true );
1083 $quiz->questions = tutor_utils()->get_questions_by_quiz( $quiz_id );
1084
1085 if ( ! is_array( $quiz->questions ) ) {
1086 $quiz->questions = array();
1087 }
1088
1089 foreach ( $quiz->questions as $question ) {
1090 $question->question_answers = self::get_question_answers( $question->question_id, $question->question_type );
1091 if ( isset( $question->question_settings ) ) {
1092 $question->question_settings = maybe_unserialize( $question->question_settings );
1093 }
1094 }
1095
1096 return $quiz;
1097 }
1098
1099 /**
1100 * Calculate attempt earned percentage
1101 *
1102 * @since 3.7.1
1103 *
1104 * @param int|object $attempt attempt id or attempt object.
1105 *
1106 * @return float
1107 */
1108 public static function calculate_attempt_earned_percentage( $attempt ) {
1109 if ( is_numeric( $attempt ) ) {
1110 $attempt = tutor_utils()->get_attempt( $attempt );
1111 }
1112
1113 if ( ! $attempt ) {
1114 return 0;
1115 }
1116
1117 $total_marks = (float) $attempt->total_marks;
1118 $earned_marks = (float) $attempt->earned_marks;
1119
1120 $earned_percentage = ( $earned_marks > 0 && $total_marks > 0 )
1121 ? number_format( ( $earned_marks * 100 ) / $total_marks )
1122 : 0;
1123
1124 return $earned_percentage;
1125 }
1126
1127
1128 /**
1129 * Prepare attempt result from attempt data.
1130 *
1131 * @since 3.7.1
1132 *
1133 * @param int|object $attempt attempt id or object.
1134 *
1135 * @return string pass, fail, pending
1136 */
1137 public static function prepare_attempt_result( $attempt ) {
1138 if ( is_numeric( $attempt ) ) {
1139 $attempt = tutor_utils()->get_attempt( $attempt );
1140 }
1141
1142 if ( ! $attempt ) {
1143 return false;
1144 }
1145
1146 $attempt_info = maybe_unserialize( $attempt->attempt_info );
1147 $answers_map = self::get_quiz_answers_by_attempt_id( $attempt->attempt_id, true );
1148
1149 $earned_percentage = self::calculate_attempt_earned_percentage( $attempt );
1150 $passing_grade = (int) $attempt_info['passing_grade'] ?? 0;
1151
1152 $answers = $answers_map[ $attempt->attempt_id ] ?? array();
1153 $answers = is_array( $answers ) ? $answers : array();
1154
1155 $has_pending = (bool) count(
1156 array_filter( $answers, fn( $answer ) => null === $answer->is_correct )
1157 );
1158
1159 if ( $has_pending ) {
1160 $result = self::RESULT_PENDING;
1161 } else {
1162 $result = ( $earned_percentage >= $passing_grade ) ? self::RESULT_PASS : self::RESULT_FAIL;
1163 }
1164
1165 return $result;
1166 }
1167
1168 /**
1169 * Get a quiz attempt result
1170 *
1171 * @since 3.7.1
1172 *
1173 * @param int $attempt_id attempt id.
1174 *
1175 * @return string pass, fail, pending
1176 */
1177 public static function get_attempt_result( $attempt_id ) {
1178 $attempt = tutor_utils()->get_attempt( $attempt_id );
1179 if ( ! $attempt ) {
1180 return false;
1181 }
1182
1183 /**
1184 * If result is processed then return it
1185 *
1186 * @since 3.7.1
1187 */
1188 if ( isset( $attempt->result ) && ! empty( $attempt->result ) ) {
1189 return $attempt->result;
1190 }
1191
1192 /**
1193 * Backward compatibility for getting a attempt result
1194 *
1195 * @since 3.7.1
1196 */
1197 $result = self::prepare_attempt_result( $attempt );
1198
1199 return $result;
1200 }
1201
1202 /**
1203 * Update attempt result.
1204 *
1205 * @param int $attempt_id attempt id.
1206 *
1207 * @return bool
1208 */
1209 public static function update_attempt_result( $attempt_id ) {
1210 $attempt_result = self::prepare_attempt_result( $attempt_id );
1211 if ( $attempt_result ) {
1212 QueryHelper::update(
1213 QueryHelper::prepare_table_name( 'tutor_quiz_attempts' ),
1214 array( 'result' => $attempt_result ),
1215 array( 'attempt_id' => $attempt_id )
1216 );
1217
1218 return true;
1219 }
1220
1221 return false;
1222 }
1223 }
1224