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
-
109apps/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