diff --git a/apps/course/management/commands/sync_course_lessons_count.py b/apps/course/management/commands/sync_course_lessons_count.py new file mode 100644 index 0000000..a3f78ca --- /dev/null +++ b/apps/course/management/commands/sync_course_lessons_count.py @@ -0,0 +1,42 @@ +from django.core.management.base import BaseCommand + +from apps.course.models.course import Course + + +class Command(BaseCommand): + help = "Recalculate lessons_count for all courses based on active assigned course lessons." + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Preview updates without saving changes.', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + updated_courses = 0 + + for course in Course.objects.all().order_by('id'): + actual_count = course.lessons.filter(is_active=True).count() + + if course.lessons_count != actual_count: + updated_courses += 1 + self.stdout.write( + f"Course {course.id} ({course.slug}): {course.lessons_count} -> {actual_count}" + ) + if not dry_run: + Course.objects.filter(pk=course.pk).update(lessons_count=actual_count) + + if dry_run: + self.stdout.write( + self.style.WARNING( + f"Dry run complete. {updated_courses} course(s) would be updated." + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + f"Sync complete. {updated_courses} course(s) updated." + ) + ) diff --git a/apps/course/models/course.py b/apps/course/models/course.py index 227c483..dc66e9e 100644 --- a/apps/course/models/course.py +++ b/apps/course/models/course.py @@ -128,6 +128,23 @@ class Course(models.Model): def is_student_participant(self, student): return self.participants.filter(student=student).exists() + @classmethod + def recalculate_lessons_count_for_course(cls, course_id): + if not course_id: + return 0 + + from apps.course.models.lesson import CourseLesson + + lessons_count = CourseLesson.objects.filter( + course_id=course_id, + is_active=True, + ).count() + cls.objects.filter(pk=course_id).update(lessons_count=lessons_count) + return lessons_count + + def recalculate_lessons_count(self): + return self.__class__.recalculate_lessons_count_for_course(self.pk) + def save(self, *args, **kwargs): if not self.slug: diff --git a/apps/course/models/lesson.py b/apps/course/models/lesson.py index 1f593fb..15b72a4 100644 --- a/apps/course/models/lesson.py +++ b/apps/course/models/lesson.py @@ -117,7 +117,7 @@ class CourseLesson(models.Model): def save(self, *args, **kwargs): # 👇 3. Auto-sync v1 and v2 relations! - if self.chapter and not getattr(self, 'course_id', None): + if self.chapter and getattr(self, 'course_id', None) != self.chapter.course_id: self.course = self.chapter.course if not self.title: @@ -177,4 +177,4 @@ class LessonCompletion(models.Model): def __str__(self): return f"{self.student.fullname} - {self.course_lesson.title} - Completed" - \ No newline at end of file + diff --git a/apps/course/signals.py b/apps/course/signals.py index b83ac22..0e1c93f 100644 --- a/apps/course/signals.py +++ b/apps/course/signals.py @@ -1,8 +1,10 @@ from apps.course.models import Course from apps.chat.models import RoomMessage +from apps.course.models.course import Course +from apps.course.models.lesson import CourseLesson from django.db.models import Q -from django.db.models.signals import post_save, post_delete +from django.db.models.signals import post_save, post_delete, pre_save from django.dispatch import receiver from django.core.cache import cache from django.contrib.auth import get_user_model @@ -79,4 +81,45 @@ def sync_course_chat_locks(sender, instance, **kwargs): # Find rooms where initiator is prof and recipient is student, OR vice versa Q(initiator_id=instance.professor_id, recipient_id__in=student_ids) | Q(initiator_id__in=student_ids, recipient_id=instance.professor_id) - ).update(is_locked=instance.is_professor_chat_locked) \ No newline at end of file + ).update(is_locked=instance.is_professor_chat_locked) + + +def _recalculate_course_lessons_count(course_id): + Course.recalculate_lessons_count_for_course(course_id) + + +@receiver(pre_save, sender=CourseLesson) +def capture_previous_course_lesson_state(sender, instance, **kwargs): + if not instance.pk: + instance._previous_course_id = None + instance._previous_is_active = None + return + + previous = sender.objects.filter(pk=instance.pk).values('course_id', 'is_active').first() + if previous: + instance._previous_course_id = previous['course_id'] + instance._previous_is_active = previous['is_active'] + else: + instance._previous_course_id = None + instance._previous_is_active = None + + +@receiver(post_save, sender=CourseLesson) +def sync_lessons_count_on_course_lesson_save(sender, instance, **kwargs): + affected_course_ids = {instance.course_id} + + previous_course_id = getattr(instance, '_previous_course_id', None) + previous_is_active = getattr(instance, '_previous_is_active', None) + if previous_course_id and previous_course_id != instance.course_id: + affected_course_ids.add(previous_course_id) + + if previous_is_active is not None and previous_is_active != instance.is_active and previous_course_id: + affected_course_ids.add(previous_course_id) + + for course_id in affected_course_ids: + _recalculate_course_lessons_count(course_id) + + +@receiver(post_delete, sender=CourseLesson) +def sync_lessons_count_on_course_lesson_delete(sender, instance, **kwargs): + _recalculate_course_lessons_count(instance.course_id) diff --git a/apps/course/tests/test_lessons_count_sync.py b/apps/course/tests/test_lessons_count_sync.py new file mode 100644 index 0000000..468002a --- /dev/null +++ b/apps/course/tests/test_lessons_count_sync.py @@ -0,0 +1,174 @@ +from io import StringIO + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.test import TestCase + +from apps.account.models import ProfessorUser +from apps.course.models.course import Course, CourseCategory +from apps.course.models.lesson import CourseChapter, CourseLesson, Lesson + + +class LessonsCountSyncTests(TestCase): + def setUp(self): + self.professor = ProfessorUser.objects.create( + email='prof-sync@example.com', + fullname='Professor Sync', + experience_years=5, + ) + self.category = CourseCategory.objects.create( + name='Sync Category', + slug='sync-category', + ) + + def _create_course(self, slug, title, lessons_count=0): + thumbnail = SimpleUploadedFile(f'{slug}.jpg', b'filecontent', content_type='image/jpeg') + return Course.objects.create( + title=title, + slug=slug, + category=self.category, + professor=self.professor, + thumbnail=thumbnail, + video_type=Course.VedioTypeChoices.YOUTUBE_LINK, + video_link='https://example.com/video', + is_online=False, + level=Course.LevelChoices.BEGINNER, + duration=10, + lessons_count=lessons_count, + description='Description', + short_description='Short description', + status=Course.StatusChoices.ONGOING, + is_free=True, + ) + + def _create_chapter(self, course, title='Chapter', priority=1): + return CourseChapter.objects.create( + course=course, + title=title, + priority=priority, + is_active=True, + ) + + def _create_course_lesson(self, course, chapter=None, title='Lesson', is_active=True, priority=1): + if chapter is None: + chapter = self._create_chapter(course, title=f'{course.title} Chapter', priority=priority) + + lesson = Lesson.objects.create( + title=title, + content_type=Lesson.ContentTypeChoices.VIDEO_FILE, + duration=5, + ) + return CourseLesson.objects.create( + course=course, + chapter=chapter, + lesson=lesson, + priority=priority, + is_active=is_active, + ) + + def test_creating_active_course_lesson_updates_lessons_count(self): + course = self._create_course('create-active-course', 'Create Active Course') + + self._create_course_lesson(course, title='Lesson 1') + + course.refresh_from_db() + self.assertEqual(course.lessons_count, 1) + + def test_deleting_active_course_lesson_updates_lessons_count(self): + course = self._create_course('delete-active-course', 'Delete Active Course') + course_lesson = self._create_course_lesson(course, title='Lesson 1') + + course.refresh_from_db() + self.assertEqual(course.lessons_count, 1) + + course_lesson.delete() + + course.refresh_from_db() + self.assertEqual(course.lessons_count, 0) + + def test_toggling_is_active_updates_lessons_count(self): + course = self._create_course('toggle-active-course', 'Toggle Active Course') + course_lesson = self._create_course_lesson(course, title='Lesson 1', is_active=False) + + course.refresh_from_db() + self.assertEqual(course.lessons_count, 0) + + course_lesson.is_active = True + course_lesson.save() + + course.refresh_from_db() + self.assertEqual(course.lessons_count, 1) + + course_lesson.is_active = False + course_lesson.save() + + course.refresh_from_db() + self.assertEqual(course.lessons_count, 0) + + def test_moving_course_lesson_updates_old_and_new_course_counts(self): + old_course = self._create_course('old-course', 'Old Course') + new_course = self._create_course('new-course', 'New Course') + old_chapter = self._create_chapter(old_course, title='Old Chapter', priority=1) + new_chapter = self._create_chapter(new_course, title='New Chapter', priority=1) + course_lesson = self._create_course_lesson( + old_course, + chapter=old_chapter, + title='Lesson 1', + is_active=True, + priority=1, + ) + + old_course.refresh_from_db() + new_course.refresh_from_db() + self.assertEqual(old_course.lessons_count, 1) + self.assertEqual(new_course.lessons_count, 0) + + course_lesson.chapter = new_chapter + course_lesson.save() + + old_course.refresh_from_db() + new_course.refresh_from_db() + self.assertEqual(old_course.lessons_count, 0) + self.assertEqual(new_course.lessons_count, 1) + + def test_inactive_lessons_are_not_counted(self): + course = self._create_course('inactive-course', 'Inactive Course') + + self._create_course_lesson(course, title='Lesson 1', is_active=False) + self._create_course_lesson(course, title='Lesson 2', is_active=True, priority=2) + + course.refresh_from_db() + self.assertEqual(course.lessons_count, 1) + + def test_sync_course_lessons_count_command_updates_mismatched_courses(self): + first_course = self._create_course('sync-first-course', 'Sync First Course', lessons_count=99) + second_course = self._create_course('sync-second-course', 'Sync Second Course', lessons_count=7) + + self._create_course_lesson(first_course, title='Lesson 1', is_active=True) + self._create_course_lesson(first_course, title='Lesson 2', is_active=False, priority=2) + self._create_course_lesson(second_course, title='Lesson 3', is_active=True) + self._create_course_lesson(second_course, title='Lesson 4', is_active=True, priority=2) + + Course.objects.filter(pk=first_course.pk).update(lessons_count=99) + Course.objects.filter(pk=second_course.pk).update(lessons_count=7) + + stdout = StringIO() + call_command('sync_course_lessons_count', stdout=stdout) + + first_course.refresh_from_db() + second_course.refresh_from_db() + self.assertEqual(first_course.lessons_count, 1) + self.assertEqual(second_course.lessons_count, 2) + self.assertIn('Sync complete. 2 course(s) updated.', stdout.getvalue()) + + def test_sync_course_lessons_count_command_dry_run_does_not_persist_changes(self): + course = self._create_course('sync-dry-course', 'Sync Dry Course', lessons_count=5) + self._create_course_lesson(course, title='Lesson 1', is_active=True) + Course.objects.filter(pk=course.pk).update(lessons_count=5) + + stdout = StringIO() + call_command('sync_course_lessons_count', '--dry-run', stdout=stdout) + + course.refresh_from_db() + self.assertEqual(course.lessons_count, 5) + self.assertIn('Dry run complete. 1 course(s) would be updated.', stdout.getvalue())