Browse Source

feat(course): implement v2 3-tier architecture (Course -> Chapter -> Lesson)

This commit refactors the LMS architecture from a flat 2-tier structure to a granular 3-tier structure to support better course organization, while ensuring zero downtime or breaking changes for existing frontend clients.

Introduced `CourseChapter` model as the new grouping layer

2 endpoints (`/v2/.../lessons/`): Introduced new `LessonListV2APIView` and `CourseChapterSerializer` to return the new nested JSON schema (Chapters -> Lessons) for the upcoming accordion UI.

Created robust `migrate_lessons_to_chapters` management command.

Registered `CourseChapter` and added `CourseChapterInline` to `CourseAdmin` for seamless content management.
master
Mohsen Taba 2 months ago
parent
commit
bb0280bccc
  1. 328
      README_WEBHOOK.md
  2. 36
      apps/course/admin/course.py
  3. 36
      apps/course/admin/lesson.py
  4. 43
      apps/course/management/commands/migrate_lessons_to_chapters.py
  5. 65
      apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py
  6. 54
      apps/course/models/lesson.py
  7. 109
      apps/course/serializers/course.py
  8. 21
      apps/course/serializers/lesson.py
  9. 1
      apps/course/urls.py
  10. 22
      apps/course/views/course.py
  11. 87
      apps/course/views/lesson.py

328
README_WEBHOOK.md

@ -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)

36
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.admin import project_admin_site, dovoodi_admin_site
from utils.json_editor_field import JsonEditorWidget from utils.json_editor_field import JsonEditorWidget
from apps.course.models import Course, Glossary, Attachment, CourseCategory, Participant, CourseGlossary, CourseAttachment 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.account.models import StudentUser, User
from apps.quiz.models import Quiz from apps.quiz.models import Quiz
from utils.schema import get_weekly_timing_schema, get_course_feature_schema from utils.schema import get_weekly_timing_schema, get_course_feature_schema
@ -172,16 +172,44 @@ class CourseGlossaryInline(TabularInline):
show_change_link = True show_change_link = True
# Removed autocomplete_fields to restore Unfold UI # 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): class CourseLessonInline(TabularInline):
model = CourseLesson model = CourseLesson
form = MinWidthInlineForm 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 = True
tab_id = "lessons_tab" tab_id = "lessons_tab"
show_change_link = True show_change_link = True
ordering_field = "priority" 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 # Removed autocomplete_fields to restore Unfold UI
@ -226,7 +254,7 @@ class AddStudentForm(forms.Form):
class CourseAdmin(DirectCourseAdmin): class CourseAdmin(DirectCourseAdmin):
form = CourseForm 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_display = ('display_header', 'category', 'display_professor', 'status', 'display_price', 'is_online')
list_filter = [ list_filter = [

36
apps/course/admin/lesson.py

@ -14,7 +14,7 @@ from unfold.widgets import UnfoldAdminRadioSelectWidget
from utils.admin import project_admin_site from utils.admin import project_admin_site
from .professor_base import CourseRelatedAdmin 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): class LessonForm(forms.ModelForm):
@ -74,25 +74,43 @@ class LessonAdmin(ModelAdmin):
return format_html('<span class="badge badge-info">{} {}</span>', obj.duration, _("min")) return format_html('<span class="badge badge-info">{} {}</span>', 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 = ( list_filter = (
('course', MultipleRelatedDropdownFilter), ('course', MultipleRelatedDropdownFilter),
'is_active', 'is_active',
) )
search_fields = ('title', 'course__title') search_fields = ('title', 'course__title')
ordering = ('course', 'priority') 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 list_filter_submit = True
# 🔔 REMOVES FROM "ALL APPLICATIONS" MODAL
def has_module_permission(self, request): def has_module_permission(self, request):
return False return False
fieldsets = ( fieldsets = (
(None, { (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): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
return qs.order_by('course', 'priority')
return qs.order_by('chapter__course', 'chapter', 'priority')
class LessonCompletionAdmin(ModelAdmin): class LessonCompletionAdmin(ModelAdmin):
@ -117,10 +135,10 @@ class LessonCompletionAdmin(ModelAdmin):
return [] return []
# Registrations
from django.contrib import admin as django_admin from django.contrib import admin as django_admin
django_admin.site.register(Lesson, LessonAdmin) django_admin.site.register(Lesson, LessonAdmin)
project_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(CourseLesson, CourseLessonAdmin)
project_admin_site.register(LessonCompletion, LessonCompletionAdmin) project_admin_site.register(LessonCompletion, LessonCompletionAdmin)

43
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!"
)
)

65
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'),
),
]

54
apps/course/models/lesson.py

@ -47,14 +47,45 @@ class Lesson(models.Model):
models.Index(fields=['created_at']), 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): 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')) 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')) 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')) priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority'))
is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) is_active = models.BooleanField(default=True, verbose_name=_('Is Active'))
@ -63,7 +94,7 @@ class CourseLesson(models.Model):
def __str__(self): def __str__(self):
title = self.title or self.lesson.title 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): def is_completed_by(self, student):
return self.completions.filter(student=student).exists() return self.completions.filter(student=student).exists()
@ -85,35 +116,32 @@ class CourseLesson(models.Model):
return self.lesson.duration return self.lesson.duration
def save(self, *args, **kwargs): 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: if not self.title:
self.title = self.lesson.title self.title = self.lesson.title
if self.priority is None: 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'] max_priority = self.course.lessons.aggregate(max_priority=models.Max('priority'))['max_priority']
self.priority = (max_priority or 0) + 1 self.priority = (max_priority or 0) + 1
else: else:
self._adjust_priorities() self._adjust_priorities()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def _adjust_priorities(self): 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) lessons.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1)
class Meta: class Meta:
verbose_name = _("Course Lesson") verbose_name = _("Course Lesson")
verbose_name_plural = _("Course Lessons") verbose_name_plural = _("Course Lessons")
ordering = ['priority']
indexes = [ indexes = [
models.Index(fields=['course']),
models.Index(fields=['chapter']),
models.Index(fields=['lesson']), models.Index(fields=['lesson']),
models.Index(fields=['priority']), models.Index(fields=['priority']),
models.Index(fields=['is_active']), models.Index(fields=['is_active']),
models.Index(fields=['course', 'priority']),
models.Index(fields=['course', 'is_active']),
] ]

109
apps/course/serializers/course.py

@ -57,12 +57,17 @@ class CourseListSerializer(serializers.ModelSerializer):
return obj.participants.count() return obj.participants.count()
def get_lessons_count(self, obj): 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) return max(lessons_count, obj.lessons_count)
# Fallback to direct query # 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) return max(lessons_count, obj.lessons_count)
def get_price(self, obj): def get_price(self, obj):
@ -165,48 +170,76 @@ class CourseDetailSerializer(serializers.ModelSerializer):
if request and request.user.is_authenticated: if request and request.user.is_authenticated:
user = request.user 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 hasattr(lesson, 'completions') and lesson.completions.all():
if any(completion.student_id == user.id for completion in lesson.completions.all()): if any(completion.student_id == user.id for completion in lesson.completions.all()):
completed_lessons.append(lesson) completed_lessons.append(lesson)
if completed_lessons: 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 # 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 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 # Fallback to direct queries if not prefetched
last_completed_lesson = LessonCompletion.objects.filter( last_completed_lesson = LessonCompletion.objects.filter(
student=user, 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() ).order_by('-completed_at').first()
from apps.course.models.lesson import CourseLesson
if last_completed_lesson: if last_completed_lesson:
cl = last_completed_lesson.course_lesson
# Try to find next lesson in same chapter
next_lesson = CourseLesson.objects.filter( 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 is_active=True
).order_by('priority').first() ).order_by('priority').first()
# If no more lessons in this chapter, find first lesson in next chapter
if not next_lesson: if not next_lesson:
next_lesson = CourseLesson.objects.filter( next_lesson = CourseLesson.objects.filter(
course=obj,
chapter__course=obj,
chapter__priority__gt=cl.chapter.priority,
chapter__is_active=True,
is_active=True is_active=True
).order_by('priority').first()
).order_by('chapter__priority', 'priority').first()
if next_lesson: if next_lesson:
return next_lesson.id 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 return None
@ -228,12 +261,15 @@ class CourseDetailSerializer(serializers.ModelSerializer):
return False return False
def get_lessons_count(self, obj): 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) 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) 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 return request.user if request and request.user.is_authenticated else None
def _get_completed_lessons_count(self, student, course): 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 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 return completed_count
# Fallback to direct query if not prefetched
return LessonCompletion.objects.filter( return LessonCompletion.objects.filter(
student=student, student=student,
course_lesson__course=course
course_lesson__chapter__course=course,
course_lesson__is_active=True,
course_lesson__chapter__is_active=True
).count() ).count()

21
apps/course/serializers/lesson.py

@ -83,3 +83,24 @@ class CourseLessonSerializer(serializers.ModelSerializer):
if quizzes: if quizzes:
return QuizListSerializer(quizzes, many=True, context=self.context).data return QuizListSerializer(quizzes, many=True, context=self.context).data
return None 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

1
apps/course/urls.py

@ -27,6 +27,7 @@ urlpatterns = [
re_path(r'(?P<slug>[\w-]+)/attachments/$', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), re_path(r'(?P<slug>[\w-]+)/attachments/$', views.AttachmentListAPIView.as_view(), name='course-attachment-list'),
re_path(r'(?P<slug>[\w-]+)/glossaries/$', views.GlossaryListAPIView.as_view(), name='course-glossary-list'), re_path(r'(?P<slug>[\w-]+)/glossaries/$', views.GlossaryListAPIView.as_view(), name='course-glossary-list'),
re_path(r'(?P<slug>[\w-]+)/lessons/$', views.LessonListView.as_view(), name='course-lesson-list'), re_path(r'(?P<slug>[\w-]+)/lessons/$', views.LessonListView.as_view(), name='course-lesson-list'),
re_path(r'v2/(?P<slug>[\w-]+)/lessons/$', views.LessonListV2APIView.as_view(), name='course-lesson-list-v2'),
path('lesson/<int:id>/', views.LessonDetailView.as_view(), name='lesson-detail'), path('lesson/<int:id>/', views.LessonDetailView.as_view(), name='lesson-detail'),
re_path(r'(?P<slug>[\w-]+)/participants/$', views.CourseParticipantsView.as_view(), name='course-participant-list'), re_path(r'(?P<slug>[\w-]+)/participants/$', views.CourseParticipantsView.as_view(), name='course-participant-list'),

22
apps/course/views/course.py

@ -177,8 +177,8 @@ class CourseDetailAPIView(RetrieveAPIView):
'category', 'category',
'professor' 'professor'
).prefetch_related( ).prefetch_related(
'lessons__lesson',
'lessons__completions',
'chapters__lessons__lesson',
'chapters__lessons__completions',
'attachments__attachment', 'attachments__attachment',
'glossaries__glossary', 'glossaries__glossary',
'participants__student', 'participants__student',
@ -227,8 +227,8 @@ class MyCourseListAPIView(ListAPIView):
'category', 'category',
'professor' 'professor'
).prefetch_related( ).prefetch_related(
'lessons__lesson',
'lessons__completions',
'chapters__lessons__lesson',
'chapters__lessons__completions',
'participants__student' 'participants__student'
).exclude(status=Course.StatusChoices.INACTIVE) ).exclude(status=Course.StatusChoices.INACTIVE)
@ -243,20 +243,20 @@ class MyCourseListAPIView(ListAPIView):
if completed_only == True: if completed_only == True:
# نمایش دوره‌هایی که همه درس‌هایشان توسط کاربر تکمیل شده‌اند # نمایش دوره‌هایی که همه درس‌هایشان توسط کاربر تکمیل شده‌اند
qs = qs.annotate( 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( completed_lessons=Count(
'lessons__completions',
filter=Q(lessons__completions__student=user),
'chapters__lessons__completions',
filter=Q(chapters__lessons__completions__student=user),
distinct=True distinct=True
) )
).filter(total_lessons=F('completed_lessons')) ).filter(total_lessons=F('completed_lessons'))
elif completed_only == False: elif completed_only == False:
# نمایش دوره‌هایی که همه درس‌هایشان تکمیل نشده‌اند
qs = qs.annotate( qs = qs.annotate(
total_lessons=Count('lessons', distinct=True),
total_lessons=Count('chapters__lessons', distinct=True),
completed_lessons=Count( completed_lessons=Count(
'lessons__completions',
filter=Q(lessons__completions__student=user),
'chapters__lessons__completions',
filter=Q(chapters__lessons__completions__student=user),
distinct=True distinct=True
) )
).filter(total_lessons__gt=F('completed_lessons')) ).filter(total_lessons__gt=F('completed_lessons'))

87
apps/course/views/lesson.py

@ -7,9 +7,10 @@ from drf_yasg import openapi
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from apps.course.models import CourseChapter
from apps.course.serializers import ( from apps.course.serializers import (
CourseLessonSerializer
CourseLessonSerializer,
CourseChapterSerializer
) )
from apps.course.models import Course, CourseLesson, LessonCompletion from apps.course.models import Course, CourseLesson, LessonCompletion
from apps.course.doc import * from apps.course.doc import *
@ -40,15 +41,16 @@ class LessonListView(ListAPIView):
course = get_object_or_404(Course, slug=course_slug) course = get_object_or_404(Course, slug=course_slug)
return CourseLesson.objects.select_related( return CourseLesson.objects.select_related(
'course',
'chapter', # 👇 Route through chapter
'lesson' 'lesson'
).prefetch_related( ).prefetch_related(
'completions', 'completions',
'quizzes' 'quizzes'
).filter( ).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): def get(self, request, *args, **kwargs):
"""
Optimized lesson detail view with select_related for relationships
"""
lesson_id = self.kwargs.get('id') lesson_id = self.kwargs.get('id')
course_lesson = get_object_or_404( course_lesson = get_object_or_404(
CourseLesson.objects.select_related('course', 'lesson'),
CourseLesson.objects.select_related('chapter__course', 'lesson'),
id=lesson_id, 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( lessons = CourseLesson.objects.select_related(
'lesson'
'lesson', 'chapter'
).filter( ).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() 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 = self.get_serializer(course_lesson).data
lesson_data['total_lessons'] = total_lessons lesson_data['total_lessons'] = total_lessons
lesson_data['current_lesson_number'] = current_lesson_number lesson_data['current_lesson_number'] = current_lesson_number
lesson_data['next_lesson_id'] = next_lesson_id lesson_data['next_lesson_id'] = next_lesson_id
lesson_data['previous_lesson_id'] = previous_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) return Response(lesson_data)
@ -166,3 +177,35 @@ class LessonCompletionToggleAPIView(GenericAPIView):
{'message': 'Lesson completed successfully.', 'is_completed': True}, {'message': 'Lesson completed successfully.', 'is_completed': True},
status=status.HTTP_201_CREATED 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')
Loading…
Cancel
Save