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