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