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