PluginProbe ʕ •ᴥ•ʔ
Tutor LMS – eLearning and online course solution / 4.0.0
Tutor LMS – eLearning and online course solution v4.0.0
4.0.0 3.9.15 3.9.14 3.9.13 3.9.12 3.9.11 trunk 1.0.0 1.0.0-alpha 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 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 / CourseModel.php
tutor / models Last commit date
BaseModel.php 10 months ago BillingModel.php 1 year ago CartItemModel.php 10 months ago CartModel.php 1 day ago CouponModel.php 1 day ago CourseModel.php 1 day ago EnrollmentModel.php 1 day ago LessonModel.php 9 months ago OrderActivitiesModel.php 1 year ago OrderItemMetaModel.php 10 months ago OrderItemModel.php 10 months ago OrderMetaModel.php 1 year ago OrderModel.php 1 day ago QuizModel.php 1 day ago UserModel.php 1 year ago WithdrawModel.php 1 day ago
CourseModel.php
1767 lines
1 <?php
2 /**
3 * Course Model
4 *
5 * @package Tutor\Models
6 * @author Themeum <support@themeum.com>
7 * @link https://themeum.com
8 * @since 2.0.6
9 */
10
11 namespace Tutor\Models;
12
13 use Tutor\Helpers\DateTimeHelper;
14 use TUTOR\Icon;
15 use TUTOR\Course;
16 use TUTOR\Lesson;
17 use Tutor\Ecommerce\Tax;
18 use InvalidArgumentException;
19 use Tutor\Helpers\QueryHelper;
20 use TUTOR\User;
21 use TUTOR_ASSIGNMENTS\Assignments;
22
23 /**
24 * CourseModel Class
25 *
26 * @since 2.0.6
27 */
28 class CourseModel {
29 /**
30 * WordPress course type name
31 *
32 * @var string
33 */
34 const POST_TYPE = 'courses';
35 const COURSE_CATEGORY = 'course-category';
36 const COURSE_TAG = 'course-tag';
37
38 const STATUS_PUBLISH = 'publish';
39 const STATUS_DRAFT = 'draft';
40 const STATUS_AUTO_DRAFT = 'auto-draft';
41 const STATUS_PENDING = 'pending';
42 const STATUS_PRIVATE = 'private';
43 const STATUS_FUTURE = 'future';
44 const STATUS_TRASH = 'trash';
45
46 /**
47 * Course completion modes
48 */
49 const MODE_FLEXIBLE = 'flexible';
50 const MODE_STRICT = 'strict';
51
52 /**
53 * Course attachment/downloadable resources meta key
54 *
55 * @var string
56 */
57 const ATTACHMENT_META_KEY = '_tutor_attachments';
58
59 /**
60 * Course benefits meta key
61 *
62 * @var string
63 */
64 const BENEFITS_META_KEY = '_tutor_course_benefits';
65
66 /**
67 * The constant representing the status when a course is completed.
68 *
69 * @since 3.8.1
70 *
71 * @var string
72 */
73 const COURSE_COMPLETED = 'course_completed';
74
75 /**
76 * Get available status list.
77 *
78 * @since 3.0.0
79 *
80 * @return array
81 */
82 public static function get_status_list() {
83 return array(
84 self::STATUS_DRAFT,
85 self::STATUS_AUTO_DRAFT,
86 self::STATUS_PUBLISH,
87 self::STATUS_PRIVATE,
88 self::STATUS_FUTURE,
89 self::STATUS_PENDING,
90 self::STATUS_TRASH,
91 );
92 }
93
94 /**
95 * Check if a course is available for enrollment or purchase.
96 *
97 * @since 4.0.0
98 *
99 * @param integer $course_id the course id.
100 *
101 * @return bool
102 */
103 public static function is_course_accessible( $course_id = 0 ) {
104 $course = get_post( $course_id );
105 if ( ! $course || ! is_object( $course ) ) {
106 return false;
107 }
108
109 if ( ! in_array( $course->post_type, array( tutor()->course_post_type, tutor()->bundle_post_type ), true ) ) {
110 return false;
111 }
112
113 if ( ! in_array( $course->post_status, array( self::STATUS_PUBLISH, self::STATUS_PRIVATE ), true ) ) {
114 return false;
115 }
116
117 return true;
118 }
119
120 /**
121 * Course record count
122 *
123 * @since 2.0.7
124 *
125 * @since 3.6.0 $post_type param added
126 *
127 * @param string $status Post status.
128 * @param string $post_type Post type.
129 *
130 * @return int
131 */
132 public static function count( $status = self::STATUS_PUBLISH, $post_type = self::POST_TYPE ) {
133 $count_obj = wp_count_posts( $post_type );
134 if ( 'all' === $status ) {
135 return array_sum( (array) $count_obj );
136 }
137
138 return (int) $count_obj->{$status};
139 }
140
141 /**
142 * Get tutor post types
143 *
144 * @since 3.5.0
145 *
146 * @param int|\WP_POST $post the post id or object.
147 *
148 * @return bool
149 */
150 public static function get_post_types( $post ) {
151 return apply_filters( 'tutor_check_course_post_type', self::POST_TYPE === get_post_type( $post ), get_post_type( $post ) );
152 }
153
154 /**
155 * Get courses
156 *
157 * @since 1.0.0
158 *
159 * @param array $excludes exclude course ids.
160 * @param array $post_status post status array.
161 *
162 * @return array|null|object
163 */
164 public static function get_courses( $excludes = array(), $post_status = array( 'publish' ) ) {
165 global $wpdb;
166
167 $excludes = (array) $excludes;
168 $exclude_query = '';
169
170 if ( count( $excludes ) ) {
171 $exclude_query = implode( "','", $excludes );
172 }
173
174 $post_status = array_map(
175 function ( $element ) {
176 return "'" . $element . "'";
177 },
178 $post_status
179 );
180
181 $post_status = implode( ',', $post_status );
182 $course_post_type = tutor()->course_post_type;
183
184 //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
185 $query = $wpdb->get_results(
186 $wpdb->prepare(
187 "SELECT ID,
188 post_author,
189 post_title,
190 post_name,
191 post_status,
192 menu_order
193 FROM {$wpdb->posts}
194 WHERE post_status IN ({$post_status})
195 AND ID NOT IN('$exclude_query')
196 AND post_type = %s;
197 ",
198 $course_post_type
199 )
200 );
201 //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
202
203 return $query;
204 }
205
206 /**
207 * Get courses using provided args
208 *
209 * If user is not admin then it will return only current user's post
210 *
211 * @since 3.0.0
212 *
213 * @param array $args Args.
214 *
215 * @return \WP_Query
216 */
217 public static function get_courses_by_args( array $args = array() ) {
218
219 $default_args = array(
220 'post_type' => tutor()->course_post_type,
221 'posts_per_page' => -1,
222 'post_status' => 'publish',
223 );
224
225 if ( ! current_user_can( 'manage_options' ) ) {
226 $default_args['author'] = get_current_user_id();
227 }
228
229 $args = wp_parse_args( $args, apply_filters( 'tutor_get_course_list_filter_args', $default_args ) );
230
231 return new \WP_Query( $args );
232 }
233
234 /**
235 * Get course count by instructor
236 *
237 * @since 1.0.0
238 *
239 * @param int $instructor_id instructor ID.
240 *
241 * @return null|string
242 */
243 public static function get_course_count_by_instructor( $instructor_id ) {
244 global $wpdb;
245
246 $course_post_type = tutor()->course_post_type;
247
248 $count = $wpdb->get_var(
249 $wpdb->prepare(
250 "SELECT COUNT(ID)
251 FROM {$wpdb->posts}
252 INNER JOIN {$wpdb->usermeta}
253 ON user_id = %d
254 AND meta_key = %s
255 AND meta_value = ID
256 WHERE post_status = %s
257 AND post_type = %s;
258 ",
259 $instructor_id,
260 '_tutor_instructor_course_id',
261 'publish',
262 $course_post_type
263 )
264 );
265
266 return $count;
267 }
268
269 /**
270 * Get course by quiz
271 *
272 * @since 1.0.0
273 *
274 * @param int $quiz_id quiz id.
275 *
276 * @return array|bool|null|object|void
277 */
278 public static function get_course_by_quiz( $quiz_id ) {
279 $quiz_id = tutils()->get_post_id( $quiz_id );
280 $post = get_post( $quiz_id );
281
282 if ( $post ) {
283 $course = get_post( $post->post_parent );
284 if ( $course ) {
285 if ( tutor()->course_post_type !== $course->post_type ) {
286 $course = get_post( $course->post_parent );
287 }
288 return $course;
289 }
290 }
291
292 return false;
293 }
294
295 /**
296 * Get courses by a instructor
297 *
298 * @since 1.0.0
299 * @since 3.5.0 param $post_types added.
300 * @since 4.0.0 params $search and $order added.
301 *
302 * @param integer $instructor_id instructor id.
303 * @param array|string $post_status post status.
304 * @param integer $offset offset.
305 * @param integer $limit limit.
306 * @param boolean $count_only count or not.
307 * @param array $post_types array of post types.
308 * @param string $search search keyword.
309 * @param string $order order.
310 *
311 * @return array|null|object
312 */
313 public static function get_courses_by_instructor(
314 $instructor_id = 0,
315 $post_status = array( 'publish' ),
316 int $offset = 0,
317 int $limit = PHP_INT_MAX,
318 $count_only = false,
319 $post_types = array(),
320 $search = '',
321 $order = 'DESC'
322 ) {
323 global $wpdb;
324 $offset = sanitize_text_field( $offset );
325 $limit = sanitize_text_field( $limit );
326 $instructor_id = tutils()->get_user_id( $instructor_id );
327
328 if ( ! count( $post_types ) ) {
329 $post_types = array( tutor()->course_post_type );
330 }
331
332 $post_types = QueryHelper::prepare_in_clause( $post_types );
333
334 if ( empty( $post_status ) || 'any' == $post_status ) {
335 $where_post_status = '';
336 } else {
337 ! is_array( $post_status ) ? $post_status = array( $post_status ) : 0;
338 $statuses = "'" . implode( "','", $post_status ) . "'";
339 $where_post_status = "AND $wpdb->posts.post_status IN({$statuses}) ";
340 }
341
342 $search_sql = '';
343 if ( ! empty( $search ) ) {
344 $like = '%' . $wpdb->esc_like( $search ) . '%';
345 $search_sql = $wpdb->prepare(
346 " AND ( {$wpdb->posts}.post_title LIKE %s OR {$wpdb->posts}.post_excerpt LIKE %s ) ",
347 $like,
348 $like
349 );
350 }
351
352 $order = strtoupper( $order ) === 'ASC' ? 'ASC' : 'DESC';
353 $order_sql = " ORDER BY $wpdb->posts.post_date {$order} ";
354
355 $select_col = $count_only ? " COUNT(DISTINCT $wpdb->posts.ID) " : " $wpdb->posts.* ";
356 $limit_offset = $count_only ? '' : " LIMIT $offset, $limit ";
357
358 //phpcs:disable
359 $query = $wpdb->prepare(
360 "SELECT $select_col
361 FROM $wpdb->posts
362 LEFT JOIN {$wpdb->usermeta}
363 ON $wpdb->usermeta.user_id = %d
364 AND $wpdb->usermeta.meta_key = %s
365 AND $wpdb->usermeta.meta_value = $wpdb->posts.ID
366 WHERE 1 = 1 {$where_post_status}
367 AND $wpdb->posts.post_type IN ({$post_types})
368 AND ($wpdb->posts.post_author = %d OR $wpdb->usermeta.user_id = %d)
369 {$search_sql}
370 {$order_sql} {$limit_offset}",
371 $instructor_id,
372 '_tutor_instructor_course_id',
373 $instructor_id,
374 $instructor_id
375 );
376 //phpcs:enable
377
378 $query = apply_filters( 'modify_get_courses_by_instructor_query', $query, $instructor_id, $where_post_status, $post_types, $limit_offset, $select_col );
379
380 //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
381 return $count_only ? $wpdb->get_var( $query ) : $wpdb->get_results( $query, OBJECT );
382 }
383
384 /**
385 * Get courses for instructors
386 *
387 * @since 1.0.0
388 *
389 * @param int $instructor_id Instructor ID.
390 * @return array|null|object
391 */
392 public function get_courses_for_instructors( $instructor_id = 0 ) {
393 $instructor_id = tutor_utils()->get_user_id( $instructor_id );
394 $course_post_type = tutor()->course_post_type;
395
396 $courses = get_posts(
397 array(
398 'post_type' => $course_post_type,
399 'author' => $instructor_id,
400 'post_status' => array( 'publish', 'pending' ),
401 'posts_per_page' => 5,
402 )
403 );
404
405 return $courses;
406 }
407
408 /**
409 * Check a user is main instructor of a course
410 *
411 * @since 2.1.6
412 *
413 * @param integer $course_id course id.
414 * @param integer $user_id instructor id ( optional ) default: current user id.
415 *
416 * @return boolean
417 */
418 public static function is_main_instructor( $course_id, $user_id = 0 ) {
419 $course = get_post( $course_id );
420 $user_id = tutor_utils()->get_user_id( $user_id );
421
422 if ( ! $course || ! self::get_post_types( $course_id ) || $user_id !== (int) $course->post_author ) {
423 return false;
424 }
425
426 return true;
427 }
428
429 /**
430 * Mark the course as completed
431 *
432 * @since 2.0.7
433 *
434 * @param int $course_id course id which is completed.
435 * @param int $user_id student id who completed the course.
436 *
437 * @return bool
438 */
439 public static function mark_course_as_completed( $course_id, $user_id ) {
440 if ( ! $course_id || ! $user_id ) {
441 return false;
442 }
443
444 do_action( 'tutor_course_complete_before', $course_id );
445
446 /**
447 * Marking course completed at Comment.
448 */
449 global $wpdb;
450
451 $date = date( 'Y-m-d H:i:s', tutor_time() ); //phpcs:ignore
452
453 // Making sure that, hash is unique.
454 do {
455 $hash = substr( md5( wp_generate_password( 32 ) . $date . $course_id . $user_id ), 0, 16 );
456 $has_hash = (int) $wpdb->get_var(
457 $wpdb->prepare(
458 "SELECT COUNT(comment_ID) from {$wpdb->comments}
459 WHERE comment_agent = 'TutorLMSPlugin' AND comment_type = 'course_completed' AND comment_content = %s ",
460 $hash
461 )
462 );
463
464 } while ( $has_hash > 0 );
465
466 $data = array(
467 'comment_post_ID' => $course_id,
468 'comment_author' => $user_id,
469 'comment_date' => $date,
470 'comment_date_gmt' => get_gmt_from_date( $date ),
471 'comment_content' => $hash, // Identification Hash.
472 'comment_approved' => 'approved',
473 'comment_agent' => 'TutorLMSPlugin',
474 'comment_type' => 'course_completed',
475 'user_id' => $user_id,
476 );
477
478 $wpdb->insert( $wpdb->comments, $data );
479
480 do_action( 'tutor_course_complete_after', $course_id, $user_id );
481
482 return true;
483 }
484
485 /**
486 * Delete a course by ID
487 *
488 * @since 2.0.9
489 *
490 * @param int $post_id course id that need to delete.
491 * @return bool
492 */
493 public static function delete_course( $post_id ) {
494 if ( ! self::get_post_types( $post_id ) ) {
495 return false;
496 }
497
498 wp_delete_post( $post_id, true );
499 return true;
500 }
501
502 /**
503 * Get post ids by post type and parent_id
504 *
505 * @since 1.6.6
506 *
507 * @param string $post_type post type.
508 * @param integer $post_parent post parent ID.
509 *
510 * @return array
511 */
512 private function get_post_ids( $post_type, $post_parent ) {
513 $args = array(
514 'fields' => 'ids',
515 'post_type' => $post_type,
516 'post_parent' => $post_parent,
517 'post_status' => 'any',
518 'posts_per_page' => -1,
519 );
520 return get_posts( $args );
521 }
522
523 /**
524 * Delete course data when permanently deleting a course.
525 *
526 * @since 1.6.6
527 * @since 2.0.9 updated
528 *
529 * @param integer $post_id post ID.
530 * @return bool
531 */
532 public function delete_course_data( $post_id ) {
533 $course_post_type = tutor()->course_post_type;
534 if ( get_post_type( $post_id ) !== $course_post_type ) {
535 return false;
536 }
537
538 do_action( 'tutor_before_delete_course_content', $post_id, 0 );
539
540 global $wpdb;
541
542 $lesson_post_type = tutor()->lesson_post_type;
543 $assignment_post_type = tutor()->assignment_post_type;
544 $quiz_post_type = tutor()->quiz_post_type;
545
546 $topic_ids = $this->get_post_ids( 'topics', $post_id );
547
548 // Course > Topic > ( Lesson | Quiz | Assignment ).
549 if ( ! empty( $topic_ids ) ) {
550 foreach ( $topic_ids as $topic_id ) {
551 $content_post_type = array( $lesson_post_type, $assignment_post_type, $quiz_post_type );
552 $topic_content_ids = $this->get_post_ids( $content_post_type, $topic_id );
553
554 foreach ( $topic_content_ids as $content_id ) {
555 /**
556 * Delete Quiz data
557 */
558 if ( get_post_type( $content_id ) === $quiz_post_type ) {
559 // Collect file paths from all question types that store files before deleting rows (files deleted after DB for safety).
560 $attempts_for_quiz = QueryHelper::get_all( 'tutor_quiz_attempts', array( 'quiz_id' => $content_id ), 'attempt_id', -1 );
561 $attempt_file_paths = array();
562 if ( ! empty( $attempts_for_quiz ) ) {
563 $attempt_ids = array_map(
564 function ( $row ) {
565 return (int) $row->attempt_id;
566 },
567 $attempts_for_quiz
568 );
569 $attempt_file_paths = apply_filters( 'tutor_quiz/attempt_file_paths_for_deletion', array(), $attempt_ids );
570 $attempt_file_paths = is_array( $attempt_file_paths ) ? array_values( array_filter( array_unique( $attempt_file_paths ) ) ) : array();
571 }
572
573 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $content_id ) );
574 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $content_id ) );
575
576 QuizModel::delete_files_by_paths( $attempt_file_paths );
577
578 do_action( 'tutor_before_delete_quiz_content', $content_id, null );
579
580 // Collect instructor file paths before deleting question data (e.g. draw_image / pin_image masks).
581 /**
582 * Filter to get file paths for quiz deletion.
583 * Pro and other add-ons register their question types via this filter.
584 *
585 * @param string[] $file_paths Paths collected so far.
586 * @param int $quiz_id Quiz post ID.
587 */
588 $quiz_file_paths = apply_filters( 'tutor_quiz_quiz_file_paths_for_deletion', array(), $content_id );
589
590 $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $content_id ) );
591 if ( is_array( $questions_ids ) && count( $questions_ids ) ) {
592 $in_clause = QueryHelper::prepare_in_clause( $questions_ids );
593 //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
594 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id IN({$in_clause}) " ) );
595 }
596 $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $content_id ) );
597
598 QuizModel::delete_files_by_paths( $quiz_file_paths );
599 }
600
601 /**
602 * Delete assignment data ( Assignments, Assignment Submit, Assignment Evalutation )
603 *
604 * @since 2.0.9
605 */
606 if ( get_post_type( $content_id ) === $assignment_post_type ) {
607 QueryHelper::delete_comment_with_meta(
608 array(
609 'comment_type' => 'tutor_assignment',
610 'comment_post_ID' => $content_id,
611 )
612 );
613 }
614
615 wp_delete_post( $content_id, true );
616
617 }
618
619 // Delete zoom meeting.
620 $wpdb->delete(
621 $wpdb->posts,
622 array(
623 'post_parent' => $topic_id,
624 'post_type' => 'tutor_zoom_meeting',
625 )
626 );
627
628 /**
629 * Delete Google Meet Record Related to Course Topic
630 *
631 * @since 2.1.0
632 */
633 $wpdb->delete(
634 $wpdb->posts,
635 array(
636 'post_parent' => $topic_id,
637 'post_type' => 'tutor-google-meet',
638 )
639 );
640
641 wp_delete_post( $topic_id, true );
642 }
643 }
644
645 $child_post_ids = $this->get_post_ids( array( 'tutor_announcements', 'tutor_enrolled', 'tutor_zoom_meeting', 'tutor-google-meet' ), $post_id );
646 if ( ! empty( $child_post_ids ) ) {
647 foreach ( $child_post_ids as $child_post_id ) {
648 wp_delete_post( $child_post_id, true );
649 }
650 }
651
652 /**
653 * Delete earning, gradebook result, course complete data
654 *
655 * @since 2.0.9
656 */
657 $wpdb->delete( $wpdb->prefix . 'tutor_earnings', array( 'course_id' => $post_id ) );
658 $wpdb->delete( $wpdb->prefix . 'tutor_gradebooks_results', array( 'course_id' => $post_id ) );
659 $wpdb->delete(
660 $wpdb->comments,
661 array(
662 'comment_type' => 'course_completed',
663 'comment_post_ID' => $post_id,
664 )
665 );
666
667 /**
668 * Delete onsite notification record & _tutor_instructor_course_id user meta
669 *
670 * @since 2.1.0
671 */
672 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}tutor_notifications WHERE post_id=%d AND type IN ('Announcements','Q&A','Enrollments')", $post_id ) );
673 $wpdb->delete(
674 $wpdb->usermeta,
675 array(
676 'meta_key' => '_tutor_instructor_course_id',
677 'meta_value' => $post_id,
678 )
679 );
680
681 /**
682 * Delete Course rating and review
683 *
684 * @since 2.0.9
685 */
686 QueryHelper::delete_comment_with_meta(
687 array(
688 'comment_type' => 'tutor_course_rating',
689 'comment_post_ID' => $post_id,
690 )
691 );
692
693 /**
694 * Delete Q&A and its status ( read, replied etc )
695 *
696 * @since 2.0.9
697 */
698 QueryHelper::delete_comment_with_meta(
699 array(
700 'comment_type' => 'tutor_q_and_a',
701 'comment_post_ID' => $post_id,
702 )
703 );
704
705 /**
706 * Delete caches
707 */
708 $attempt_cache = new \Tutor\Cache\QuizAttempts();
709 if ( $attempt_cache->has_cache() ) {
710 $attempt_cache->delete_cache();
711 }
712
713 return true;
714 }
715
716
717 /**
718 * Get paid courses
719 *
720 * To identify course is connected with any product
721 * like WC Product or EDD product meta key will be used
722 *
723 * @since 2.2.0
724 *
725 * @since 3.0.0
726 *
727 * Meta key removed and default meta query updated
728 *
729 * @since 3.0.1
730 * Course::COURSE_PRICE_META meta key exists clause added
731 *
732 * @param array $args wp_query args.
733 *
734 * @return \WP_Query
735 */
736 public static function get_paid_courses( array $args = array() ) {
737 $current_user = wp_get_current_user();
738
739 $default_args = array(
740 'post_type' => tutor()->course_post_type,
741 'posts_per_page' => -1,
742 'offset' => 0,
743 'post_status' => 'publish',
744 'meta_query' => array(
745 'relation' => 'AND',
746 array(
747 'key' => Course::COURSE_PRICE_TYPE_META,
748 'value' => Course::PRICE_TYPE_SUBSCRIPTION,
749 'compare' => '!=',
750 ),
751 array(
752 'key' => Course::COURSE_PRICE_META,
753 'compare' => 'EXISTS',
754 ),
755 ),
756 );
757
758 // Check if the current user is an admin.
759 if ( ! current_user_can( 'administrator' ) ) {
760 $args['author'] = $current_user->ID;
761 }
762
763 $args = wp_parse_args( $args, $default_args );
764 return new \WP_Query( $args );
765 }
766
767 /**
768 * Check the course is completeable or not
769 *
770 * @since 2.4.0
771 *
772 * @param int $course_id course id.
773 * @param int $user_id user id.
774 *
775 * @return boolean
776 */
777 public static function can_complete_course( $course_id, $user_id ) {
778 $mode = tutor_utils()->get_option( 'course_completion_process' );
779 if ( self::MODE_FLEXIBLE === $mode ) {
780 return true;
781 }
782
783 if ( self::MODE_STRICT === $mode ) {
784 $completed_lesson = tutor_utils()->get_completed_lesson_count_by_course( $course_id, $user_id );
785 $lesson_count = tutor_utils()->get_lesson_count_by_course( $course_id, $user_id );
786
787 if ( $completed_lesson < $lesson_count ) {
788 return false;
789 }
790
791 $quizzes = array();
792 $assignments = array();
793
794 $course_contents = tutor_utils()->get_course_contents_by_id( $course_id );
795 if ( tutor_utils()->count( $course_contents ) ) {
796 foreach ( $course_contents as $content ) {
797 if ( 'tutor_quiz' === $content->post_type ) {
798 $quizzes[] = $content;
799 }
800 if ( 'tutor_assignments' === $content->post_type ) {
801 $assignments[] = $content;
802 }
803 }
804 }
805
806 foreach ( $quizzes as $row ) {
807 $result = QuizModel::get_quiz_result( $row->ID );
808 if ( 'pass' !== $result ) {
809 return false;
810 }
811 }
812
813 if ( tutor()->has_pro ) {
814 foreach ( $assignments as $row ) {
815 $result = \TUTOR_ASSIGNMENTS\Assignments::get_assignment_result( $row->ID, $user_id );
816 if ( 'pass' !== $result ) {
817 return false;
818 }
819 }
820 }
821
822 return true;
823 }
824
825 return false;
826 }
827
828 /**
829 * Check a course can be auto complete by an enrolled student.
830 *
831 * @since 2.4.0
832 *
833 * @param int $course_id course id.
834 * @param int $user_id user id.
835 *
836 * @return boolean
837 */
838 public static function can_autocomplete_course( $course_id, $user_id ) {
839 $auto_course_complete_option = (bool) tutor_utils()->get_option( 'auto_course_complete_on_all_lesson_completion' );
840 if ( ! $auto_course_complete_option ) {
841 return false;
842 }
843
844 $is_course_completed = tutor_utils()->is_completed_course( $course_id, $user_id );
845 if ( $is_course_completed ) {
846 return false;
847 }
848
849 $course_stats = tutor_utils()->get_course_completed_percent( $course_id, $user_id, true );
850 if ( $course_stats['total_count'] && $course_stats['completed_count'] === $course_stats['total_count'] ) {
851 return self::can_complete_course( $course_id, $user_id );
852 } else {
853 return false;
854 }
855 }
856
857 /**
858 * Get review progress link when course progress 100% and
859 * User has pending or fail quiz or assignment
860 *
861 * @since 2.4.0
862 *
863 * @param int $course_id course id.
864 * @param int $user_id user id.
865 *
866 * @return string course content permalink.
867 */
868 public static function get_review_progress_link( $course_id, $user_id ) {
869 $course_progress = tutor_utils()->get_course_completed_percent( $course_id, $user_id, true );
870 $completed_percent = (int) $course_progress['completed_percent'];
871 $course_contents = tutor_utils()->get_course_contents_by_id( $course_id );
872 $permalink = '';
873
874 if ( tutor_utils()->count( $course_contents ) && 100 === $completed_percent ) {
875 foreach ( $course_contents as $content ) {
876 if ( 'tutor_quiz' === $content->post_type ) {
877 $result = QuizModel::get_quiz_result( $content->ID, $user_id );
878 if ( 'pass' !== $result ) {
879 $permalink = get_the_permalink( $content->ID );
880 break;
881 }
882 }
883
884 if ( tutor()->has_pro && 'tutor_assignments' === $content->post_type ) {
885 $result = \TUTOR_ASSIGNMENTS\Assignments::get_assignment_result( $content->ID, $user_id );
886 if ( 'pass' !== $result ) {
887 $permalink = get_the_permalink( $content->ID );
888 break;
889 }
890 }
891 }
892 }
893
894 // Fallback link.
895 if ( empty( $permalink ) ) {
896 $permalink = tutils()->get_course_first_lesson( $course_id );
897 }
898
899 return $permalink;
900 }
901
902 /**
903 * Get course preview image placeholder
904 *
905 * @since 3.0.0
906 *
907 * @return string
908 */
909 public static function get_course_preview_image_placeholder() {
910 return tutor()->url . 'assets/images/placeholder.svg';
911 }
912
913 /**
914 * Retrieve the courses or course bundles that a given coupon code applies to.
915 *
916 * This function fetches published courses or course bundles from the database
917 * based on the specified type. For each course, it retrieves the course prices
918 * and the course thumbnail URL. If the user has Tutor Pro, it additionally
919 * retrieves the total number of courses in a course bundle.
920 *
921 * @since 3.0.0
922 *
923 * @param string $applies_to The type of items the coupon applies to. Accepts 'specific_courses'
924 * for individual courses or any other value for course bundles.
925 *
926 * @global wpdb $wpdb WordPress database abstraction object.
927 *
928 * @return array An array of course objects. Each course object contains:
929 * - int $id: The ID of the course.
930 * - string $title: The title of the course.
931 * - string $type: The post type of the course (e.g., 'courses', 'course-bundle').
932 * - float $price: The regular price of the course.
933 * - float $sale_price: The sale price of the course.
934 * - string $image: The URL of the course's thumbnail image.
935 * - int|null $total_courses: The total number of courses in the bundle
936 * (only if the user has Tutor Pro and the course type is 'course-bundle').
937 */
938 public function get_coupon_applies_to_courses( string $applies_to ) {
939 global $wpdb;
940
941 $post_type = 'specific_courses' === $applies_to ? 'courses' : 'course-bundle';
942
943 $where = array(
944 'post_status' => 'publish',
945 'post_type' => $post_type,
946 );
947
948 $courses = QueryHelper::get_all( $wpdb->posts, $where, 'ID' );
949
950 if ( tutor()->has_pro ) {
951 $bundle_model = new \TutorPro\CourseBundle\Models\BundleModel();
952 }
953
954 $final_data = array();
955
956 if ( ! empty( $courses ) ) {
957 foreach ( $courses as $course ) {
958 $data = new \stdClass();
959
960 if ( tutor()->has_pro && 'course-bundle' === $course->type ) {
961 $data->total_courses = count( $bundle_model->get_bundle_course_ids( $course->ID ) );
962 }
963
964 $author_name = get_the_author_meta( 'display_name', $course->post_author );
965 $course_prices = tutor_utils()->get_raw_course_price( $course->ID );
966 $data->id = (int) $course->ID;
967 $data->title = $course->post_title;
968 $data->price = $course_prices->regular_price;
969 $data->sale_price = $course_prices->sale_price;
970 $data->image = get_the_post_thumbnail_url( $course->ID );
971 $data->author = $author_name;
972
973 $final_data[] = $data;
974 }
975 }
976
977 return ! empty( $final_data ) ? $final_data : array();
978 }
979
980 /**
981 * Get course instructor IDs.
982 *
983 * @since 3.0.0
984 *
985 * @param int $course_id course id.
986 *
987 * @return array
988 */
989 public static function get_course_instructor_ids( $course_id ) {
990 global $wpdb;
991 $instructor_ids = $wpdb->get_col(
992 $wpdb->prepare(
993 "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key=%s AND meta_value=%s",
994 '_tutor_instructor_course_id',
995 $course_id
996 )
997 );
998
999 return $instructor_ids;
1000 }
1001
1002 /**
1003 * Check tax collection is enabled for single purchase course/bundle
1004 *
1005 * @since 3.7.0
1006 *
1007 * @param int $post_id course or bundle id.
1008 *
1009 * @return boolean
1010 */
1011 public static function is_tax_enabled_for_single_purchase( $post_id ) {
1012 if ( ! Tax::is_individual_control_enabled() ) {
1013 return true;
1014 }
1015
1016 $data = get_post_meta( $post_id, Course::TAX_ON_SINGLE_META, true );
1017 return ( '1' === $data || '' === $data );
1018 }
1019
1020 /**
1021 * Count total attachments available in all courses or specific
1022 *
1023 * @since 3.6.0
1024 *
1025 * @param int|array $course_id Course id or array of ids, to get course's attachment.
1026 *
1027 * @return int
1028 */
1029 public static function count_attachment( $course_id = 0 ) {
1030 global $wpdb;
1031
1032 $total_count = 0;
1033
1034 $primary_table = "$wpdb->posts p";
1035 $joining_tables = array(
1036 array(
1037 'type' => 'INNER',
1038 'table' => "{$wpdb->postmeta} pm",
1039 'on' => "p.ID = pm.post_id AND pm.meta_key = '_tutor_attachments' AND pm.meta_value != 'a:0:{}'",
1040 ),
1041 );
1042
1043 // Prepare query.
1044 $select = array( 'pm.meta_value' );
1045 $where = array();
1046 if ( $course_id ) {
1047 $where['p.ID'] = is_array( $course_id ) ? array( 'IN', $course_id ) : $course_id;
1048 }
1049
1050 $search = array();
1051 $limit = 0; // Get all.
1052 $offset = 0;
1053 $order_by = '';
1054
1055 $results = QueryHelper::get_joined_data(
1056 $primary_table,
1057 $joining_tables,
1058 $select,
1059 $where,
1060 $search,
1061 $order_by,
1062 $limit,
1063 $offset
1064 )['results'];
1065
1066 if ( $results ) {
1067 foreach ( $results as $row ) {
1068 $attachment_ids = maybe_unserialize( $row->meta_value );
1069 if ( ! is_array( $attachment_ids ) || empty( $attachment_ids ) ) {
1070 continue;
1071 }
1072
1073 $attachments = get_posts(
1074 array(
1075 'post_type' => 'attachment',
1076 'post__in' => $attachment_ids,
1077 'posts_per_page' => -1,
1078 )
1079 );
1080
1081 $total_count += count( $attachments );
1082 }
1083 }
1084
1085 return $total_count;
1086 }
1087
1088 /**
1089 * Count total questions available in all or specific courses
1090 *
1091 * @since 3.7.1
1092 *
1093 * @param int|array $course_id Course id or array of ids, to get course's attachment.
1094 *
1095 * @return int
1096 */
1097 public static function count_questions( $course_id = 0 ) {
1098 global $wpdb;
1099
1100 $total_count = 0;
1101 $quiz_post_type = tutor()->quiz_post_type;
1102 $topic_post_type = tutor()->topics_post_type;
1103 $course_post_type = tutor()->course_post_type;
1104
1105 $primary_table = "{$wpdb->prefix}tutor_quiz_questions question";
1106
1107 $joining_tables = array(
1108 array(
1109 'type' => 'INNER',
1110 'table' => "{$wpdb->posts} q",
1111 'on' => "q.ID = question.quiz_id AND q.post_type='{$quiz_post_type}'",
1112 ),
1113 array(
1114 'type' => 'INNER',
1115 'table' => "{$wpdb->posts} t",
1116 'on' => "q.post_parent = t.ID AND t.post_type='{$topic_post_type}'",
1117 ),
1118 array(
1119 'type' => 'INNER',
1120 'table' => "{$wpdb->posts} c",
1121 'on' => "c.ID = t.post_parent AND c.post_type='{$course_post_type}'",
1122 ),
1123 );
1124
1125 // Prepare query.
1126 $where = array();
1127 if ( $course_id ) {
1128 $where['c.ID'] = is_array( $course_id ) ? array( 'IN', $course_id ) : $course_id;
1129 }
1130
1131 $search = array();
1132
1133 $total_count = QueryHelper::get_joined_count(
1134 $primary_table,
1135 $joining_tables,
1136 $where,
1137 $search,
1138 '*'
1139 );
1140
1141 return $total_count;
1142 }
1143
1144 /**
1145 * Count course content
1146 *
1147 * @since 3.6.0
1148 *
1149 * @param string $content_type Content type.
1150 * @param array $course_ids Course ids.
1151 *
1152 * @return int
1153 */
1154 public static function count_course_content( string $content_type, array $course_ids = array() ): int {
1155 $total_count = 0;
1156 switch ( $content_type ) {
1157 case tutor()->lesson_post_type:
1158 $total_count = tutor_utils()->get_total_lesson( $course_ids );
1159 break;
1160 case tutor()->quiz_post_type:
1161 $total_count = tutor_utils()->get_total_quiz( $course_ids );
1162 break;
1163 case tutor()->assignment_post_type:
1164 if ( tutor_utils()->is_addon_enabled( 'tutor-assignments' ) ) {
1165 $total_count = ( new Assignments( false ) )->get_total_assignment( $course_ids );
1166 }
1167 break;
1168 case 'attachments':
1169 $total_count = self::count_attachment( $course_ids );
1170 break;
1171 case 'questions':
1172 $total_count = self::count_questions( $course_ids );
1173 break;
1174 default:
1175 break;
1176 }
1177
1178 return (int) $total_count;
1179 }
1180
1181 /**
1182 * Get course dropdown options
1183 *
1184 * @since 3.7.0
1185 *
1186 * @return array
1187 */
1188 public static function get_course_dropdown_options() {
1189 $course_options = array(
1190 array(
1191 'key' => '',
1192 'title' => __( 'All Courses', 'tutor' ),
1193 ),
1194 );
1195
1196 $courses = current_user_can( 'administrator' ) ? self::get_courses() : self::get_courses_by_instructor();
1197 if ( ! empty( $courses ) ) {
1198 foreach ( $courses as $course ) {
1199 $course_options[] = array(
1200 'key' => $course->ID,
1201 'title' => $course->post_title,
1202 );
1203 }
1204 }
1205
1206 return apply_filters( 'tutor_course_dropdown_options', $course_options );
1207 }
1208
1209 /**
1210 * Get category dropdown options
1211 *
1212 * @since 3.7.0
1213 *
1214 * @return array
1215 */
1216 public static function get_category_dropdown_options() {
1217 $category_options = array(
1218 array(
1219 'key' => '',
1220 'title' => __( 'All Categories', 'tutor' ),
1221 ),
1222 );
1223
1224 $categories = get_terms(
1225 array(
1226 'taxonomy' => self::COURSE_CATEGORY,
1227 'orderby' => 'term_id',
1228 'order' => 'DESC',
1229 )
1230 );
1231 if ( ! is_wp_error( $categories ) && ! empty( $categories ) ) {
1232 foreach ( $categories as $category ) {
1233 $category_options[] = array(
1234 'key' => $category->slug,
1235 'title' => $category->name,
1236 );
1237 }
1238 }
1239
1240 return $category_options;
1241 }
1242
1243 /**
1244 * Retrieve all course completion records for a specific course, including meta data.
1245 *
1246 * @since 3.8.1
1247 *
1248 * @param int $course_id The ID of the course.
1249 *
1250 * @return array Array of course completion objects with meta, empty array if none found, or on database error.
1251 */
1252 public function get_course_completion_data_by_course_id( int $course_id ): array {
1253
1254 global $wpdb;
1255
1256 $where = array(
1257 'comment_type' => self::COURSE_COMPLETED,
1258 'comment_post_ID' => $course_id,
1259 'comment_agent' => 'TutorLMSPlugin',
1260 );
1261
1262 $result = QueryHelper::get_all( $wpdb->comments, $where, 'comment_post_ID', -1 );
1263
1264 if ( empty( $result ) ) {
1265 return array();
1266 }
1267
1268 return array_map(
1269 function ( $item ) {
1270
1271 return array(
1272 'completion' => $item,
1273 'completion_meta' => get_comment_meta( $item->comment_ID ),
1274 );
1275 },
1276 $result
1277 );
1278 }
1279
1280 /**
1281 * Return completed courses by user_id
1282 *
1283 * @since 1.0.0
1284 *
1285 * @param int $user_id user id.
1286 * @param int $offset offset.
1287 * @param int $posts_per_page posts per page.
1288 * @param array $args Args to override the defaults.
1289 *
1290 * @return bool|\WP_Query
1291 */
1292 public static function get_completed_courses_by_user( $user_id = 0, $offset = 0, $posts_per_page = -1, $args = array() ) {
1293 $user_id = tutor_utils()->get_user_id( $user_id );
1294 $course_ids = tutor_utils()->get_completed_courses_ids_by_user( $user_id, false );
1295
1296 if ( count( $course_ids ) ) {
1297 $course_post_type = tutor()->course_post_type;
1298 $course_args = array(
1299 'post_type' => $course_post_type,
1300 'post_status' => 'publish',
1301 'post__in' => $course_ids,
1302 'posts_per_page' => $posts_per_page,
1303 'offset' => $offset,
1304 'orderby' => 'post__in',
1305 );
1306
1307 $args = apply_filters( 'tutor_get_completed_courses_by_user', $args, $user_id, $course_post_type );
1308 $course_args = wp_parse_args( $args, $course_args );
1309
1310 return new \WP_Query( $course_args );
1311 }
1312
1313 return false;
1314 }
1315
1316 /**
1317 * Get the active course by user
1318 *
1319 * @since 1.0.0
1320 *
1321 * @param int $user_id user id.
1322 * @param int $offset offset.
1323 * @param int $posts_per_page posts per page.
1324 * @param array $args Args to override the defaults.
1325 *
1326 * @return bool|\WP_Query
1327 */
1328 public static function get_active_courses_by_user( $user_id = 0, $offset = 0, $posts_per_page = -1, $args = array() ) {
1329 $user_id = tutor_utils()->get_user_id( $user_id );
1330 $course_ids = tutor_utils()->get_completed_courses_ids_by_user( $user_id, false );
1331 $enrolled_course_ids = tutor_utils()->get_enrolled_courses_ids_by_user( $user_id, false );
1332 $active_courses = array_diff( $enrolled_course_ids, $course_ids );
1333
1334 if ( count( $active_courses ) ) {
1335 $course_post_type = tutor()->course_post_type;
1336 $course_args = array(
1337 'post_type' => apply_filters( 'tutor_active_courses_post_types', array( $course_post_type ) ),
1338 'post_status' => 'publish',
1339 'post__in' => $active_courses,
1340 'posts_per_page' => $posts_per_page,
1341 'offset' => $offset,
1342 'orderby' => 'post__in',
1343 );
1344
1345 $args = apply_filters( 'tutor_get_active_courses_by_user', $args, $user_id, $course_post_type );
1346 $course_args = wp_parse_args( $args, $course_args );
1347
1348 return new \WP_Query( $course_args );
1349 }
1350
1351 return false;
1352 }
1353
1354 /**
1355 * Get the enrolled courses by user
1356 *
1357 * @since 1.0.0
1358 * @since 2.5.0 $filters param added to query enrolled courses with additional filters.
1359 *
1360 * @since 3.4.0 $filters replaced with $args to override the defaults.
1361 *
1362 * @param integer $user_id user id.
1363 * @param mixed $post_status post status.
1364 * @param integer $offset offset.
1365 * @param integer $posts_per_page post per page.
1366 * @param array $args Args to override the defaults.
1367 *
1368 * @return bool|\WP_Query
1369 */
1370 public static function get_enrolled_courses_by_user( $user_id = 0, $post_status = 'publish', $offset = 0, $posts_per_page = -1, $args = array() ) {
1371 $user_id = tutor_utils()->get_user_id( $user_id );
1372 $course_ids = array_unique( tutor_utils()->get_enrolled_courses_ids_by_user( $user_id, false ) );
1373
1374 if ( count( $course_ids ) ) {
1375 $course_post_type = tutor()->course_post_type;
1376 $course_args = array(
1377 'post_type' => $course_post_type,
1378 'post_status' => $post_status,
1379 'post__in' => $course_ids,
1380 'offset' => $offset,
1381 'posts_per_page' => $posts_per_page,
1382 'orderby' => 'post__in',
1383 );
1384
1385 $args = apply_filters( 'tutor_get_enrolled_courses_by_user', $args, $user_id, $course_post_type );
1386 $course_args = wp_parse_args( $args, $course_args );
1387
1388 $result = new \WP_Query( $course_args );
1389
1390 if ( is_object( $result ) && is_array( $result->posts ) ) {
1391
1392 // Sort courses according to the id list.
1393 $new_array = array();
1394
1395 foreach ( $course_ids as $id ) {
1396 foreach ( $result->posts as $post ) {
1397 $post->ID == $id ? $new_array[] = $post : 0;
1398 }
1399 }
1400
1401 $result->posts = $new_array;
1402 }
1403
1404 return $result;
1405 }
1406
1407 return false;
1408 }
1409
1410 /**
1411 * Get the number of courses created by an instructor within a given date range.
1412 *
1413 * @since 4.0.0
1414 *
1415 * If no date range is provided, returns the total course count for the instructor.
1416 * Course creation date is determined using the `_wp_old_date` meta value when available; otherwise, the course
1417 * post date is used.
1418 *
1419 * @param string|null $start_date Start date in Y-m-d format.
1420 * @param string|null $end_date End date in Y-m-d format.
1421 * @param int $user_id Course author (user) ID.
1422 *
1423 * @return int Total number of matching courses.
1424 */
1425 public static function get_course_count_by_date( $start_date, $end_date, $user_id ) {
1426
1427 $user_id = tutor_utils()->get_user_id( $user_id );
1428
1429 if ( empty( $start_date ) && empty( $end_date ) ) {
1430 return (int) self::get_courses_by_instructor( $user_id, array( 'publish', 'private' ), 0, PHP_INT_MAX, true );
1431 }
1432
1433 $courses = self::get_courses_by_instructor( $user_id, array( 'publish', 'private' ), 0, PHP_INT_MAX );
1434
1435 if ( empty( $courses ) ) {
1436 return 0;
1437 }
1438
1439 $start_date_timestamp = strtotime( $start_date . ' 00:00:00' );
1440 $end_date_timestamp = strtotime( $end_date . ' 23:59:59' );
1441
1442 $filtered = array_filter(
1443 $courses,
1444 function ( $course ) use ( $start_date_timestamp, $end_date_timestamp ) {
1445 $creation_date = get_post_meta( $course->ID, '_wp_old_date', true );
1446
1447 $course_creation_date = empty( $creation_date ) ? strtotime( $course->post_date_gmt ) : strtotime( $creation_date );
1448
1449 return $start_date_timestamp <= $course_creation_date && $end_date_timestamp >= $course_creation_date;
1450 }
1451 );
1452
1453 return count( $filtered );
1454 }
1455
1456 /**
1457 * Get topic-wise progress data for a course and a student.
1458 *
1459 * @since 4.0.0
1460 *
1461 * @param int $course_id Course ID.
1462 * @param int $student_id Student ID.
1463 *
1464 * @return array[] Topic progress data.
1465 */
1466 public static function get_course_progress_details( $course_id, $student_id ) {
1467
1468 $topics_query = tutor_utils()->get_topics( $course_id );
1469 $topic_list = array();
1470
1471 if ( empty( $topics_query ) || ! $topics_query->have_posts() ) {
1472 return $topic_list;
1473 }
1474
1475 foreach ( $topics_query->posts as $topic_post ) {
1476 $topic_id = (int) $topic_post->ID;
1477 $items = array();
1478 $completion_percentage = tutor_utils()->count_completed_contents_by_topic( $topic_id, $student_id );
1479 $contents_query = tutor_utils()->get_course_contents_by_topic( $topic_id, -1 );
1480
1481 if ( ! empty( $contents_query ) && $contents_query->have_posts() ) {
1482 foreach ( $contents_query->posts as $content_post ) {
1483 $items[] = ( new self() )->build_course_progress_item( $content_post, $student_id, $course_id );
1484 }
1485 }
1486
1487 $topic_list[] = array(
1488 'id' => $topic_id,
1489 'summary' => $topic_post->post_content,
1490 'title' => get_the_title( $topic_id ),
1491 'items' => $items,
1492 'completion_percentage' => $completion_percentage['percentage'] ?? 0,
1493 );
1494 }
1495
1496 wp_reset_postdata();
1497
1498 return $topic_list;
1499 }
1500
1501 /**
1502 * Check if the current user has course content access
1503 *
1504 * @since 4.0.0
1505 *
1506 * @throws InvalidArgumentException If args are invalid.
1507 *
1508 * @param array $args Array of arguments.
1509 *
1510 * `$defaults = array(
1511 * 'current_post_type' => '',
1512 * 'current_post_id' => 0,
1513 * 'course_id' => 0,
1514 * 'is_public' => false,
1515 * 'is_enrolled' => false,
1516 * );`.
1517 *
1518 * @return bool
1519 */
1520 public static function has_course_content_access( array $args = array() ): bool {
1521 if ( User::is_admin() ) {
1522 return true;
1523 }
1524
1525 $defaults = array(
1526 'current_post_type' => '',
1527 'current_post_id' => 0,
1528 'course_id' => 0,
1529 'is_public' => false,
1530 'is_enrolled' => false,
1531 );
1532
1533 $args = wp_parse_args( $args, $defaults );
1534
1535 if ( ! $args['course_id'] || ! $args['current_post_id'] || ! $args['current_post_type'] ) {
1536 throw new InvalidArgumentException( __( 'Invalid argument passed', 'tutor' ) );
1537 }
1538
1539 $has_access = false;
1540 $user_id = get_current_user_id();
1541 switch ( $args['current_post_type'] ) {
1542 case tutor()->lesson_post_type:
1543 $is_preview = (int) get_post_meta( $args['current_post_id'], Lesson::PREVIEW_META_KEY, true );
1544 if ( $args['is_public'] || $is_preview ) {
1545 $has_access = true;
1546 } elseif ( $args['is_enrolled'] || tutor_utils()->has_enrolled_content_access( 'lesson' ) ) {
1547 $has_access = true;
1548 }
1549 break;
1550 case tutor()->quiz_post_type:
1551 case tutor()->assignment_post_type:
1552 if ( $user_id ) {
1553 $content_type = tutor()->quiz_post_type === $args['current_post_type'] ? 'quiz' : 'assignment';
1554 if ( $args['is_enrolled'] || $args['is_public'] || tutor_utils()->has_enrolled_content_access( $content_type ) ) {
1555 $has_access = true;
1556 }
1557 }
1558 break;
1559 default:
1560 if ( $args['is_enrolled'] ) {
1561 $has_access = true;
1562 }
1563 break;
1564 }
1565
1566 return apply_filters( 'tutor_course_content_access', $has_access, $args );
1567 }
1568
1569 /**
1570 * Build course progress item data for a specific lesson, quiz, assignment, or meeting.
1571 *
1572 * @since 4.0.0
1573 *
1574 * @param \WP_Post $post The course content post object.
1575 * @param int $user_id The user ID for whom progress is being calculated.
1576 * @param int $course_id The course ID to which the content belongs.
1577 *
1578 * @return array Associative array of progress item data
1579 */
1580 private function build_course_progress_item( \WP_Post $post, $user_id, $course_id ) {
1581
1582 $base_items = array(
1583 'id' => $post->ID,
1584 'type' => $post->post_type,
1585 'link' => esc_url_raw( get_permalink( $post->ID ) ),
1586 'title' => $post->post_title,
1587 );
1588
1589 switch ( $post->post_type ) {
1590 case tutor()->quiz_post_type:
1591 $quiz_status = '';
1592 $icon_name = Icon::QUIZ_2;
1593 $icon_class = 'tutor-text-subdued';
1594 $last_attempt = ( new QuizModel() )->get_first_or_last_attempt( $post->ID, $user_id );
1595
1596 if ( is_object( $last_attempt ) && QuizModel::ATTEMPT_STARTED !== $last_attempt->attempt_status ) {
1597 $quiz_status = QuizModel::get_quiz_result( $post->ID, $user_id );
1598
1599 $quiz_icon_map = array(
1600 QuizModel::RESULT_FAIL => array( Icon::CROSS_COLORIZE, 'tutor-icon-critical' ),
1601 QuizModel::RESULT_PENDING => array( Icon::INFO_COLORIZE, 'tutor-icon-warning-secondary' ),
1602 );
1603
1604 if ( isset( $quiz_icon_map[ $quiz_status ] ) ) {
1605 list( $icon_name, $icon_class ) = $quiz_icon_map[ $quiz_status ];
1606 }
1607 }
1608
1609 return array_merge(
1610 $base_items,
1611 array(
1612 'is_completed' => QuizModel::RESULT_PASS === $quiz_status,
1613 'label' => __( 'Quiz', 'tutor' ),
1614 'icon' => $icon_name,
1615 'icon_class' => $icon_class,
1616 )
1617 );
1618
1619 case tutor()->assignment_post_type:
1620 $status = '';
1621
1622 $is_submitted = tutor_utils()->is_assignment_submitted( $post->ID, $user_id );
1623 $status = $is_submitted ? Assignments::get_assignment_result( $post->ID, $user_id ) : $status;
1624
1625 if ( ! $is_submitted && Assignments::is_expired( $post->ID, $user_id, $course_id ) ) {
1626 $status = 'fail';
1627 }
1628
1629 $icon = Icon::BOOK_2;
1630 $icon_class = 'tutor-text-subdued';
1631
1632 $status_map = array(
1633 'pending' => array( Icon::INFO_COLORIZE, 'tutor-icon-warning-secondary' ),
1634 'fail' => array( Icon::CROSS_COLORIZE, 'tutor-icon-critical' ),
1635 );
1636
1637 if ( isset( $status_map[ $status ] ) ) {
1638 list( $icon, $icon_class ) = $status_map[ $status ];
1639 }
1640
1641 return array_merge(
1642 $base_items,
1643 array(
1644 'is_completed' => 'pass' === $status,
1645 'label' => __( 'Assignment', 'tutor' ),
1646 'icon' => $icon,
1647 'icon_class' => $icon_class,
1648 )
1649 );
1650
1651 case tutor()->zoom_post_type:
1652 if ( ! tutor_utils()->is_addon_enabled( 'tutor-zoom' ) ) {
1653 return $base_items;
1654 }
1655
1656 return array_merge(
1657 $base_items,
1658 array(
1659 'label' => __( 'Zoom', 'tutor' ),
1660 'icon' => Icon::ZOOM,
1661 'is_completed' => \TUTOR_ZOOM\Zoom::is_zoom_lesson_done( '', $post->ID, $user_id ),
1662 )
1663 );
1664
1665 case tutor()->meet_post_type:
1666 if ( ! tutor_utils()->is_addon_enabled( 'google-meet' ) ) {
1667 return $base_items;
1668 }
1669
1670 return array_merge(
1671 $base_items,
1672 array(
1673 'label' => __( 'Google Meet', 'tutor' ),
1674 'icon' => Icon::GOOGLE_MEET,
1675 'is_completed' => \TutorPro\GoogleMeet\Frontend\Frontend::is_lesson_completed( false, $post->ID, $user_id ),
1676 )
1677 );
1678
1679 default:
1680 $video = tutor_utils()->get_video_info( $post->ID );
1681
1682 return array_merge(
1683 $base_items,
1684 array(
1685 'video' => $video,
1686 'video_play_time' => isset( $video->playtime ) ? $video->playtime : '',
1687 'is_completed' => (bool) tutor_utils()->is_completed_lesson( $post->ID, $user_id ),
1688 'label' => empty( $video ) ? __( 'Reading', 'tutor' ) : __( 'Video', 'tutor' ),
1689 'icon' => empty( $video ) ? Icon::COURSES : Icon::VIDEO_CAMERA,
1690 )
1691 );
1692 }
1693 }
1694
1695 /**
1696 * Calculate the total course duration for a set of courses and returns the total time in hours, minutes, and
1697 * seconds.
1698 *
1699 * @since 4.0.0
1700 *
1701 * @param array<int> | int $course_ids List of course IDs or a single course ID.
1702 *
1703 * @return array{
1704 * hours: int,
1705 * minutes: int,
1706 * seconds: int
1707 * } Total accumulated duration from all given courses.
1708 */
1709 public static function get_total_course_duration( $course_ids ): array {
1710 $total_seconds = 0;
1711 $course_ids = is_array( $course_ids ) ? $course_ids : array( $course_ids );
1712 foreach ( $course_ids as $id ) {
1713 $total_seconds += self::get_course_duration_in_seconds( $id );
1714 }
1715
1716 return DateTimeHelper::split_seconds_into_time_units( $total_seconds );
1717 }
1718
1719 /**
1720 * Calculate the estimated time spent on courses based on completion progress.
1721 *
1722 * @since 4.0.0
1723 *
1724 * @param array<int> $course_ids List of course IDs.
1725 *
1726 * @return array{
1727 * hours: int,
1728 * minutes: int,
1729 * seconds: int
1730 * } Total accumulated duration from all given courses.
1731 */
1732 public static function get_total_estimated_time_spent( $course_ids ): array {
1733 $total_seconds = 0;
1734 foreach ( $course_ids as $id ) {
1735 $completion_percentage = tutor_utils()->is_completed_course( $id )
1736 ? 100
1737 : (int) tutor_utils()->get_course_completed_percent( (int) $id );
1738
1739 if ( 0 === $completion_percentage ) {
1740 continue;
1741 }
1742 $course_duration_in_seconds = self::get_course_duration_in_seconds( $id );
1743 // Calculate the time spent by a user based on the course completion percentage.
1744 $total_seconds += (int) round( $course_duration_in_seconds * ( $completion_percentage / 100 ) );
1745 }
1746
1747 return DateTimeHelper::split_seconds_into_time_units( $total_seconds );
1748 }
1749
1750 /**
1751 * Get the total duration of a course in seconds.
1752 *
1753 * @since 4.0.0
1754 *
1755 * @param int $course_id Course ID.
1756 *
1757 * @return int Course duration in seconds.
1758 */
1759 public static function get_course_duration_in_seconds( int $course_id ): int {
1760 $duration = tutor_utils()->get_course_duration( $course_id, true );
1761
1762 return ( $duration['durationHours'] * HOUR_IN_SECONDS )
1763 + ( $duration['durationMinutes'] * MINUTE_IN_SECONDS )
1764 + ( $duration['durationSeconds'] );
1765 }
1766 }
1767