PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.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 1.1.0 All 77 releases
fluent-community / Modules / Course / Services / CourseHelper.php

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

687 lines 22.3 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'];
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'];
446
447 foreach ($yesNoFields as $field) {
448 $meta[$field] = Arr::get($meta, $field, 'no') == 'yes' ? 'yes' : 'no';
449 }
450
451 if (Arr::get($meta, 'enable_media') == 'yes') {
452 if ($media = Arr::get($meta, 'media', [])) {
453 $meta['media'] = self::sanitizeMedia($media);
454 }
455 } else {
456 $meta['media'] = [
457 'provider' => ''
458 ];
459 }
460
461 $numericFields = ['video_length', 'passing_score', 'video_completion_threshold'];
462
463 foreach ($numericFields as $field) {
464 $meta[$field] = absint(Arr::get($meta, $field, 0));
465 }
466
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);
473 }
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
489 public static function getCourseCategories()
490 {
491 $terms = Term::whereHas('base_spaces', function ($q) {
492 $q->where('type', 'course');
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();
509
510 $formattedTerms = [];
511
512 foreach ($terms as $term) {
513 $formattedTerms[] = [
514 'id' => $term->id,
515 'title' => $term->title,
516 'slug' => $term->slug
517 ];
518 }
519
520 return $formattedTerms;
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
686 }
687