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
fluent-community / Modules / Course / Services / CourseHelper.php

CourseHelper.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.11.0, at Modules/Course/Services/CourseHelper.php

691 lines 22.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\Modules\Course\Services;
4
5 use FluentCommunity\App\Models\Activity;
6 use FluentCommunity\App\Models\Meta;
7 use FluentCommunity\App\Models\Reaction;
8 use FluentCommunity\App\Models\SpaceUserPivot;
9 use FluentCommunity\App\Models\Term;
10 use FluentCommunity\App\Services\CustomSanitizer;
11 use FluentCommunity\App\Services\Helper;
12 use FluentCommunity\App\Services\SmartCodeParser;
13 use FluentCommunity\Framework\Support\Arr;
14 use FluentCommunity\Modules\Course\Model\Course;
15 use FluentCommunity\Modules\Course\Model\CourseLesson;
16 use FluentCommunity\Modules\Course\Model\CourseTopic;
17
18 class CourseHelper
19 {
20 protected static $completedLessonsCache = [];
21
22 public static function getCourseProgressTrack($courseId, $userId = null)
23 {
24 $isEnrolled = self::isEnrolled($courseId, $userId);
25
26 return [
27 'completed_lessons' => $isEnrolled ? self::getCompletedLessonIds($courseId, $userId) : [],
28 'isEnrolled' => $isEnrolled,
29 'progress' => $isEnrolled ? self::getCourseProgress($courseId, $userId) : 0,
30 ];
31 }
32
33 public static function resolveLessonAccess($initialCanView, $lesson, $course, $user, $ctx = [])
34 {
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'] = '';
43 }
44
45 return $access;
46 }
47
48 public static function isEnrolled($courseId, $userId = null)
49 {
50 return (bool) self::getCourseEnrollment($courseId, $userId);
51 }
52
53 public static function getCourseEnrollment($courseId, $userId = null)
54 {
55 if (!$userId) {
56 $userId = get_current_user_id();
57 }
58
59 if (!$userId) {
60 return null;
61 }
62
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)
70 ->where('user_id', $userId)
71 ->first();
72 }
73
74 public static function getCoursePublishedLessonIds($courseId)
75 {
76 static $counts = [];
77
78 if (isset($counts[$courseId])) {
79 return $counts[$courseId];
80 }
81
82 $counts[$courseId] = CourseLesson::where('space_id', $courseId)
83 ->where('status', 'published')
84 ->pluck('id')
85 ->toArray();
86
87 return $counts[$courseId];
88 }
89
90 public static function getCourseProgress($courseId, $userId = null)
91 {
92 if (!$userId) {
93 $userId = get_current_user_id();
94 }
95
96 if (!$userId) {
97 return 0;
98 }
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
113 $lessonIds = self::getCoursePublishedLessonIds($courseId);
114 $totalLessons = count($lessonIds);
115
116 if (!$totalLessons) {
117 return $progressMap;
118 }
119
120 $completionCounts = Reaction::whereIn('user_id', $userIds)
121 ->whereIn('object_id', $lessonIds)
122 ->where('object_type', 'lesson_completed')
123 ->where('type', 'completed')
124 ->groupBy('user_id')
125 ->selectRaw('user_id, COUNT(*) as completed_count')
126 ->pluck('completed_count', 'user_id');
127
128 foreach ($completionCounts as $userId => $completed) {
129 $result = floor(($completed / $totalLessons) * 100);
130 $progressMap[$userId] = min($result, 100);
131 }
132
133 return $progressMap;
134 }
135
136 public static function getCompletedLessonIds($courseId, $userId = null)
137 {
138 if (!$userId) {
139 $userId = get_current_user_id();
140 }
141
142 if (!$userId) {
143 return [];
144 }
145
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)
152 ->where('parent_id', $courseId)
153 ->where('object_type', 'lesson_completed')
154 ->where('type', 'completed')
155 ->pluck('object_id')
156 ->toArray();
157 }
158
159 public static function overallCourseProgressAverage($course)
160 {
161 $lessonsCount = CourseLesson::where('space_id', $course->id)
162 ->where('status', 'published')
163 ->count();
164
165 if (!$lessonsCount) {
166 return 0;
167 }
168
169 $allCompletedCount = Reaction::where('object_type', 'lesson_completed')
170 ->where('type', 'completed')
171 ->where('parent_id', $course->id)
172 ->count();
173
174 $studentsCount = $course->students_count ? $course->students_count : $course->students()->count();
175
176 if (!$studentsCount) {
177 return 0;
178 }
179
180 // calculate the average of all students
181 return ceil(($allCompletedCount / ($studentsCount * $lessonsCount)) * 100);
182 }
183
184 public static function updateLessonCompletion($lesson, $userId, $state = 'completed')
185 {
186 $reaction = Reaction::where('user_id', $userId)
187 ->where('object_id', $lesson->id)
188 ->where('object_type', 'lesson_completed')
189 ->first();
190
191 if ($reaction) {
192 if ($state != 'completed') {
193 $reaction->type = 'incomplete';
194 $reaction->save();
195 do_action('fluent_community/course/lesson_marked_incomplete', $lesson, $userId);
196 } else {
197 $reaction->type = 'completed';
198 $reaction->save();
199 }
200 return true;
201 }
202
203 Reaction::create([
204 'user_id' => $userId,
205 'object_id' => $lesson->id,
206 'object_type' => 'lesson_completed',
207 'parent_id' => $lesson->space_id,
208 'type' => 'completed'
209 ]);
210
211 do_action('fluent_community/course/lesson_completed', $lesson, $userId);
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
231 return true;
232 }
233
234 public static function getCourseMeta($key, $default = false, $withModel = false)
235 {
236 $meta = Meta::where('meta_key', $key)
237 ->where('object_type', 'course')
238 ->first();
239
240 if ($withModel) {
241 return $meta;
242 }
243
244 return $meta ? $meta->value : $default;
245 }
246
247 public static function updateCourseMeta($key, $value)
248 {
249 $meta = Meta::where('meta_key', $key)
250 ->where('object_type', 'course')
251 ->first();
252
253 if ($meta) {
254 $meta->update([
255 'value' => $value
256 ]);
257 return $meta;
258 }
259
260 return Meta::create([
261 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
262 'value' => $value,
263 'object_type' => 'course'
264 ]);
265 }
266
267 public static function completeCourse($course, $lesson, $userId)
268 {
269 $activity = Activity::where('feed_id', $course->id)
270 ->where('user_id', $userId)
271 ->where('action_name', 'course_completed')
272 ->first();
273
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 }
281 return false;
282 }
283
284 Activity::create([
285 'feed_id' => $course->id,
286 'user_id' => $userId,
287 'action_name' => 'course_completed'
288 ]);
289
290 do_action('fluent_community/course/completed', $course, $userId);
291
292 return true;
293 }
294
295 public static function resetCourseProgress($courseId, $userId)
296 {
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;
326 }
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
333 public static function enrollCourses($courseIds, $userId = null, $by = 'self')
334 {
335 if (!$userId) {
336 $userId = get_current_user_id();
337 }
338
339 $enrolledCourseIds = [];
340
341 foreach ($courseIds as $courseId) {
342 $course = Course::find($courseId);
343 if ($course) {
344 if (self::enrollCourse($course, $userId, $by)) {
345 $enrolledCourseIds[] = $courseId;
346 }
347 }
348 }
349
350 return $enrolledCourseIds;
351 }
352
353 public static function leaveCourse($course, $userId = null, $by = 'self')
354 {
355 if (!$userId) {
356 $userId = get_current_user_id();
357 }
358
359 if (!$userId) {
360 return false;
361 }
362
363 return Helper::removeFromSpace($course, $userId, $by);
364 }
365
366 public static function leaveCourses($courseIds, $userId = null, $by = 'self')
367 {
368 if (!$userId) {
369 $userId = get_current_user_id();
370 }
371
372 $processedCourseIds = [];
373
374 foreach ($courseIds as $courseId) {
375 $course = Course::find($courseId);
376 if ($course) {
377 if (self::leaveCourse($course, $userId, $by)) {
378 $processedCourseIds[] = $courseId;
379 }
380 }
381
382 }
383
384 return $processedCourseIds;
385 }
386
387 public static function getEnrolledCourseIds($userId = null)
388 {
389 if (!$userId) {
390 $userId = get_current_user_id();
391 }
392
393 if (!$userId) {
394 return [];
395 }
396
397 return SpaceUserPivot::where('user_id', $userId)
398 ->where('role', 'student')
399 ->pluck('space_id')
400 ->toArray();
401
402 }
403
404 public static function getSectionAccessDate($section, $courseType, $enrollment = null)
405 {
406 if ($courseType == 'self_paced') {
407 return null;
408 }
409
410 if ($courseType == 'scheduled') {
411 return $section->scheduled_at;
412 }
413
414 // This is for structured course
415 if (!$enrollment) {
416 return null;
417 }
418
419 $accessAfterEnrollment = $section->reactions_count;
420
421 return gmdate('Y-m-d H:i:s', strtotime($enrollment->created_at) + $accessAfterEnrollment * 86400);
422 }
423
424 public static function sanitizeLessonMeta($meta, $lesson)
425 {
426 $validFields = [
427 'enable_comments',
428 'enable_media',
429 'media',
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'
436 ];
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
443 $meta = Arr::only($meta, $validFields);
444
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'];
446
447 foreach ($yesNoFields as $field) {
448 $meta[$field] = Arr::get($meta, $field, 'no') == 'yes' ? 'yes' : 'no';
449 }
450
451 if ($lesson->isQuizType()) {
452 $meta['layout'] = Arr::get($meta, 'layout') === 'one_by_one' ? 'one_by_one' : 'all';
453 }
454
455 if (Arr::get($meta, 'enable_media') == 'yes') {
456 if ($media = Arr::get($meta, 'media', [])) {
457 $meta['media'] = self::sanitizeMedia($media);
458 }
459 } else {
460 $meta['media'] = [
461 'provider' => ''
462 ];
463 }
464
465 $numericFields = ['video_length', 'passing_score', 'video_completion_threshold'];
466
467 foreach ($numericFields as $field) {
468 $meta[$field] = absint(Arr::get($meta, $field, 0));
469 }
470
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);
477 }
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
493 public static function getCourseCategories()
494 {
495 $terms = Term::whereHas('base_spaces', function ($q) {
496 $q->where('type', 'course');
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();
513
514 $formattedTerms = [];
515
516 foreach ($terms as $term) {
517 $formattedTerms[] = [
518 'id' => $term->id,
519 'title' => $term->title,
520 'slug' => $term->slug
521 ];
522 }
523
524 return $formattedTerms;
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
690 }
691