Browse Source
feat(course): implement v2 3-tier architecture (Course -> Chapter -> Lesson)
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
11 changed files with 379 additions and 423 deletions
-
328README_WEBHOOK.md
-
36apps/course/admin/course.py
-
36apps/course/admin/lesson.py
-
43apps/course/management/commands/migrate_lessons_to_chapters.py
-
65apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py
-
54apps/course/models/lesson.py
-
105apps/course/serializers/course.py
-
21apps/course/serializers/lesson.py
-
1apps/course/urls.py
-
22apps/course/views/course.py
-
87apps/course/views/lesson.py
@ -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) |
|||
@ -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!" |
|||
) |
|||
) |
|||
@ -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'), |
|||
), |
|||
] |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue