PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.11.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.11.0
2.11.0 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 All 78 releases
← All changes | Modules/Course/Services/CourseHelper.php +346 -64 1.0.902.11.0 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,30 +427,35 @@
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', 'shuffle_options', 'shuffle_questions', 'layout'];
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', 'shuffle_options', 'shuffle_questions'];
357 446
358 447 foreach ($yesNoFields as $field) {
359 448 $meta[$field] = Arr::get($meta, $field, 'no') == 'yes' ? 'yes' : 'no';
360 449 }
361 450
451 + if ($lesson->isQuizType()) {
452 + $meta['layout'] = Arr::get($meta, 'layout') === 'one_by_one' ? 'one_by_one' : 'all';
453 + }
454 +
362 455 if (Arr::get($meta, 'enable_media') == 'yes') {
363 456 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;
457 + $meta['media'] = self::sanitizeMedia($media);
374 458 }
375 459 } else {
376 460 $meta['media'] = [
377 461 'provider' => ''
@@ -377,32 +461,230 @@
377 461 'provider' => ''
378 462 ];
379 463 }
380 464
381 - $numericFields = ['video_length'];
465 + $numericFields = ['video_length', 'passing_score', 'video_completion_threshold'];
382 466
383 467 foreach ($numericFields as $field) {
384 468 $meta[$field] = absint(Arr::get($meta, $field, 0));
385 469 }
386 470
387 - return $meta;
471 + // 0 means "not set" — the gate falls back to its default threshold
472 + if ($meta['video_completion_threshold']) {
473 + $meta['video_completion_threshold'] = min(100, max(1, $meta['video_completion_threshold']));
474 + }
475 +
476 + return apply_filters('fluent_community/lesson/sanitize_meta', $meta, $lesson);
388 477 }
389 478
479 + public static function sanitizeMedia($media)
480 + {
481 + return array_filter([
482 + 'type' => sanitize_text_field(Arr::get($media, 'type', '')),
483 + 'url' => sanitize_url(Arr::get($media, 'url', '')),
484 + 'content_type' => sanitize_text_field(Arr::get($media, 'content_type', '')),
485 + 'provider' => sanitize_url(Arr::get($media, 'provider', '')),
486 + 'title' => sanitize_text_field(Arr::get($media, 'title', '')),
487 + 'author_name' => sanitize_text_field(Arr::get($media, 'author_name', '')),
488 + 'html' => CustomSanitizer::sanitizeRichText(Arr::get($media, 'html', '')),
489 + 'image' => sanitize_url(Arr::get($media, 'image', ''))
490 + ]);
491 + }
492 +
390 493 public static function getCourseCategories()
391 494 {
392 495 $terms = Term::whereHas('base_spaces', function ($q) {
393 496 $q->where('type', 'course');
394 - })->get();
497 + })->orderBy('title', 'ASC')->get();
498 + // Respect the admin-defined order (settings.serial)
499 + $terms = $terms->sort(function ($a, $b) {
500 + $serialA = Arr::get($a->settings, 'serial');
501 + $serialB = Arr::get($b->settings, 'serial');
502 + if ($serialA === null && $serialB === null) {
503 + return 0;
504 + }
505 + if ($serialA === null) {
506 + return 1;
507 + }
508 + if ($serialB === null) {
509 + return -1;
510 + }
511 + return (int) $serialA <=> (int) $serialB;
512 + })->values();
395 513
396 514 $formattedTerms = [];
397 515
398 516 foreach ($terms as $term) {
399 517 $formattedTerms[] = [
400 - 'id' => $term->id,
401 - 'title' => $term->title,
402 - 'slug' => $term->slug
518 + 'id' => $term->id,
519 + 'title' => $term->title,
520 + 'slug' => $term->slug
403 521 ];
404 522 }
405 523
406 524 return $formattedTerms;
407 525 }
526 +
527 + public static function getUserCourses($userId = null)
528 + {
529 + if (!$userId) {
530 + $userId = get_current_user_id();
531 + }
532 +
533 + if (!$userId) {
534 + return null;
535 + }
536 +
537 + return Course::whereHas('students', function ($q) use ($userId) {
538 + $q->where('user_id', $userId);
539 + })
540 + ->with('enrollment', function ($q) use ($userId) {
541 + $q->where('user_id', $userId);
542 + })
543 + ->get();
544 + }
545 +
546 + public static function santizeLessonBody($body)
547 + {
548 + if (current_user_can('unfiltered_html')) {
549 + return $body;
550 + }
551 +
552 + return wp_kses_post($body);
553 + }
554 +
555 + public static function formatLessonData($course, $lesson, $user = null, $config = [])
556 + {
557 + $canViewLesson = Arr::get($config, 'can_view', false);
558 + $parseContent = Arr::get($config, 'parse_content', true);
559 +
560 + $inlineCss = '';
561 + if ($parseContent && $canViewLesson) {
562 + $content = $lesson->message_rendered;
563 +
564 + // message_rendered still holding block markup never went through the_content
565 + if (!$content || has_blocks($content)) {
566 + $source = $lesson->message ?: $content;
567 +
568 + if ($source) {
569 + $content = apply_filters('the_content', $source); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
570 + if (function_exists('wp_style_engine_get_stylesheet_from_context')) {
571 + $core_styles_keys = array('block-supports');
572 + // Adds comment if code is prettified to identify core styles sections in debugging.
573 + foreach ($core_styles_keys as $style_key) {
574 + $inlineCss .= wp_style_engine_get_stylesheet_from_context($style_key, []);
575 + }
576 + }
577 + }
578 + }
579 +
580 + if (!$content) {
581 + $content = '';
582 + }
583 + $content = (new SmartCodeParser())->parse($content, $user);
584 + } else {
585 + $content = '';
586 + }
587 +
588 + $formattedLesson = [
589 + 'id' => $lesson->id,
590 + 'title' => $lesson->title,
591 + 'content' => $content,
592 + 'slug' => $lesson->slug,
593 + 'course_id' => $lesson->space_id,
594 + 'section_id' => $lesson->parent_id,
595 + 'created_at' => $lesson->created_at->format('Y-m-d H:i:s'),
596 + 'content_type' => $lesson->content_type,
597 + 'featured_image' => $lesson->featured_image,
598 + 'meta' => $lesson->getPublicLessonMeta($canViewLesson),
599 + 'comments_count' => $lesson->comments_count,
600 + 'is_locked' => Arr::get($config, 'is_locked', false),
601 + 'unlock_date' => Arr::get($config, 'unlock_date', ''),
602 + 'lock_type' => Arr::get($config, 'lock_type', ''),
603 + 'can_view' => $canViewLesson,
604 + 'inline_css' => $inlineCss
605 + ];
606 +
607 + if (!$canViewLesson) {
608 + $formattedLesson['access_message'] = static::getAccessMessage($course, $lesson, $config);
609 + }
610 +
611 + return $formattedLesson;
612 + }
613 +
614 + public static function copyLessonDocuments($lesson, $newLesson)
615 + {
616 + if (!$lesson || !$newLesson) {
617 + return;
618 + }
619 +
620 + $newLessonMeta = $newLesson->meta;
621 + $newLessonMeta['document_lists'] = [];
622 + foreach ($lesson->media as $media) {
623 + if (!$media->is_active) {
624 + continue;
625 + }
626 + $newMedia = $media->replicate();
627 + $newMedia->feed_id = $newLesson->id;
628 + $newMedia->save();
629 + if ($media->object_source == 'lesson_document') {
630 + $newLessonMeta['document_lists'][] = $newMedia->getPrivateFileMeta();
631 + }
632 + }
633 +
634 + $newLesson->meta = $newLessonMeta;
635 + $newLesson->save();
636 + }
637 +
638 + public static function getAccessMessage($course, $lesson, $config)
639 + {
640 + $isLocked = Arr::get($config, 'is_locked', false);
641 + $unlockDate = Arr::get($config, 'unlock_date', '');
642 + $courseLessonsUrl = Helper::baseUrl('/course/' . $course->slug . '/lessons');
643 +
644 + $headerMessage = __('This lesson is currently locked', 'fluent-community');
645 + $bodyMessage = __('Please enroll in this course to access this lesson', 'fluent-community');
646 + $backToCourseText = __('Back to Course', 'fluent-community');
647 +
648 + if ($isLocked && Arr::get($config, 'lock_type') === 'sequential') {
649 + $headerMessage = __('Complete the previous lesson first', 'fluent-community');
650 + $bodyMessage = __('You need to complete the previous lesson before unlocking this one.', 'fluent-community');
651 + } elseif ($isLocked && $unlockDate) {
652 + $headerMessage = __('This lesson is not published for you yet', 'fluent-community');
653 + /* translators: %s is replaced by the date */
654 + $bodyMessage = sprintf(__('It will be available to you on %s', 'fluent-community'),
655 + date_i18n(get_option('date_format'), strtotime($unlockDate))
656 + );
657 + }
658 +
659 + /* 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 */
660 + $accessMessage = sprintf('<div class="fcom_locker"><h1>%s</h1><p>%s</p><a href="%s" class="el-button el-button--info">%s</a></div>',
661 + $headerMessage,
662 + $bodyMessage,
663 + $courseLessonsUrl,
664 + $backToCourseText
665 + );
666 +
667 + return apply_filters('fluent_community/course/access_message_html', $accessMessage, $course, $lesson, $config);
668 + }
669 +
670 + public static function getParsedEmailSubject($text, $section, $user)
671 + {
672 + $parsedSubject = (new SmartCodeParser())->parse($text, $user, $section, false);
673 +
674 + return $parsedSubject;
675 + }
676 +
677 + public static function getParsedEmailBody($text, $section, $user)
678 + {
679 + $parsedBody = (new SmartCodeParser())->parse($text, $user, $section);
680 +
681 + $emailComposer = new \FluentCommunity\App\Services\Libs\EmailComposer();
682 +
683 + $emailComposer->addBlock('html_content', $parsedBody);
684 + $emailComposer->setDefaultLogo();
685 + $emailComposer->setDefaultFooter();
686 +
687 + return $emailComposer->getHtml();
688 + }
689 +
408 690 }