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 +327 -56 1.0.972.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 }
@@ -183,8 +209,26 @@
183 209 ]);
184 210
185 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();
@@ -319,9 +402,9 @@
319 402 }
320 403
321 404 public static function getSectionAccessDate($section, $courseType, $enrollment = null)
322 405 {
323 - if ($courseType == 'slef_paced') {
406 + if ($courseType == 'self_paced') {
324 407 return null;
325 408 }
326 409
327 410 if ($courseType == 'scheduled') {
@@ -337,9 +420,9 @@
337 420
338 421 return gmdate('Y-m-d H:i:s', strtotime($enrollment->created_at) + $accessAfterEnrollment * 86400);
339 422 }
340 423
341 - public static function sanitizeLessonMeta($meta)
424 + public static function sanitizeLessonMeta($meta, $lesson)
342 425 {
343 426 $validFields = [
344 427 'enable_comments',
345 428 'enable_media',
@@ -344,13 +427,23 @@
344 427 'enable_comments',
345 428 'enable_media',
346 429 'media',
347 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'
348 436 ];
349 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 +
350 443 $meta = Arr::only($meta, $validFields);
351 444
352 - $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'];
353 446
354 447 foreach ($yesNoFields as $field) {
355 448 $meta[$field] = Arr::get($meta, $field, 'no') == 'yes' ? 'yes' : 'no';
356 449 }
@@ -356,18 +449,9 @@
356 449 }
357 450
358 451 if (Arr::get($meta, 'enable_media') == 'yes') {
359 452 if ($media = Arr::get($meta, 'media', [])) {
360 - $media = array_filter([
361 - 'type' => sanitize_text_field(Arr::get($media, 'type', '')),
362 - 'url' => sanitize_url(Arr::get($media, 'url', '')),
363 - 'content_type' => sanitize_text_field(Arr::get($media, 'content_type', '')),
364 - 'provider' => sanitize_url(Arr::get($media, 'provider', '')),
365 - 'title' => sanitize_text_field(Arr::get($media, 'title', '')),
366 - 'author_name' => sanitize_text_field(Arr::get($media, 'author_name', '')),
367 - 'html' => CustomSanitizer::sanitizeRichText(Arr::get($media, 'html', '')),
368 - ]);
369 - $meta['media'] = $media;
453 + $meta['media'] = self::sanitizeMedia($media);
370 454 }
371 455 } else {
372 456 $meta['media'] = [
373 457 'provider' => ''
@@ -373,22 +457,56 @@
373 457 'provider' => ''
374 458 ];
375 459 }
376 460
377 - $numericFields = ['video_length'];
461 + $numericFields = ['video_length', 'passing_score', 'video_completion_threshold'];
378 462
379 463 foreach ($numericFields as $field) {
380 464 $meta[$field] = absint(Arr::get($meta, $field, 0));
381 465 }
382 466
383 - 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);
384 473 }
385 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 +
386 489 public static function getCourseCategories()
387 490 {
388 491 $terms = Term::whereHas('base_spaces', function ($q) {
389 492 $q->where('type', 'course');
390 - })->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();
391 509
392 510 $formattedTerms = [];
393 511
394 512 foreach ($terms as $term) {
@@ -401,15 +519,168 @@
401 519
402 520 return $formattedTerms;
403 521 }
404 522
405 - public function getUserCourses($userId = null)
523 + public static function getUserCourses($userId = null)
406 524 {
407 525 if (!$userId) {
408 526 $userId = get_current_user_id();
409 527 }
410 528
529 + if (!$userId) {
530 + return null;
531 + }
532 +
411 533 return Course::whereHas('students', function ($q) use ($userId) {
412 534 $q->where('user_id', $userId);
413 - })->get();
535 + })
536 + ->with('enrollment', function ($q) use ($userId) {
537 + $q->where('user_id', $userId);
538 + })
539 + ->get();
414 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 +
415 686 }