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