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