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