diff --git a/README_WEBHOOK.md b/README_WEBHOOK.md deleted file mode 100644 index 9fc6833..0000000 --- a/README_WEBHOOK.md +++ /dev/null @@ -1,328 +0,0 @@ -# PlugNMeet Webhook Integration - Quick Setup Guide - -## Overview - -This project implements automatic webhook integration with PlugNMeet to handle live session events in real-time. - -## Features - -✅ **Room Management** -- Automatically close sessions when room ends -- Real-time session status updates - -✅ **Participant Tracking** -- Track when users join/leave sessions -- Maintain accurate online status - -✅ **Recording Management** -- Automatically download completed recordings -- Generate video thumbnails -- Save to database with metadata - -## Prerequisites - -### Required Software - -```bash -# Install FFmpeg (required for video thumbnail generation) -sudo apt-get update -sudo apt-get install ffmpeg - -# Verify installation -ffmpeg -version -``` - -### Django Settings - -Ensure these settings are configured in your `settings.py`: - -```python -# PlugNMeet Configuration -PLUGNMEET_SERVER_URL = "https://your-plugnmeet-server.com" -PLUGNMEET_API_KEY = "your-api-key" -PLUGNMEET_API_SECRET = "your-api-secret" -PLUGNMEET_TIMEOUT = 10.0 - -# Media files (for recordings) -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') -MEDIA_URL = '/media/' -``` - -## PlugNMeet Server Configuration - -Configure webhook in your PlugNMeet server settings: - -```yaml -# plugnmeet config.yaml -webhooks: - - url: "https://your-django-backend.com/api/course/plugnmeet/webhook/" - events: - - room_finished - - participant_joined - - participant_left - - end_recording -``` - -## Webhook Endpoint - -**URL:** `https://your-domain.com/api/course/plugnmeet/webhook/` - -**Method:** `POST` - -**Security:** HMAC SHA256 signature verification - -## Events Handled - -### 1. room_finished -- Closes the live session -- Marks all participants as offline -- Sets `ended_at` timestamp - -### 2. participant_joined -- Creates `LiveSessionUser` entry -- Sets user as online -- Records join timestamp - -### 3. participant_left -- Updates `LiveSessionUser` entry -- Sets user as offline -- Records exit timestamp - -### 4. end_recording -- Fetches recording from PlugNMeet -- Downloads recording file -- Saves to `LiveSessionRecording` model -- Generates video thumbnail (if applicable) - -## Testing - -### Using the Test Script - -```bash -# Test room_finished event -python scripts/test_webhook.py room_finished - -# Test participant_joined event -python scripts/test_webhook.py participant_joined - -# Test participant_left event -python scripts/test_webhook.py participant_left - -# Test end_recording event -python scripts/test_webhook.py end_recording - -# Dry run (show payload without sending) -python scripts/test_webhook.py room_finished --dry-run -``` - -### Manual Testing with cURL - -```bash -#!/bin/bash - -# Configuration -SECRET="your-api-secret" -URL="https://your-domain.com/api/course/plugnmeet/webhook/" - -# Sample payload -PAYLOAD='{ - "event": "room_finished", - "room": { - "identity": "test-room-20240101120000" - } -}' - -# Calculate signature -SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2) - -# Send request -curl -X POST "$URL" \ - -H "Content-Type: application/webhook+json" \ - -H "Hash-Token: $SIGNATURE" \ - -d "$PAYLOAD" -``` - -## Monitoring - -### Check Logs - -```bash -# Django logs -tail -f logs/django.log | grep "PlugNMeet Webhook" - -# Specific events -tail -f logs/django.log | grep "end_recording" -tail -f logs/django.log | grep "participant_joined" -``` - -### Log Messages - -``` -[PlugNMeet Webhook] Received webhook request -[PlugNMeet Webhook] Processing event=room_finished -[PlugNMeet Webhook] Session closed - session_id=123 room_id=test-room -[PlugNMeet Webhook] User sessions closed - session_id=123 count=5 -[PlugNMeet Webhook] Event processed successfully - event=room_finished -``` - -### Recording Download Logs - -``` -[PlugNMeet Webhook] end_recording - room_id=test-room recording_id=rec_123 -[PlugNMeet Webhook] Fetching recording info - recording_id=rec_123 -[PlugNMeet Webhook] Getting download token - recording_id=rec_123 -[PlugNMeet Webhook] Downloading recording file - recording_id=rec_123 -[PlugNMeet Webhook] File downloaded - size=524288000 bytes -[PlugNMeet Webhook] Recording saved - recording_id=456 file=recording.mp4 -[PlugNMeet Webhook] Thumbnail generated - recording_id=456 -``` - -## Database Models - -### CourseLiveSession - -```python -{ - 'id': 123, - 'course': Course instance, - 'room_id': 'test-room-20240101120000', - 'subject': 'Test Session', - 'started_at': datetime, - 'ended_at': datetime, # Set by webhook -} -``` - -### LiveSessionUser - -```python -{ - 'id': 456, - 'session': CourseLiveSession instance, - 'user': User instance, - 'role': 'participant' or 'moderator', - 'entered_at': datetime, # Set by webhook - 'exited_at': datetime, # Set by webhook - 'is_online': True/False, # Updated by webhook -} -``` - -### LiveSessionRecording - -```python -{ - 'id': 789, - 'session': CourseLiveSession instance, - 'title': 'Test Session - Recording', - 'file': FileField, # Downloaded by webhook - 'file_time': DurationField, - 'recording_type': 'video' or 'voice', - 'thumbnail': ImageField, # Generated by webhook - 'is_active': True, -} -``` - -## Troubleshooting - -### Webhook Not Receiving Events - -1. Check PlugNMeet server configuration -2. Verify webhook URL is accessible from PlugNMeet server -3. Check firewall rules -4. Review PlugNMeet server logs - -### Signature Verification Failed - -1. Ensure `PLUGNMEET_API_SECRET` matches PlugNMeet config -2. Check for extra whitespace in settings -3. Verify request is coming from PlugNMeet server - -### Recording Download Failed - -1. Check PlugNMeet server is accessible -2. Verify recording exists: `POST /auth/recording/recordingInfo` -3. Check disk space -4. Review media directory permissions - -### Thumbnail Generation Failed - -1. Verify ffmpeg is installed: `ffmpeg -version` -2. Check ffmpeg has permissions to read/write temp files -3. Review video file format (mp4, webm, mkv supported) -4. Check server resources (CPU, memory) - -### File Upload Errors - -```python -# Check media directory permissions -ls -la media/ -chmod -R 755 media/ - -# Check Django settings -python manage.py shell ->>> from django.conf import settings ->>> print(settings.MEDIA_ROOT) ->>> print(settings.MEDIA_URL) -``` - -## Performance Considerations - -### Disk Space - -- Monitor disk space for recordings -- Implement cleanup policy for old recordings -- Consider using external storage (S3, MinIO) - -### Processing Time - -- Large recordings may take time to download -- Thumbnail generation adds 1-3 seconds per video -- Consider async processing for large files (Celery) - -### Concurrent Webhooks - -- Django handles webhooks synchronously by default -- For high-traffic scenarios, consider: - - Queue system (Celery, RQ) - - Async views (Django 4.1+) - - Horizontal scaling - -## Migration from Polling - -The old polling approach has been deprecated and commented out: - -```python -# OLD (Deprecated) - in apps/course/views/course.py -# def _sync_room_status_with_plugnmeet(self, course: Course): -# client = PlugNMeetClient() -# response = client.is_room_active(active_session.room_id) -# ... - -# NEW (Webhook-based) -# Room status is automatically updated via webhooks -# No polling required -``` - -## Security Best Practices - -1. ✅ **Signature Verification**: Always enabled (HMAC SHA256) -2. ✅ **HTTPS Only**: Webhook endpoint requires HTTPS -3. ✅ **IP Whitelist**: Consider restricting to PlugNMeet server IP -4. ✅ **Rate Limiting**: Implement rate limiting on webhook endpoint -5. ✅ **Input Validation**: All webhook payloads are validated -6. ✅ **Error Handling**: Comprehensive error handling and logging - -## Support - -For issues or questions: - -1. Check logs: `logs/django.log` -2. Review documentation: `docs/plugnmeet_webhook.md` -3. Test with script: `scripts/test_webhook.py` -4. Check PlugNMeet docs: https://www.plugnmeet.org/docs - -## References - -- [PlugNMeet Webhook Documentation](docs/plugnmeet_webhook.md) -- [PlugNMeet API Documentation](docs/plugnmeet_api.md) -- [Test Script](scripts/test_webhook.py) -- [Webhook Implementation](apps/course/views/webhook.py) diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py index 423a401..dfb409b 100644 --- a/apps/course/admin/course.py +++ b/apps/course/admin/course.py @@ -27,7 +27,7 @@ from .professor_base import DirectCourseAdmin, CourseRelatedAdmin, AttachmentGlo from utils.admin import project_admin_site, dovoodi_admin_site from utils.json_editor_field import JsonEditorWidget from apps.course.models import Course, Glossary, Attachment, CourseCategory, Participant, CourseGlossary, CourseAttachment -from apps.course.models.lesson import Lesson, CourseLesson +from apps.course.models.lesson import Lesson, CourseLesson , CourseChapter from apps.account.models import StudentUser, User from apps.quiz.models import Quiz from utils.schema import get_weekly_timing_schema, get_course_feature_schema @@ -172,16 +172,44 @@ class CourseGlossaryInline(TabularInline): show_change_link = True # Removed autocomplete_fields to restore Unfold UI +class CourseChapterInline(TabularInline): + model = CourseChapter + form = MinWidthInlineForm + fields = ('title', 'is_active', 'priority') + extra = 1 + tab = True + tab_id = "chapters_tab" + show_change_link = True + ordering_field = "priority" + verbose_name = _("Chapter") + verbose_name_plural = _("Chapters") class CourseLessonInline(TabularInline): model = CourseLesson form = MinWidthInlineForm - fields = ('lesson', 'title', 'is_active', 'priority',) - extra = 1 # Show 1 empty dropdown by default + # Remove 'course' if it was there, add 'chapter' + fields = ('chapter', 'lesson', 'title', 'is_active', 'priority') + extra = 0 # Set to 0 so the page doesn't get massive tab = True tab_id = "lessons_tab" show_change_link = True ordering_field = "priority" + + def get_queryset(self, request): + qs = super().get_queryset(request) + object_id = request.resolver_match.kwargs.get('object_id') + if object_id: + # Only show lessons belonging to chapters in this course + return qs.filter(chapter__course_id=object_id).order_by('chapter__priority', 'priority') + return qs.none() + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "chapter": + object_id = request.resolver_match.kwargs.get('object_id') + if object_id: + # Limit the chapter dropdown to only chapters belonging to THIS course + kwargs["queryset"] = CourseChapter.objects.filter(course_id=object_id) + return super().formfield_for_foreignkey(db_field, request, **kwargs) # Removed autocomplete_fields to restore Unfold UI @@ -226,7 +254,7 @@ class AddStudentForm(forms.Form): class CourseAdmin(DirectCourseAdmin): form = CourseForm - inlines = [CourseLessonInline, CourseAttachmentInline, CourseGlossaryInline, CourseQuizInline, ParticipantInline] + inlines = [CourseChapterInline, CourseLessonInline, CourseAttachmentInline, CourseGlossaryInline, CourseQuizInline, ParticipantInline] list_display = ('display_header', 'category', 'display_professor', 'status', 'display_price', 'is_online') list_filter = [ diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py index 830d258..d616cf3 100644 --- a/apps/course/admin/lesson.py +++ b/apps/course/admin/lesson.py @@ -14,7 +14,7 @@ from unfold.widgets import UnfoldAdminRadioSelectWidget from utils.admin import project_admin_site from .professor_base import CourseRelatedAdmin -from apps.course.models.lesson import Lesson, CourseLesson, LessonCompletion +from apps.course.models.lesson import Lesson, CourseLesson, LessonCompletion, CourseChapter class LessonForm(forms.ModelForm): @@ -74,25 +74,43 @@ class LessonAdmin(ModelAdmin): return format_html('{} {}', obj.duration, _("min")) -class CourseLessonAdmin(CourseRelatedAdmin): - form = CourseLessonForm - list_display = ('title', 'course', 'display_duration', 'is_active', 'priority') +class CourseChapterAdmin(CourseRelatedAdmin): + list_display = ('title', 'course', 'priority', 'is_active') list_filter = ( ('course', MultipleRelatedDropdownFilter), 'is_active', ) search_fields = ('title', 'course__title') ordering = ('course', 'priority') - autocomplete_fields = ('course', 'lesson') + autocomplete_fields = ('course',) + + def has_module_permission(self, request): + return False + + + +class CourseLessonAdmin(CourseRelatedAdmin): + form = CourseLessonForm + # Change 'course' to 'chapter' in list_display and ordering + list_display = ('title', 'chapter', 'display_duration', 'is_active', 'priority') + list_filter = ( + ('chapter__course', MultipleRelatedDropdownFilter), + ('chapter', MultipleRelatedDropdownFilter), + 'is_active', + ) + search_fields = ('title', 'chapter__title', 'chapter__course__title') + ordering = ('chapter__course', 'chapter', 'priority') + # Change 'course' to 'chapter' in autocomplete_fields + autocomplete_fields = ('chapter', 'lesson') list_filter_submit = True - # 🔔 REMOVES FROM "ALL APPLICATIONS" MODAL def has_module_permission(self, request): return False fieldsets = ( (None, { - 'fields': ('course', 'lesson', 'title', ('priority', 'is_active')) + # Removed 'course', added 'chapter' + 'fields': ('chapter', 'lesson', 'title', ('priority', 'is_active')) }), ) @@ -102,7 +120,7 @@ class CourseLessonAdmin(CourseRelatedAdmin): def get_queryset(self, request): qs = super().get_queryset(request) - return qs.order_by('course', 'priority') + return qs.order_by('chapter__course', 'chapter', 'priority') class LessonCompletionAdmin(ModelAdmin): @@ -117,10 +135,10 @@ class LessonCompletionAdmin(ModelAdmin): return [] -# Registrations from django.contrib import admin as django_admin django_admin.site.register(Lesson, LessonAdmin) project_admin_site.register(Lesson, LessonAdmin) +project_admin_site.register(CourseChapter, CourseChapterAdmin) project_admin_site.register(CourseLesson, CourseLessonAdmin) project_admin_site.register(LessonCompletion, LessonCompletionAdmin) \ No newline at end of file diff --git a/apps/course/management/commands/migrate_lessons_to_chapters.py b/apps/course/management/commands/migrate_lessons_to_chapters.py new file mode 100644 index 0000000..df7d340 --- /dev/null +++ b/apps/course/management/commands/migrate_lessons_to_chapters.py @@ -0,0 +1,43 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from apps.course.models import Course, CourseChapter + +class Command(BaseCommand): + help = 'Migrates existing CourseLessons to be nested inside default CourseChapters (V2 Architecture).' + + def handle(self, *args, **options): + self.stdout.write(self.style.WARNING("Starting data migration for Course Chapters...")) + + courses = Course.objects.all() + courses_updated = 0 + lessons_migrated = 0 + + # We wrap the whole loop in an atomic transaction. + # If anything crashes halfway through, the database rolls back to its original state! + with transaction.atomic(): + for course in courses: + # Check if course has lessons but no chapters yet + if course.lessons.exists() and not course.chapters.exists(): + self.stdout.write(f"Migrating course: {course.title}") + + # Create a default 'General' chapter + default_chapter = CourseChapter.objects.create( + course=course, + title="General", + priority=1 + ) + + # Attach all existing lessons to this new chapter + for index, course_lesson in enumerate(course.lessons.all().order_by('priority')): + course_lesson.chapter = default_chapter + course_lesson.priority = index + 1 + course_lesson.save() + lessons_migrated += 1 + + courses_updated += 1 + + self.stdout.write( + self.style.SUCCESS( + f"🎉 Successfully migrated {lessons_migrated} lessons across {courses_updated} courses!" + ) + ) \ No newline at end of file diff --git a/apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py b/apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py new file mode 100644 index 0000000..59a1c70 --- /dev/null +++ b/apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.12 on 2026-05-17 15:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0006_alter_course_professor_alter_course_video_file_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CourseChapter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Chapter Title')), + ('priority', models.IntegerField(blank=True, null=True, verbose_name='Priority')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Course Chapter', + 'verbose_name_plural': 'Course Chapters', + 'ordering': ['priority'], + }, + ), + migrations.AlterModelOptions( + name='courselesson', + options={'ordering': ['priority'], 'verbose_name': 'Course Lesson', 'verbose_name_plural': 'Course Lessons'}, + ), + migrations.RemoveIndex( + model_name='courselesson', + name='course_cour_course__4afa4c_idx', + ), + migrations.RemoveIndex( + model_name='courselesson', + name='course_cour_course__192d2c_idx', + ), + migrations.RemoveIndex( + model_name='courselesson', + name='course_cour_course__7c6f06_idx', + ), + migrations.AlterField( + model_name='courselesson', + name='title', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Sub-lesson Title'), + ), + migrations.AddField( + model_name='coursechapter', + name='course', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='course.course', verbose_name='Course'), + ), + migrations.AddField( + model_name='courselesson', + name='chapter', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='course.coursechapter', verbose_name='Chapter'), + ), + migrations.AddIndex( + model_name='courselesson', + index=models.Index(fields=['chapter'], name='course_cour_chapter_09df20_idx'), + ), + ] diff --git a/apps/course/models/lesson.py b/apps/course/models/lesson.py index d8dc00f..1f593fb 100644 --- a/apps/course/models/lesson.py +++ b/apps/course/models/lesson.py @@ -47,14 +47,45 @@ class Lesson(models.Model): models.Index(fields=['created_at']), ] +class CourseChapter(models.Model): + """ + V2 Grouping Layer: Groups lessons together into chapters/sections. + """ + course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='chapters', verbose_name=_('Course')) + title = models.CharField(max_length=255, verbose_name=_('Chapter Title')) + priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority')) + is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.course.title} - {self.title}" + + def save(self, *args, **kwargs): + if self.priority is None: + max_priority = self.course.chapters.aggregate(max_p=models.Max('priority'))['max_p'] + self.priority = (max_priority or 0) + 1 + else: + self._adjust_priorities() + super().save(*args, **kwargs) + + def _adjust_priorities(self): + chapters = self.course.chapters.exclude(pk=self.pk) + chapters.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1) + + class Meta: + verbose_name = _("Course Chapter") + verbose_name_plural = _("Course Chapters") + ordering = ['priority'] class CourseLesson(models.Model): """ - Intermediate model that connects Course with Lesson + Intermediate model that connects a Chapter with a Lesson (the actual context) """ course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name=_('Course')) + chapter = models.ForeignKey(CourseChapter, on_delete=models.CASCADE, related_name='lessons', verbose_name=_('Chapter') , null=True , blank=True) lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='course_lessons', verbose_name=_('Lesson')) - title = models.CharField(max_length=255, verbose_name=_('Course Lesson Title'), null=True, blank=True) + title = models.CharField(max_length=255, verbose_name=_('Sub-lesson Title'), null=True, blank=True) priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority')) is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) @@ -63,7 +94,7 @@ class CourseLesson(models.Model): def __str__(self): title = self.title or self.lesson.title - return f"{self.course.title} - {title}" + return f"{self.chapter.title} - {title}" def is_completed_by(self, student): return self.completions.filter(student=student).exists() @@ -85,35 +116,32 @@ class CourseLesson(models.Model): return self.lesson.duration def save(self, *args, **kwargs): - # If title is not provided, use the lesson's title + # 👇 3. Auto-sync v1 and v2 relations! + if self.chapter and not getattr(self, 'course_id', None): + self.course = self.chapter.course + if not self.title: self.title = self.lesson.title if self.priority is None: - # If priority is not set, set it to the next available priority max_priority = self.course.lessons.aggregate(max_priority=models.Max('priority'))['max_priority'] self.priority = (max_priority or 0) + 1 else: self._adjust_priorities() super().save(*args, **kwargs) - def _adjust_priorities(self): - # Adjust priorities of other lessons in the course - lessons = self.course.lessons.exclude(pk=self.pk) - # Shift priorities for lessons with the same or higher priority + lessons = self.chapter.lessons.exclude(pk=self.pk) lessons.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1) class Meta: verbose_name = _("Course Lesson") verbose_name_plural = _("Course Lessons") - + ordering = ['priority'] indexes = [ - models.Index(fields=['course']), + models.Index(fields=['chapter']), models.Index(fields=['lesson']), models.Index(fields=['priority']), models.Index(fields=['is_active']), - models.Index(fields=['course', 'priority']), - models.Index(fields=['course', 'is_active']), ] diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py index 3a4d689..30df8b6 100644 --- a/apps/course/serializers/course.py +++ b/apps/course/serializers/course.py @@ -57,12 +57,17 @@ class CourseListSerializer(serializers.ModelSerializer): return obj.participants.count() def get_lessons_count(self, obj): - # Use prefetched lessons if available - if hasattr(obj, 'lessons') and obj.lessons.all(): - lessons_count = sum(1 for lesson in obj.lessons.all() if lesson.is_active) + # Use prefetched chapters and lessons if available + if hasattr(obj, 'chapters') and obj.chapters.all(): + lessons_count = 0 + for chapter in obj.chapters.all(): + if chapter.is_active: + lessons_count += sum(1 for lesson in chapter.lessons.all() if lesson.is_active) return max(lessons_count, obj.lessons_count) + # Fallback to direct query - lessons_count = obj.lessons.filter(is_active=True).count() + from apps.course.models.lesson import CourseLesson + lessons_count = CourseLesson.objects.filter(chapter__course=obj, is_active=True, chapter__is_active=True).count() return max(lessons_count, obj.lessons_count) def get_price(self, obj): @@ -165,48 +170,76 @@ class CourseDetailSerializer(serializers.ModelSerializer): if request and request.user.is_authenticated: user = request.user - # Use prefetched lessons if available - if hasattr(obj, 'lessons') and obj.lessons.all(): - lessons = [lesson for lesson in obj.lessons.all() if lesson.is_active] - completed_lessons = [] + # Gather all active lessons across all active chapters + all_active_lessons = [] + if hasattr(obj, 'chapters') and obj.chapters.all(): + for chapter in obj.chapters.all(): + if chapter.is_active: + all_active_lessons.extend([l for l in chapter.lessons.all() if l.is_active]) + + # Sort them by chapter priority first, then lesson priority + all_active_lessons.sort(key=lambda x: (x.chapter.priority, x.priority)) - # Check which lessons are completed using prefetched data - for lesson in lessons: + completed_lessons = [] + for lesson in all_active_lessons: if hasattr(lesson, 'completions') and lesson.completions.all(): if any(completion.student_id == user.id for completion in lesson.completions.all()): completed_lessons.append(lesson) if completed_lessons: - # Find the last completed lesson by priority - last_completed = max(completed_lessons, key=lambda x: x.priority) + # Find the last completed lesson + last_completed = completed_lessons[-1] + # Find next lesson - next_lessons = [l for l in lessons if l.priority > last_completed.priority] - if next_lessons: - return min(next_lessons, key=lambda x: x.priority).id + # We look for lessons that come AFTER the last completed one in our sorted list + last_completed_index = all_active_lessons.index(last_completed) + if last_completed_index + 1 < len(all_active_lessons): + return all_active_lessons[last_completed_index + 1].id # If no completed lessons or no next lesson, return first lesson - if lessons: - return min(lessons, key=lambda x: x.priority).id + if all_active_lessons: + return all_active_lessons[0].id # Fallback to direct queries if not prefetched last_completed_lesson = LessonCompletion.objects.filter( student=user, - course_lesson__course=obj + course_lesson__chapter__course=obj, + course_lesson__is_active=True, + course_lesson__chapter__is_active=True ).order_by('-completed_at').first() + from apps.course.models.lesson import CourseLesson + if last_completed_lesson: + cl = last_completed_lesson.course_lesson + # Try to find next lesson in same chapter next_lesson = CourseLesson.objects.filter( - course=obj, - priority__gt=last_completed_lesson.course_lesson.priority, + chapter=cl.chapter, + priority__gt=cl.priority, is_active=True ).order_by('priority').first() + + # If no more lessons in this chapter, find first lesson in next chapter if not next_lesson: next_lesson = CourseLesson.objects.filter( - course=obj, + chapter__course=obj, + chapter__priority__gt=cl.chapter.priority, + chapter__is_active=True, is_active=True - ).order_by('priority').first() + ).order_by('chapter__priority', 'priority').first() + if next_lesson: return next_lesson.id + else: + # No completions at all, just get the very first lesson + first_lesson = CourseLesson.objects.filter( + chapter__course=obj, + chapter__is_active=True, + is_active=True + ).order_by('chapter__priority', 'priority').first() + if first_lesson: + return first_lesson.id + return None @@ -228,12 +261,15 @@ class CourseDetailSerializer(serializers.ModelSerializer): return False def get_lessons_count(self, obj): - # Use prefetched lessons if available - if hasattr(obj, 'lessons') and obj.lessons.all(): - lessons_count = sum(1 for lesson in obj.lessons.all() if lesson.is_active) + if hasattr(obj, 'chapters') and obj.chapters.all(): + lessons_count = 0 + for chapter in obj.chapters.all(): + if chapter.is_active: + lessons_count += sum(1 for lesson in chapter.lessons.all() if lesson.is_active) return max(lessons_count, obj.lessons_count) - # Fallback to direct query - lessons_count = obj.lessons.filter(is_active=True).count() + + from apps.course.models.lesson import CourseLesson + lessons_count = CourseLesson.objects.filter(chapter__course=obj, is_active=True, chapter__is_active=True).count() return max(lessons_count, obj.lessons_count) @@ -277,20 +313,21 @@ class CourseDetailSerializer(serializers.ModelSerializer): return request.user if request and request.user.is_authenticated else None def _get_completed_lessons_count(self, student, course): - """Helper method to count completed lessons for the student in the given course.""" - # Use prefetched completions if available - if hasattr(course, 'lessons') and course.lessons.all(): + if hasattr(course, 'chapters') and course.chapters.all(): completed_count = 0 - for lesson in course.lessons.all(): - if hasattr(lesson, 'completions') and lesson.completions.all(): - if any(completion.student_id == student.id for completion in lesson.completions.all()): - completed_count += 1 + for chapter in course.chapters.all(): + if chapter.is_active: + for lesson in chapter.lessons.all(): + if lesson.is_active and hasattr(lesson, 'completions') and lesson.completions.all(): + if any(completion.student_id == student.id for completion in lesson.completions.all()): + completed_count += 1 return completed_count - # Fallback to direct query if not prefetched return LessonCompletion.objects.filter( student=student, - course_lesson__course=course + course_lesson__chapter__course=course, + course_lesson__is_active=True, + course_lesson__chapter__is_active=True ).count() diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py index e828e51..58cbc5e 100644 --- a/apps/course/serializers/lesson.py +++ b/apps/course/serializers/lesson.py @@ -83,3 +83,24 @@ class CourseLessonSerializer(serializers.ModelSerializer): if quizzes: return QuizListSerializer(quizzes, many=True, context=self.context).data return None + + +from apps.course.models import CourseChapter + +# 👇 ADD V2 SERIALIZERS + +class CourseChapterSerializer(serializers.ModelSerializer): + """ + V2 API: Returns a Chapter and a nested list of all its active lessons + """ + lessons = serializers.SerializerMethodField() + + class Meta: + model = CourseChapter + fields = ['id', 'title', 'priority', 'is_active', 'lessons'] + + def get_lessons(self, obj): + # Grab all active lessons tied to this specific chapter + chapter_lessons = obj.lessons.filter(is_active=True).order_by('priority') + # Re-use the V1 CourseLessonSerializer to serialize the nested items + return CourseLessonSerializer(chapter_lessons, many=True, context=self.context).data \ No newline at end of file diff --git a/apps/course/urls.py b/apps/course/urls.py index 8028538..a7bd540 100644 --- a/apps/course/urls.py +++ b/apps/course/urls.py @@ -27,6 +27,7 @@ urlpatterns = [ re_path(r'(?P[\w-]+)/attachments/$', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), re_path(r'(?P[\w-]+)/glossaries/$', views.GlossaryListAPIView.as_view(), name='course-glossary-list'), re_path(r'(?P[\w-]+)/lessons/$', views.LessonListView.as_view(), name='course-lesson-list'), + re_path(r'v2/(?P[\w-]+)/lessons/$', views.LessonListV2APIView.as_view(), name='course-lesson-list-v2'), path('lesson//', views.LessonDetailView.as_view(), name='lesson-detail'), re_path(r'(?P[\w-]+)/participants/$', views.CourseParticipantsView.as_view(), name='course-participant-list'), diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 5b90d01..f48db03 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -177,8 +177,8 @@ class CourseDetailAPIView(RetrieveAPIView): 'category', 'professor' ).prefetch_related( - 'lessons__lesson', - 'lessons__completions', + 'chapters__lessons__lesson', + 'chapters__lessons__completions', 'attachments__attachment', 'glossaries__glossary', 'participants__student', @@ -227,8 +227,8 @@ class MyCourseListAPIView(ListAPIView): 'category', 'professor' ).prefetch_related( - 'lessons__lesson', - 'lessons__completions', + 'chapters__lessons__lesson', + 'chapters__lessons__completions', 'participants__student' ).exclude(status=Course.StatusChoices.INACTIVE) @@ -243,20 +243,20 @@ class MyCourseListAPIView(ListAPIView): if completed_only == True: # نمایش دوره‌هایی که همه درس‌هایشان توسط کاربر تکمیل شده‌اند qs = qs.annotate( - total_lessons=Count('lessons', distinct=True), + # Count distinct lessons across all chapters + total_lessons=Count('chapters__lessons', distinct=True), completed_lessons=Count( - 'lessons__completions', - filter=Q(lessons__completions__student=user), + 'chapters__lessons__completions', + filter=Q(chapters__lessons__completions__student=user), distinct=True ) ).filter(total_lessons=F('completed_lessons')) elif completed_only == False: - # نمایش دوره‌هایی که همه درس‌هایشان تکمیل نشده‌اند qs = qs.annotate( - total_lessons=Count('lessons', distinct=True), + total_lessons=Count('chapters__lessons', distinct=True), completed_lessons=Count( - 'lessons__completions', - filter=Q(lessons__completions__student=user), + 'chapters__lessons__completions', + filter=Q(chapters__lessons__completions__student=user), distinct=True ) ).filter(total_lessons__gt=F('completed_lessons')) diff --git a/apps/course/views/lesson.py b/apps/course/views/lesson.py index 520586d..60479e1 100644 --- a/apps/course/views/lesson.py +++ b/apps/course/views/lesson.py @@ -7,9 +7,10 @@ from drf_yasg import openapi from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.response import Response - +from apps.course.models import CourseChapter from apps.course.serializers import ( - CourseLessonSerializer + CourseLessonSerializer, + CourseChapterSerializer ) from apps.course.models import Course, CourseLesson, LessonCompletion from apps.course.doc import * @@ -40,15 +41,16 @@ class LessonListView(ListAPIView): course = get_object_or_404(Course, slug=course_slug) return CourseLesson.objects.select_related( - 'course', + 'chapter', # 👇 Route through chapter 'lesson' ).prefetch_related( 'completions', 'quizzes' ).filter( - course=course, - is_active=True - ).order_by('priority', 'id') + chapter__course=course, # 👇 Query via chapter + is_active=True, + chapter__is_active=True + ).order_by('chapter__priority', 'priority') # 👇 Sort by chapter first @@ -77,37 +79,46 @@ class LessonDetailView(RetrieveAPIView): } ) def get(self, request, *args, **kwargs): - """ - Optimized lesson detail view with select_related for relationships - """ lesson_id = self.kwargs.get('id') course_lesson = get_object_or_404( - CourseLesson.objects.select_related('course', 'lesson'), + CourseLesson.objects.select_related('chapter__course', 'lesson'), id=lesson_id, - is_active=True + is_active=True, + chapter__is_active=True ) - course = course_lesson.course + course = course_lesson.chapter.course + + # Get all lessons in order lessons = CourseLesson.objects.select_related( - 'lesson' + 'lesson', 'chapter' ).filter( - course=course, - is_active=True - ).order_by('priority') + chapter__course=course, + is_active=True, + chapter__is_active=True + ).order_by('chapter__priority', 'priority') total_lessons = lessons.count() - current_lesson_number = list(lessons.values_list('id', flat=True)).index(course_lesson.id) + 1 - next_lesson = lessons.filter(priority__gt=course_lesson.priority).order_by('priority').first() - next_lesson_id = next_lesson.id if next_lesson else None - previous_lesson = lessons.filter(priority__lt=course_lesson.priority).order_by('-priority').first() - previous_lesson_id = previous_lesson.id if previous_lesson else None + # Convert to list to find index + lesson_ids = list(lessons.values_list('id', flat=True)) + try: + current_index = lesson_ids.index(course_lesson.id) + current_lesson_number = current_index + 1 + + next_lesson_id = lesson_ids[current_index + 1] if current_index + 1 < len(lesson_ids) else None + previous_lesson_id = lesson_ids[current_index - 1] if current_index > 0 else None + + except ValueError: + current_lesson_number = 1 + next_lesson_id = None + previous_lesson_id = None lesson_data = self.get_serializer(course_lesson).data lesson_data['total_lessons'] = total_lessons lesson_data['current_lesson_number'] = current_lesson_number lesson_data['next_lesson_id'] = next_lesson_id lesson_data['previous_lesson_id'] = previous_lesson_id - lesson_data['can_go_next'] = next_lesson is not None + lesson_data['can_go_next'] = next_lesson_id is not None return Response(lesson_data) @@ -166,3 +177,35 @@ class LessonCompletionToggleAPIView(GenericAPIView): {'message': 'Lesson completed successfully.', 'is_completed': True}, status=status.HTTP_201_CREATED ) + +from django.db.models import Prefetch +class LessonListV2APIView(ListAPIView): + """ + V2 API: Returns lessons grouped by Chapters + """ + serializer_class = CourseChapterSerializer + pagination_class = StandardResultsSetPagination + permission_classes = [AllowAny] + authentication_classes = [TokenAuthentication] + + @swagger_auto_schema( + operation_description="V2: Get a nested list of chapters and their lessons", + tags=['Imam-Javad - Course (V2)'], + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + course_slug = self.kwargs.get('slug') + course = get_object_or_404(Course, slug=course_slug) + + # We query the Chapters, and prefetch the lessons to avoid N+1 query problems + return CourseChapter.objects.prefetch_related( + Prefetch( + 'lessons', + queryset=CourseLesson.objects.select_related('lesson').filter(is_active=True).order_by('priority') + ) + ).filter( + course=course, + is_active=True + ).order_by('priority', 'id') \ No newline at end of file