PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.01
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.01
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
← All changes | Modules/Course/Services/CourseHelper.php +342 -64 1.0.932.10.01 View file →
@@ -8,14 +8,18 @@
8 8 use FluentCommunity\App\Models\SpaceUserPivot;
9 9 use FluentCommunity\App\Models\Term;
10 10 use FluentCommunity\App\Services\CustomSanitizer;
11 11 use FluentCommunity\App\Services\Helper;
12 +use FluentCommunity\App\Services\SmartCodeParser;
12 13 use FluentCommunity\Framework\Support\Arr;
13 14 use FluentCommunity\Modules\Course\Model\Course;
14 15 use FluentCommunity\Modules\Course\Model\CourseLesson;
16 +use FluentCommunity\Modules\Course\Model\CourseTopic;
15 17
16 18 class CourseHelper
17 19 {
20 + protected static $completedLessonsCache = [];
21 +
18 22 public static function getCourseProgressTrack($courseId, $userId = null)
19 23 {
20 24 $isEnrolled = self::isEnrolled($courseId, $userId);
21 25
@@ -25,21 +29,26 @@
25 29 'progress' => $isEnrolled ? self::getCourseProgress($courseId, $userId) : 0,
26 30 ];
27 31 }
28 32
29 - public static function isEnrolled($courseId, $userId = null)
33 + public static function resolveLessonAccess($initialCanView, $lesson, $course, $user, $ctx = [])
30 34 {
31 - if (!$userId) {
32 - $userId = get_current_user_id();
35 + $canView = apply_filters('fluent_community/course/can_view_lesson', $initialCanView, $lesson, $course, $user);
36 + $access = apply_filters('fluent_community/course/lesson_access_info', [
37 + 'can_view' => $canView,
38 + 'lock_type' => '',
39 + ], $lesson, $course, $user, $ctx);
40 +
41 + if (!empty($access['can_view'])) {
42 + $access['lock_type'] = '';
33 43 }
34 44
35 - if (!$userId) {
36 - return false;
37 - }
45 + return $access;
46 + }
38 47
39 - return SpaceUserPivot::where('space_id', $courseId)
40 - ->where('user_id', $userId)
41 - ->exists();
48 + public static function isEnrolled($courseId, $userId = null)
49 + {
50 + return (bool) self::getCourseEnrollment($courseId, $userId);
42 51 }
43 52
44 53 public static function getCourseEnrollment($courseId, $userId = null)
45 54 {
@@ -50,12 +59,17 @@
50 59 if (!$userId) {
51 60 return null;
52 61 }
53 62
54 - return SpaceUserPivot::where('space_id', $courseId)
63 + static $enrollmentCache = [];
64 + $key = $courseId . '_' . $userId;
65 + if (array_key_exists($key, $enrollmentCache)) {
66 + return $enrollmentCache[$key];
67 + }
68 +
69 + return $enrollmentCache[$key] = SpaceUserPivot::where('space_id', $courseId)
55 70 ->where('user_id', $userId)
56 71 ->first();
57 -
58 72 }
59 73
60 74 public static function getCoursePublishedLessonIds($courseId)
61 75 {
@@ -82,36 +96,42 @@
82 96 if (!$userId) {
83 97 return 0;
84 98 }
85 99
100 + $progressMap = self::getBulkCourseProgress($courseId, [$userId]);
101 +
102 + return $progressMap[$userId] ?? 0;
103 + }
104 +
105 + public static function getBulkCourseProgress($courseId, $userIds = [])
106 + {
107 + if (empty($userIds)) {
108 + return [];
109 + }
110 +
111 + $progressMap = array_fill_keys($userIds, 0);
112 +
86 113 $lessonIds = self::getCoursePublishedLessonIds($courseId);
114 + $totalLessons = count($lessonIds);
87 115
88 - if (!$lessonIds) {
89 - return 0;
116 + if (!$totalLessons) {
117 + return $progressMap;
90 118 }
91 119
92 - $completedLessons = Reaction::where('user_id', $userId)
120 + $completionCounts = Reaction::whereIn('user_id', $userIds)
93 121 ->whereIn('object_id', $lessonIds)
94 122 ->where('object_type', 'lesson_completed')
95 123 ->where('type', 'completed')
96 - ->count();
124 + ->groupBy('user_id')
125 + ->selectRaw('user_id, COUNT(*) as completed_count')
126 + ->pluck('completed_count', 'user_id');
97 127
98 - if (!$completedLessons) {
99 - return 0;
128 + foreach ($completionCounts as $userId => $completed) {
129 + $result = floor(($completed / $totalLessons) * 100);
130 + $progressMap[$userId] = min($result, 100);
100 131 }
101 132
102 - $result = floor(($completedLessons / count($lessonIds)) * 100);
103 -
104 - if (!$result) {
105 - return 0;
106 - }
107 -
108 - if ($result > 100) {
109 - return 100;
110 - }
111 -
112 - return $result;
113 -
133 + return $progressMap;
114 134 }
115 135
116 136 public static function getCompletedLessonIds($courseId, $userId = null)
117 137 {
@@ -122,9 +142,14 @@
122 142 if (!$userId) {
123 143 return [];
124 144 }
125 145
126 - return Reaction::where('user_id', $userId)
146 + $key = $courseId . '_' . $userId;
147 + if (isset(self::$completedLessonsCache[$key])) {
148 + return self::$completedLessonsCache[$key];
149 + }
150 +
151 + return self::$completedLessonsCache[$key] = Reaction::where('user_id', $userId)
127 152 ->where('parent_id', $courseId)
128 153 ->where('object_type', 'lesson_completed')
129 154 ->where('type', 'completed')
130 155 ->pluck('object_id')
@@ -166,8 +191,9 @@
166 191 if ($reaction) {
167 192 if ($state != 'completed') {
168 193 $reaction->type = 'incomplete';
169 194 $reaction->save();
195 + do_action('fluent_community/course/lesson_marked_incomplete', $lesson, $userId);
170 196 } else {
171 197 $reaction->type = 'completed';
172 198 $reaction->save();
173 199 }
@@ -181,10 +207,28 @@
181 207 'parent_id' => $lesson->space_id,
182 208 'type' => 'completed'
183 209 ]);
184 210
185 - do_action('fluent_community/lesson_completed', $lesson, $userId);
211 + do_action('fluent_community/course/lesson_completed', $lesson, $userId);
186 212
213 + // check if the whole module is completed
214 + $allTopicLessonIds = CourseLesson::where('space_id', $lesson->space_id)
215 + ->where('parent_id', $lesson->parent_id)
216 + ->where('status', 'published')
217 + ->pluck('id')
218 + ->toArray();
219 +
220 + $completedLessonCount = Reaction::where('user_id', $userId)
221 + ->whereIn('object_id', $allTopicLessonIds)
222 + ->where('object_type', 'lesson_completed')
223 + ->where('type', 'completed')
224 + ->count();
225 +
226 + if (count($allTopicLessonIds) == $completedLessonCount) {
227 + $topic = CourseTopic::find($lesson->parent_id);
228 + do_action('fluent_community/course/topic_completed', $topic, $userId, $lesson);
229 + }
230 +
187 231 return true;
188 232 }
189 233
190 234 public static function getCourseMeta($key, $default = false, $withModel = false)
@@ -213,22 +257,28 @@
213 257 return $meta;
214 258 }
215 259
216 260 return Meta::create([
217 - 'meta_key' => $key,
261 + 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
218 262 'value' => $value,
219 263 'object_type' => 'course'
220 264 ]);
221 265 }
222 266
223 - public static function completeCourse($course, $userId)
267 + public static function completeCourse($course, $lesson, $userId)
224 268 {
225 - $exists = Activity::where('feed_id', $course->id) // we are using feed_id as it's indexed in the DB
226 - ->where('user_id', $userId)
269 + $activity = Activity::where('feed_id', $course->id)
270 + ->where('user_id', $userId)
227 271 ->where('action_name', 'course_completed')
228 - ->exists();
272 + ->first();
229 273
230 - if ($exists) {
274 + if ($activity) {
275 + if (strtotime($lesson->scheduled_at) > strtotime($activity->updated_at)) {
276 + $activity->updated_at = current_time('mysql');
277 + $activity->save();
278 + do_action('fluent_community/course/completed', $course, $userId);
279 + return true;
280 + }
231 281 return false;
232 282 }
233 283
234 284 Activity::create([
@@ -241,13 +291,46 @@
241 291
242 292 return true;
243 293 }
244 294
245 - public static function enrollCourse($course, $userId = null, $by = 'self')
295 + public static function resetCourseProgress($courseId, $userId)
246 296 {
247 - return Helper::addToSpace($course, $userId, 'student', $by);
297 + $course = Course::find($courseId);
298 + if (!$course || !$userId) {
299 + return false;
300 + }
301 +
302 + do_action('fluent_community/course/before_progress_reset', $course, $userId);
303 +
304 + try {
305 + Helper::dbTransaction(function () use ($userId, $courseId) {
306 + // Wipe completion state entirely (both 'completed' and 'incomplete' types) so the student starts pristine.
307 + Reaction::where('user_id', $userId)
308 + ->where('parent_id', $courseId)
309 + ->where('object_type', 'lesson_completed')
310 + ->delete();
311 +
312 + Activity::where('user_id', $userId)
313 + ->where('feed_id', $courseId)
314 + ->where('action_name', 'course_completed')
315 + ->delete();
316 + });
317 + } catch (\Exception $e) {
318 + return false;
319 + }
320 +
321 + unset(self::$completedLessonsCache[$courseId . '_' . $userId]);
322 +
323 + do_action('fluent_community/course/progress_reset', $course, $userId);
324 +
325 + return true;
248 326 }
249 327
328 + public static function enrollCourse($course, $userId = null, $by = 'self', $skipSync = false)
329 + {
330 + return Helper::addToSpace($course, $userId, 'student', $by, $skipSync);
331 + }
332 +
250 333 public static function enrollCourses($courseIds, $userId = null, $by = 'self')
251 334 {
252 335 if (!$userId) {
253 336 $userId = get_current_user_id();
@@ -272,17 +355,13 @@
272 355 if (!$userId) {
273 356 $userId = get_current_user_id();
274 357 }
275 358
276 - if (!self::isEnrolled($course->id, $userId)) {
359 + if (!$userId) {
277 360 return false;
278 361 }
279 362
280 - SpaceUserPivot::where('user_id', $userId)->where('space_id', $course->id)->delete();
281 -
282 - do_action('fluent_community/course/left', $course, $userId, $by);
283 -
284 - return true;
363 + return Helper::removeFromSpace($course, $userId, $by);
285 364 }
286 365
287 366 public static function leaveCourses($courseIds, $userId = null, $by = 'self')
288 367 {
@@ -323,9 +402,9 @@
323 402 }
324 403
325 404 public static function getSectionAccessDate($section, $courseType, $enrollment = null)
326 405 {
327 - if ($courseType == 'slef_paced') {
406 + if ($courseType == 'self_paced') {
328 407 return null;
329 408 }
330 409
331 410 if ($courseType == 'scheduled') {
@@ -341,9 +420,9 @@
341 420
342 421 return gmdate('Y-m-d H:i:s', strtotime($enrollment->created_at) + $accessAfterEnrollment * 86400);
343 422 }
344 423
345 - public static function sanitizeLessonMeta($meta)
424 + public static function sanitizeLessonMeta($meta, $lesson)
346 425 {
347 426 $validFields = [
348 427 'enable_comments',
349 428 'enable_media',
@@ -348,13 +427,23 @@
348 427 'enable_comments',
349 428 'enable_media',
350 429 'media',
351 430 'video_length',
431 + 'featured_image_id',
432 + 'free_preview_lesson',
433 + 'require_video_completion',
434 + 'video_completion_threshold',
435 + 'auto_complete_on_video_end'
352 436 ];
353 437
438 + if ($lesson->isQuizType()) {
439 + $quizFields = ['quiz_questions', 'passing_score', 'enable_passing_score', 'enforce_passing_score', 'hide_result'];
440 + $validFields = wp_parse_args($validFields, $quizFields);
441 + }
442 +
354 443 $meta = Arr::only($meta, $validFields);
355 444
356 - $yesNoFields = ['enable_comments', 'enable_media'];
445 + $yesNoFields = ['enable_comments', 'enable_media', 'free_preview_lesson', 'require_video_completion', 'auto_complete_on_video_end', 'enable_passing_score', 'enforce_passing_score', 'hide_result'];
357 446
358 447 foreach ($yesNoFields as $field) {
359 448 $meta[$field] = Arr::get($meta, $field, 'no') == 'yes' ? 'yes' : 'no';
360 449 }
@@ -360,18 +449,9 @@
360 449 }
361 450
362 451 if (Arr::get($meta, 'enable_media') == 'yes') {
363 452 if ($media = Arr::get($meta, 'media', [])) {
364 - $media = array_filter([
365 - 'type' => sanitize_text_field(Arr::get($media, 'type', '')),
366 - 'url' => sanitize_url(Arr::get($media, 'url', '')),
367 - 'content_type' => sanitize_text_field(Arr::get($media, 'content_type', '')),
368 - 'provider' => sanitize_url(Arr::get($media, 'provider', '')),
369 - 'title' => sanitize_text_field(Arr::get($media, 'title', '')),
370 - 'author_name' => sanitize_text_field(Arr::get($media, 'author_name', '')),
371 - 'html' => CustomSanitizer::sanitizeRichText(Arr::get($media, 'html', '')),
372 - ]);
373 - $meta['media'] = $media;
453 + $meta['media'] = self::sanitizeMedia($media);
374 454 }
375 455 } else {
376 456 $meta['media'] = [
377 457 'provider' => ''
@@ -377,32 +457,230 @@
377 457 'provider' => ''
378 458 ];
379 459 }
380 460
381 - $numericFields = ['video_length'];
461 + $numericFields = ['video_length', 'passing_score', 'video_completion_threshold'];
382 462
383 463 foreach ($numericFields as $field) {
384 464 $meta[$field] = absint(Arr::get($meta, $field, 0));
385 465 }
386 466
387 - return $meta;
467 + // 0 means "not set" — the gate falls back to its default threshold
468 + if ($meta['video_completion_threshold']) {
469 + $meta['video_completion_threshold'] = min(100, max(1, $meta['video_completion_threshold']));
470 + }
471 +
472 + return apply_filters('fluent_community/lesson/sanitize_meta', $meta, $lesson);
388 473 }
389 474
475 + public static function sanitizeMedia($media)
476 + {
477 + return array_filter([
478 + 'type' => sanitize_text_field(Arr::get($media, 'type', '')),
479 + 'url' => sanitize_url(Arr::get($media, 'url', '')),
480 + 'content_type' => sanitize_text_field(Arr::get($media, 'content_type', '')),
481 + 'provider' => sanitize_url(Arr::get($media, 'provider', '')),
482 + 'title' => sanitize_text_field(Arr::get($media, 'title', '')),
483 + 'author_name' => sanitize_text_field(Arr::get($media, 'author_name', '')),
484 + 'html' => CustomSanitizer::sanitizeRichText(Arr::get($media, 'html', '')),
485 + 'image' => sanitize_url(Arr::get($media, 'image', ''))
486 + ]);
487 + }
488 +
390 489 public static function getCourseCategories()
391 490 {
392 491 $terms = Term::whereHas('base_spaces', function ($q) {
393 492 $q->where('type', 'course');
394 - })->get();
493 + })->orderBy('title', 'ASC')->get();
494 + // Respect the admin-defined order (settings.serial)
495 + $terms = $terms->sort(function ($a, $b) {
496 + $serialA = Arr::get($a->settings, 'serial');
497 + $serialB = Arr::get($b->settings, 'serial');
498 + if ($serialA === null && $serialB === null) {
499 + return 0;
500 + }
501 + if ($serialA === null) {
502 + return 1;
503 + }
504 + if ($serialB === null) {
505 + return -1;
506 + }
507 + return (int) $serialA <=> (int) $serialB;
508 + })->values();
395 509
396 510 $formattedTerms = [];
397 511
398 512 foreach ($terms as $term) {
399 513 $formattedTerms[] = [
400 - 'id' => $term->id,
401 - 'title' => $term->title,
402 - 'slug' => $term->slug
514 + 'id' => $term->id,
515 + 'title' => $term->title,
516 + 'slug' => $term->slug
403 517 ];
404 518 }
405 519
406 520 return $formattedTerms;
407 521 }
522 +
523 + public static function getUserCourses($userId = null)
524 + {
525 + if (!$userId) {
526 + $userId = get_current_user_id();
527 + }
528 +
529 + if (!$userId) {
530 + return null;
531 + }
532 +
533 + return Course::whereHas('students', function ($q) use ($userId) {
534 + $q->where('user_id', $userId);
535 + })
536 + ->with('enrollment', function ($q) use ($userId) {
537 + $q->where('user_id', $userId);
538 + })
539 + ->get();
540 + }
541 +
542 + public static function santizeLessonBody($body)
543 + {
544 + if (current_user_can('unfiltered_html')) {
545 + return $body;
546 + }
547 +
548 + return wp_kses_post($body);
549 + }
550 +
551 + public static function formatLessonData($course, $lesson, $user = null, $config = [])
552 + {
553 + $canViewLesson = Arr::get($config, 'can_view', false);
554 + $parseContent = Arr::get($config, 'parse_content', true);
555 +
556 + $inlineCss = '';
557 + if ($parseContent && $canViewLesson) {
558 + $content = $lesson->message_rendered;
559 +
560 + // message_rendered still holding block markup never went through the_content
561 + if (!$content || has_blocks($content)) {
562 + $source = $lesson->message ?: $content;
563 +
564 + if ($source) {
565 + $content = apply_filters('the_content', $source); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
566 + if (function_exists('wp_style_engine_get_stylesheet_from_context')) {
567 + $core_styles_keys = array('block-supports');
568 + // Adds comment if code is prettified to identify core styles sections in debugging.
569 + foreach ($core_styles_keys as $style_key) {
570 + $inlineCss .= wp_style_engine_get_stylesheet_from_context($style_key, []);
571 + }
572 + }
573 + }
574 + }
575 +
576 + if (!$content) {
577 + $content = '';
578 + }
579 + $content = (new SmartCodeParser())->parse($content, $user);
580 + } else {
581 + $content = '';
582 + }
583 +
584 + $formattedLesson = [
585 + 'id' => $lesson->id,
586 + 'title' => $lesson->title,
587 + 'content' => $content,
588 + 'slug' => $lesson->slug,
589 + 'course_id' => $lesson->space_id,
590 + 'section_id' => $lesson->parent_id,
591 + 'created_at' => $lesson->created_at->format('Y-m-d H:i:s'),
592 + 'content_type' => $lesson->content_type,
593 + 'featured_image' => $lesson->featured_image,
594 + 'meta' => $lesson->getPublicLessonMeta($canViewLesson),
595 + 'comments_count' => $lesson->comments_count,
596 + 'is_locked' => Arr::get($config, 'is_locked', false),
597 + 'unlock_date' => Arr::get($config, 'unlock_date', ''),
598 + 'lock_type' => Arr::get($config, 'lock_type', ''),
599 + 'can_view' => $canViewLesson,
600 + 'inline_css' => $inlineCss
601 + ];
602 +
603 + if (!$canViewLesson) {
604 + $formattedLesson['access_message'] = static::getAccessMessage($course, $lesson, $config);
605 + }
606 +
607 + return $formattedLesson;
608 + }
609 +
610 + public static function copyLessonDocuments($lesson, $newLesson)
611 + {
612 + if (!$lesson || !$newLesson) {
613 + return;
614 + }
615 +
616 + $newLessonMeta = $newLesson->meta;
617 + $newLessonMeta['document_lists'] = [];
618 + foreach ($lesson->media as $media) {
619 + if (!$media->is_active) {
620 + continue;
621 + }
622 + $newMedia = $media->replicate();
623 + $newMedia->feed_id = $newLesson->id;
624 + $newMedia->save();
625 + if ($media->object_source == 'lesson_document') {
626 + $newLessonMeta['document_lists'][] = $newMedia->getPrivateFileMeta();
627 + }
628 + }
629 +
630 + $newLesson->meta = $newLessonMeta;
631 + $newLesson->save();
632 + }
633 +
634 + public static function getAccessMessage($course, $lesson, $config)
635 + {
636 + $isLocked = Arr::get($config, 'is_locked', false);
637 + $unlockDate = Arr::get($config, 'unlock_date', '');
638 + $courseLessonsUrl = Helper::baseUrl('/course/' . $course->slug . '/lessons');
639 +
640 + $headerMessage = __('This lesson is currently locked', 'fluent-community');
641 + $bodyMessage = __('Please enroll in this course to access this lesson', 'fluent-community');
642 + $backToCourseText = __('Back to Course', 'fluent-community');
643 +
644 + if ($isLocked && Arr::get($config, 'lock_type') === 'sequential') {
645 + $headerMessage = __('Complete the previous lesson first', 'fluent-community');
646 + $bodyMessage = __('You need to complete the previous lesson before unlocking this one.', 'fluent-community');
647 + } elseif ($isLocked && $unlockDate) {
648 + $headerMessage = __('This lesson is not published for you yet', 'fluent-community');
649 + /* translators: %s is replaced by the date */
650 + $bodyMessage = sprintf(__('It will be available to you on %s', 'fluent-community'),
651 + date_i18n(get_option('date_format'), strtotime($unlockDate))
652 + );
653 + }
654 +
655 + /* translators: %s is replaced by the header message, %s is replaced by the body message, %s is replaced by the course lessons url, %s is replaced by the back to course text */
656 + $accessMessage = sprintf('<div class="fcom_locker"><h1>%s</h1><p>%s</p><a href="%s" class="el-button el-button--info">%s</a></div>',
657 + $headerMessage,
658 + $bodyMessage,
659 + $courseLessonsUrl,
660 + $backToCourseText
661 + );
662 +
663 + return apply_filters('fluent_community/course/access_message_html', $accessMessage, $course, $lesson, $config);
664 + }
665 +
666 + public static function getParsedEmailSubject($text, $section, $user)
667 + {
668 + $parsedSubject = (new SmartCodeParser())->parse($text, $user, $section, false);
669 +
670 + return $parsedSubject;
671 + }
672 +
673 + public static function getParsedEmailBody($text, $section, $user)
674 + {
675 + $parsedBody = (new SmartCodeParser())->parse($text, $user, $section);
676 +
677 + $emailComposer = new \FluentCommunity\App\Services\Libs\EmailComposer();
678 +
679 + $emailComposer->addBlock('html_content', $parsedBody);
680 + $emailComposer->setDefaultLogo();
681 + $emailComposer->setDefaultFooter();
682 +
683 + return $emailComposer->getHtml();
684 + }
685 +
408 686 }