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.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 = [

36
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('<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 = (
('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)

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

109
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()

21
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

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

87
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')
Loading…
Cancel
Save