Browse Source

feat(api): add live session room APIs and metadata

- add CourseLiveSession.room_id field with unique index
- introduce LiveSessionRoomCreateSerializer and LiveSessionTokenSerializer
- add URLs for room creation and token:
  - <slug>/online/room/create
  - online/room/token
- enhance token validation metadata with:
  - can_create_live_session, can_join_live_session flags
  - live session context (active_room_id, timings, details)
- improve logging and token error handling in validation flow
- add PlugNMeet configuration settings (URL, API key/secret, timeout)

This introduces the endpoints and data structures needed to create and
join live sessions, and surfaces richer metadata for frontend usage. A
database migration is required for the new model field and index.
master
mortezaei 7 months ago
parent
commit
1b0f813855
  1. 22
      apps/course/migrations/0009_auto_20251014_0051.py
  2. 9
      apps/course/models/live_session.py
  3. 22
      apps/course/serializers/online.py
  4. 3
      apps/course/services/__init__.py
  5. 74
      apps/course/services/plugnmeet.py
  6. 182
      apps/course/tests/test_live_session_api.py
  7. 312
      apps/course/token-join-guide.md
  8. 2
      apps/course/urls.py
  9. 3
      apps/course/views/__init__.py
  10. 102
      apps/course/views/course.py
  11. 231
      apps/course/views/live_session.py
  12. 4
      config/settings/base.py
  13. 333
      docs/live-session-api.md
  14. 361
      docs/live-session-logs.md

22
apps/course/migrations/0009_auto_20251014_0051.py

@ -0,0 +1,22 @@
# Generated by Django 3.2.4 on 2025-10-14 00:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0008_auto_20251013_1724'),
]
operations = [
migrations.AddField(
model_name='courselivesession',
name='room_id',
field=models.CharField(blank=True, help_text='Identifier of the PlugNMeet room.', max_length=255, null=True, unique=True, verbose_name='Room ID'),
),
migrations.AddIndex(
model_name='courselivesession',
index=models.Index(fields=['room_id'], name='course_cour_room_id_ed0222_idx'),
),
]

9
apps/course/models/live_session.py

@ -13,6 +13,14 @@ class CourseLiveSession(models.Model):
verbose_name=_("Course"),
help_text=_("Course that this live session belongs to."),
)
room_id = models.CharField(
max_length=255,
verbose_name=_("Room ID"),
help_text=_("Identifier of the PlugNMeet room."),
unique=True,
null=True,
blank=True,
)
subject = models.CharField(
max_length=255,
verbose_name=_("Subject"),
@ -41,6 +49,7 @@ class CourseLiveSession(models.Model):
indexes = [
models.Index(fields=["course", "started_at"]),
models.Index(fields=["course", "created_at"]),
models.Index(fields=["room_id"]),
]

22
apps/course/serializers/online.py

@ -19,3 +19,25 @@ class OnlineClassTokenVerifySerializer(serializers.Serializer):
if not value:
raise serializers.ValidationError("Token is required.")
return value
class LiveSessionRoomCreateSerializer(serializers.Serializer):
room_id = serializers.CharField(required=False, max_length=255, allow_blank=True)
subject = serializers.CharField(required=False, max_length=255, allow_blank=True)
metadata = serializers.DictField(required=False)
def validate_room_id(self, value: str) -> str:
return value.strip()
def validate_subject(self, value: str) -> str:
return value.strip()
class LiveSessionTokenSerializer(serializers.Serializer):
course_slug = serializers.CharField(max_length=255)
def validate_course_slug(self, value: str) -> str:
value = value.strip()
if not value:
raise serializers.ValidationError("course_slug is required.")
return value

3
apps/course/services/__init__.py

@ -0,0 +1,3 @@
from .plugnmeet import PlugNMeetClient, PlugNMeetError
__all__ = ['PlugNMeetClient', 'PlugNMeetError']

74
apps/course/services/plugnmeet.py

@ -0,0 +1,74 @@
import json
import hmac
import hashlib
from typing import Any, Dict, Optional
from urllib.parse import urljoin
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class PlugNMeetError(Exception):
def __init__(self, message: str, *, status_code: Optional[int] = None, response_data: Optional[Dict[str, Any]] = None):
super().__init__(message)
self.status_code = status_code
self.response_data = response_data or {}
class PlugNMeetClient:
def __init__(self, *, base_url: Optional[str] = None, api_key: Optional[str] = None, api_secret: Optional[str] = None, timeout: Optional[float] = None):
self.base_url = (base_url or getattr(settings, "PLUGNMEET_SERVER_URL", "")).rstrip("/")
self.api_key = api_key or getattr(settings, "PLUGNMEET_API_KEY", "")
self.api_secret = api_secret or getattr(settings, "PLUGNMEET_API_SECRET", "")
self.timeout = timeout or getattr(settings, "PLUGNMEET_TIMEOUT", 10.0)
if not self.base_url or not self.api_key or not self.api_secret:
raise ImproperlyConfigured("PlugNMeet integration settings are incomplete.")
def create_room(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return self._post("/auth/room/create", payload)
def get_join_token(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return self._post("/auth/room/getJoinToken", payload)
def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
headers = {
"Content-Type": "application/json",
"API-KEY": self.api_key,
"HASH-SIGNATURE": self._build_signature(body),
}
try:
response = requests.post(url, data=body.encode("utf-8"), headers=headers, timeout=self.timeout)
except requests.RequestException as exc:
raise PlugNMeetError("Failed to reach PlugNMeet server.") from exc
if response.status_code >= 400:
response_data = self._safe_json(response)
raise PlugNMeetError(
"PlugNMeet server returned an error.",
status_code=response.status_code,
response_data=response_data,
)
data = self._safe_json(response)
if data is None:
raise PlugNMeetError("PlugNMeet server returned an invalid response format.")
return data
def _build_signature(self, body: str) -> str:
digest = hmac.new(self.api_secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256)
return digest.hexdigest()
@staticmethod
def _safe_json(response: requests.Response) -> Optional[Dict[str, Any]]:
try:
return response.json()
except ValueError:
return None
__all__ = ["PlugNMeetClient", "PlugNMeetError"]

182
apps/course/tests/test_live_session_api.py

@ -0,0 +1,182 @@
import tempfile
from unittest import mock
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from apps.account.models import ProfessorUser, StudentUser
from apps.course.models import (
Course,
CourseCategory,
CourseLiveSession,
Participant,
)
@override_settings(
PLUGNMEET_SERVER_URL='https://meet.example.com',
PLUGNMEET_API_KEY='test-key',
PLUGNMEET_API_SECRET='test-secret',
MEDIA_ROOT=tempfile.gettempdir(),
)
class CourseLiveSessionAPITests(APITestCase):
def setUp(self):
self.professor = ProfessorUser.objects.create(
email='prof@example.com',
fullname='Professor Sample',
experience_years=5,
)
self.student = StudentUser.objects.create(
email='student@example.com',
fullname='Student Sample',
)
self.category = CourseCategory.objects.create(name='Category', slug='category')
thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
self.course = Course.objects.create(
title='Sample Course',
slug='sample-course',
category=self.category,
professor=self.professor,
thumbnail=thumbnail,
video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
video_link='https://example.com/video',
is_online=True,
online_link='https://example.com/live',
level=Course.LevelChoices.BEGINNER,
duration=10,
lessons_count=2,
description='Description',
short_description='Short',
status=Course.StatusChoices.ONGOING,
is_free=True,
)
professor_avatar = SimpleUploadedFile('prof-avatar.jpg', b'avatar', content_type='image/jpeg')
self.professor.avatar = professor_avatar
self.professor.save(update_fields=['avatar'])
student_avatar = SimpleUploadedFile('student-avatar.jpg', b'avatar', content_type='image/jpeg')
self.student.avatar = student_avatar
self.student.save(update_fields=['avatar'])
@mock.patch('apps.course.views.live_session.PlugNMeetClient')
def test_professor_can_create_room(self, mock_client_cls):
mock_client = mock_client_cls.return_value
mock_client.create_room.return_value = {'status': 'success'}
self.client.force_authenticate(user=self.professor)
url = reverse('course-live-session-room-create', kwargs={'slug': self.course.slug})
payload = {
'room_id': 'custom-room-id',
'subject': 'Algebra Session',
}
response = self.client.post(url, payload, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
mock_client.create_room.assert_called_once()
self.assertTrue(
CourseLiveSession.objects.filter(course=self.course, room_id='custom-room-id').exists()
)
@mock.patch('apps.course.views.live_session.PlugNMeetClient')
def test_professor_receives_admin_token(self, mock_client_cls):
mock_client = mock_client_cls.return_value
mock_client.get_join_token.return_value = {'token': 'abc123'}
session = CourseLiveSession.objects.create(
course=self.course,
subject='Session',
started_at=timezone.now(),
room_id='room-123',
)
self.client.force_authenticate(user=self.professor)
url = reverse('course-live-session-token')
response = self.client.post(url, {'room_id': session.room_id}, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
args, _ = mock_client.get_join_token.call_args
payload = args[0]
self.assertTrue(payload['user_info']['is_admin'])
profile_pic = payload['user_info']['user_metadata'].get('profilePic')
self.assertEqual(profile_pic, f"http://testserver{self.professor.avatar.url}")
self.assertEqual(response.data['token'], 'abc123')
@mock.patch('apps.course.views.live_session.PlugNMeetClient')
def test_student_participant_receives_limited_token(self, mock_client_cls):
mock_client = mock_client_cls.return_value
mock_client.get_join_token.return_value = {'token': 'student-token'}
session = CourseLiveSession.objects.create(
course=self.course,
subject='Session',
started_at=timezone.now(),
room_id='room-456',
)
Participant.objects.create(course=self.course, student=self.student)
self.client.force_authenticate(user=self.student)
url = reverse('course-live-session-token')
response = self.client.post(url, {'room_id': session.room_id}, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
args, _ = mock_client.get_join_token.call_args
payload = args[0]
self.assertFalse(payload['user_info']['is_admin'])
metadata = payload['user_info']['user_metadata']
self.assertIn('lock_microphone', metadata['lock_settings'])
self.assertEqual(metadata.get('profilePic'), f"http://testserver{self.student.avatar.url}")
self.assertEqual(response.data['token'], 'student-token')
def test_student_without_access_cannot_get_token(self):
session = CourseLiveSession.objects.create(
course=self.course,
subject='Session',
started_at=timezone.now(),
room_id='room-789',
)
self.client.force_authenticate(user=self.student)
url = reverse('course-live-session-token')
response = self.client.post(url, {'room_id': session.room_id}, format='json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_validate_metadata_includes_active_room_for_student(self):
session = CourseLiveSession.objects.create(
course=self.course,
subject='Session Live',
started_at=timezone.now(),
room_id='room-live-1',
)
Participant.objects.create(course=self.course, student=self.student)
self.client.force_authenticate(user=self.student)
url = reverse('course-online-validate', kwargs={'slug': self.course.slug})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
metadata = response.data['metadata']
self.assertTrue(metadata['is_online'])
self.assertEqual(metadata['active_room_id'], session.room_id)
self.assertTrue(metadata['can_join_live_session'])
self.assertEqual(metadata['live_session']['room_id'], session.room_id)
self.assertIsNotNone(metadata['live_session']['started_at'])
def test_validate_metadata_for_professor_hides_creation_when_online(self):
CourseLiveSession.objects.create(
course=self.course,
subject='Session Live',
started_at=timezone.now(),
room_id='room-live-2',
)
self.client.force_authenticate(user=self.professor)
url = reverse('course-online-validate', kwargs={'slug': self.course.slug})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
metadata = response.data['metadata']
self.assertFalse(metadata['can_create_live_session'])

312
apps/course/token-join-guide.md

@ -0,0 +1,312 @@
# راهنمای گرفتن توکن و ورود کلاینت به کلاس‌های plugNmeet
این راهنما خلاصه می‌کند که برای سناریوی استاد/دانشجو چگونه از سرویس plugNmeet توکن بگیریم و کلاینت فرانت‌اند (`client/`) با آن وارد کلاس شود.
## پیش‌نیازها
- آدرس سرویس: `window.PLUG_N_MEET_SERVER_URL = "https://meet.newhorizonco.uk"` (در `config.js`).
- `api_key` و `secret` از فایل پیکربندی بک‌اند (`services/plugnmeet-server/config.yaml`).
- بدنهٔ درخواست‌ها باید با پروتکل JSON متناظر با پیام‌های پروتوباف (`plugnmeet-protocol`) ارسال شود؛ سرور طبق `HandleAuthHeaderCheck` هدرهای امنیتی را بررسی می‌کند.
## گام ۱: ایجاد یا فعال بودن اتاق
### API Endpoint برای Django Backend:
```
POST /api/courses/<course-slug>/online/room/create/
```
### بدنه درخواست از فرانت به Django:
```json
{
"subject": "کلاس جبر فصل ۱" // اختیاری - عنوان روم
}
```
**⚠️ نکات مهم:**
- **فرانت نباید `metadata` ارسال کند!**
- بک‌اند Django (در `apps/course/views/live_session.py`) به‌طور خودکار تنظیمات امنیتی را اعمال می‌کند
- این تضمین می‌کند که تنظیمات امنیتی به‌صورت متمرکز و یکسان اعمال شود
### بدنه درخواست از Django به PlugNMeet (خودکار):
بک‌اند Django این بدنه را خودش به PlugNMeet ارسال می‌کند:
```json
{
"room_id": "algebra-1402",
"metadata": {
"room_title": "کلاس جبر فصل ۱",
"default_lock_settings": {
"lock_microphone": true, // 🔒 قفل - فقط میزبان می‌تواند باز کند
"lock_webcam": true, // 🔒 قفل - فقط میزبان می‌تواند باز کند
"lock_screen_sharing": true // 🔒 قفل - فقط میزبان می‌تواند باز کند
},
"room_features": {
"mute_on_start": true, // 🔇 همه با میک خاموش وارد می‌شوند
"waiting_room_features": {
"is_active": false
}
}
}
}
```
> **چرا بک‌اند این کار را می‌کند؟**
> - ✅ **امنیت متمرکز**: تنظیمات امنیتی در یک جا کنترل می‌شود
> - ✅ **جلوگیری از دستکاری**: فرانت نمی‌تواند تنظیمات را تغییر دهد
> - ✅ **یکپارچگی**: همه کلاس‌ها با تنظیمات یکسان ساخته می‌شوند
> - 🔒 طبق تابع `AssignLockSettingsToUser` در `pkg/models/user_lock.go` این مقادیر برای کاربران غیر-admin اعمال می‌شود
## گام ۲: گرفتن توکن ورود
### API Endpoint برای Django Backend:
```
POST /api/courses/online/room/token/
```
### درخواست از فرانت به Django:
```
Headers:
Authorization: Token <USER_TOKEN>
Content-Type: application/json
Body:
{
"course_slug": "algebra-10"
}
```
**⚠️ نکات مهم:**
- **فرانت فقط `course_slug` ارسال می‌کند!**
- بک‌اند Django از `Authorization` header کاربر را شناسایی می‌کند
- بک‌اند خودش live session فعال دوره را پیدا می‌کند:
```python
# 1. پیدا کردن دوره
course = Course.objects.get(slug=course_slug)
# 2. پیدا کردن live session فعال
session = CourseLiveSession.objects.get(
course=course,
ended_at__isnull=True # session هایی که هنوز به پایان نرسیده‌اند
)
# 3. گرفتن room_id
room_id = session.room_id
```
- بک‌اند خودش همه اطلاعات کاربر را می‌سازد:
- `user_id` از `request.user`
- `name` از `user.get_full_name()` یا `user.email`
- `is_admin` از `user.can_manage_course(course)`
- `profilePic` از `user.avatar`
- `lock_settings` برای غیر-admin
### بدنه درخواست از Django به PlugNMeet (خودکار):
بک‌اند Django این payload را خودش می‌سازد و به PlugNMeet می‌فرستد:
**برای استاد:**
```json
{
"room_id": "algebra-1402",
"user_info": {
"user_id": "10", // 🔐 از request.user
"name": "استاد نمونه", // 🔐 از user.get_full_name()
"is_admin": true, // 🔐 از user.can_manage_course()
"user_metadata": {
"is_hidden": false,
"profilePic": "https://..." // 🔐 از user.avatar
}
}
}
```
**برای دانشجو:**
```json
{
"room_id": "algebra-1402",
"user_info": {
"user_id": "27", // 🔐 از request.user
"name": "دانشجو نمونه", // 🔐 از user.get_full_name()
"is_admin": false, // 🔐 از user.can_manage_course()
"user_metadata": {
"profilePic": "https://...", // 🔐 از user.avatar
"lock_settings": { // 🔒 خودکار برای غیر-admin
"lock_microphone": true,
"lock_screen_sharing": true,
"lock_webcam": true
}
}
}
}
```
### نحوه کار بک‌اند Django:
```python
# 1. شناسایی کاربر از token
user = request.user # از Authorization header
# 2. پیدا کردن دوره و session فعال
course = Course.objects.get(slug=course_slug)
session = CourseLiveSession.objects.get(course=course, ended_at__isnull=True)
room_id = session.room_id
# 3. تشخیص نقش
is_admin = user.can_manage_course(course) # استاد یا مالک دوره
# 4. ساخت user_info
user_info = {
'user_id': str(user.id),
'name': user.get_full_name() or user.email,
'is_admin': is_admin,
}
# 4. اضافه کردن profilePic
profile_pic = request.build_absolute_uri(user.avatar.url)
user_metadata['profilePic'] = profile_pic
# 5. اضافه کردن lock_settings برای غیر-admin
if not is_admin:
user_metadata['lock_settings'] = {
'lock_microphone': True,
'lock_screen_sharing': True,
'lock_webcam': True,
}
```
### ارسال به PlugNMeet:
بک‌اند Django با هدرهای امنیتی به PlugNMeet ارسال می‌کند:
- `API-KEY`: از settings
- `HASH-SIGNATURE`: `HMAC_SHA256(body, secret)`
- این توکن JWT اختصاصی plugNmeet است که در `GeneratePNMJoinToken` ساخته می‌شود
- `is_admin: true` باعث می‌شود در `GetPNMJoinToken` کاربر به عنوان presenter با تمام دسترسی‌ها ثبت شود
- `lock_settings` باعث می‌شود در فرانت‌اند PlugNMeet دکمه‌های میکروفون/وبکم غیرفعال شوند
### پاسخ Django به فرانت:
```json
{
"room_id": "algebra-1402",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"plugnmeet": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires": 300,
...
}
}
```
فرانت با این `token` می‌تواند کاربر را به PlugNMeet وارد کند:
```
https://meet.newhorizonco.uk/?access_token=<TOKEN>
```
## گام ۳: ورود کلاینت با توکن
۱. توکن را در URL یا کوکی قرار دهید؛ کلاینت مقدار را از `access_token` در کوئری‌استرینگ یا از کوکی `pnm_access_token` می‌خواند (`getAccessToken` در `client/src/helpers/utils.ts`).
۲. آدرس ورود: `https://meet.newhorizonco.uk/?access_token=<TOKEN>`.
۳. اپلیکیشن React موجود در `client/src/components/app/index.tsx` پس از بارگذاری:
- درخواست `POST /api/verifyToken` را با هدر `Authorization: <TOKEN>` می‌فرستد (`HandleVerifyToken`).
- اگر توکن معتبر باشد، لیست آدرس‌های NATS و موضوعات لازم را می‌گیرد و اتصال را آغاز می‌کند (`startNatsConn`).
۴. پس از اتصال، وضعیت کاربر و اتاق در Redux ذخیره می‌شود (`sessionSlice`). اگر کاربر ادمین باشد، تمام امکانات بدون محدودیت فعال است؛ در غیر این صورت مقدارهای `lock_settings` تعیین می‌کنند چه دکمه‌هایی فعال باشند.
## کنترل حالت صحبت/شنیدن برای استاد و دانشجو
### استاد (Moderator/Host):
- ✅ در توکن `is_admin: true` ارسال می‌شود
- ✅ بک‌اند Django در `apps/course/views/live_session.py` این را تشخیص می‌دهد:
```python
is_admin = user.can_manage_course(course) # استاد یا مالک دوره
```
- ✅ سرور PlugNMeet در `GetPNMJoinToken` رول presenter را فعال می‌کند
- ✅ **هیچ قفلی** روی میکروفون، وبکم یا اشتراک صفحه اعمال نمی‌شود
- 🎤 استاد می‌تواند بلافاصله صحبت کند و به دانشجو **اجازه صحبت** دهد
### دانشجو (Participant):
- 🔒 در توکن `is_admin: false` ارسال می‌شود
- 🔒 بک‌اند Django خودکار lock_settings را اضافه می‌کند:
```python
if not is_admin:
user_metadata['lock_settings'] = {
'lock_microphone': True,
'lock_screen_sharing': True,
'lock_webcam': True,
}
```
- 🔇 دکمه‌های میکروفون، وبکم و اشتراک صفحه **غیرفعال** هستند
- 👂 فقط می‌تواند **گوش دهد** تا میزبان اجازه دهد
- این منطق در `joinModal.tsx` با متغیر `isMicLock` پیاده‌سازی شده است
### نحوه دادن اجازه به دانشجو:
- میزبان باید از داخل کلاس از طریق UI کنترل کند
- یا از API `/api/updateLockSettings` یا `switchPresenter` استفاده کند
## نکات تکمیلی
### توکن‌ها و انقضا:
- توکن‌ها زمان انقضای مفهومی دارند (`client.token_validity` در YAML)
- در صورت نزدیک شدن به انقضا، کلاینت خودکار با `REQ_RENEW_PNM_TOKEN` درخواست تمدید می‌دهد
### Authorization:
- برای درخواست‌های بعدی به `/api/...` همان هدر `Authorization` را ست کنید
- کلاینت این کار را در `helpers/api/plugNmeetAPI.ts` انجام می‌دهد
### مدیریت دسترسی‌ها:
- اگر می‌خواهید دانشجو را به صحبت‌کننده ارتقا دهید: `/api/updateLockSettings` یا `switchPresenter`
- این کار فقط توسط **میزبان** امکان‌پذیر است
## 🔐 جمع‌بندی امنیت
### ❌ چیزهایی که فرانت نباید انجام دهد:
#### موقع ساخت روم:
- ❌ ارسال `metadata`
- ❌ ارسال `default_lock_settings`
- ❌ ارسال `room_features`
#### موقع گرفتن توکن:
- ❌ ارسال `room_id` (بک‌اند خودش از session فعال می‌گیرد)
- ❌ ارسال `user_info`
- ❌ ارسال `is_admin`
- ❌ ارسال `lock_settings`
- ❌ ارسال `user_id` یا `name`
### ✅ چیزهایی که فرانت فقط ارسال می‌کند:
#### موقع ساخت روم:
```json
{
"room_id": "algebra-1402", // اختیاری
"subject": "کلاس جبر" // اختیاری
}
```
#### موقع گرفتن توکن:
```json
{
"course_slug": "algebra-10" // فقط این!
}
```
+ `Authorization: Token <USER_TOKEN>` در header
### ✅ چیزهایی که بک‌اند Django خودش انجام می‌دهد:
#### برای همه درخواست‌ها:
- ✅ شناسایی کاربر از `Authorization` header
- ✅ بررسی دسترسی با `user.can_manage_course()` یا `Participant.objects.filter()`
#### موقع ساخت روم:
- ✅ تعیین `default_lock_settings` (همه `true`)
- ✅ تعیین `room_features.mute_on_start: true`
- ✅ ساخت `metadata` کامل برای PlugNMeet
#### موقع گرفتن توکن:
- ✅ پیدا کردن live session فعال از `course_slug`
- ✅ گرفتن `room_id` از session
- ✅ ساخت `user_id` از `request.user.id`
- ✅ ساخت `name` از `user.get_full_name()` یا `user.email`
- ✅ تشخیص `is_admin` از `user.can_manage_course(course)`
- ✅ گرفتن `profilePic` از `user.avatar`
- ✅ اضافه کردن `lock_settings` برای غیر-admin
- ✅ ساخت `user_info` کامل برای PlugNMeet
**نتیجه:**
- 🔒 **امنیت کامل**: فرانت نمی‌تواند هیچ تنظیمات امنیتی را دستکاری کند
- ✅ **متمرکز**: همه logic در بک‌اند Django است
- 🎯 **ساده**: فرانت فقط `course_slug` و `Authorization` header ارسال می‌کند
- 🔐 **قابل کنترل**: بک‌اند تعیین می‌کند کدام session فعال است

2
apps/course/urls.py

@ -16,6 +16,8 @@ urlpatterns = [
path('<int:pk>/online/token/', views.CourseOnlineClassTokenAPIView.as_view(), name='course-online-token'),
path('<slug:slug>/online/validate/', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-validate'),
path('online/token/validate/', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-token-validate'),
path('<slug:slug>/online/room/create/', views.CourseLiveSessionRoomCreateAPIView.as_view(), name='course-live-session-room-create'),
path('online/room/token/', views.CourseLiveSessionTokenAPIView.as_view(), name='course-live-session-token'),
path('<slug:slug>/', views.CourseDetailAPIView.as_view(), name='course-detail'),
path('<slug:slug>/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'),

3
apps/course/views/__init__.py

@ -1,4 +1,5 @@
from .course import *
from .lesson import *
from .participant import *
from .professor import *
from .professor import *
from .live_session import *

102
apps/course/views/course.py

@ -1,4 +1,6 @@
from django.conf import settings
import logging
from django.contrib.auth import get_user_model
from django.db.models import Count, Q, F
from django.shortcuts import get_object_or_404
@ -14,6 +16,8 @@ from rest_framework.generics import GenericAPIView, ListAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
logger = logging.getLogger(__name__)
from apps.course.serializers import (
CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer,
@ -408,7 +412,11 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
course_data = CourseDetailSerializer(course, context={'request': request}).data
user_data = UserProfileSerializer(user, context={'request': request}).data
metadata = self._build_metadata(course, {'user_id': user.id, 'extra': {}, 'generated_at': timezone.now().isoformat()})
metadata = self._build_metadata(
course,
{'user_id': user.id, 'extra': {}, 'generated_at': timezone.now().isoformat()},
user=user,
)
return Response({
'course': course_data,
@ -438,26 +446,41 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
}
)
def post(self, request, *args, **kwargs):
logger.info(f"[Online Validate] Request received")
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
token_value = serializer.validated_data['token']
manager = OnlineClassTokenManager()
payload = manager.get_payload(token_value)
try:
payload = manager.get_payload(token_value)
logger.info(f"[Online Validate] Token decoded successfully")
except Exception as e:
logger.error(f"[Online Validate] Token decode failed - error={str(e)}")
raise
course_id = payload.get('course_id')
user_id = payload.get('user_id')
if not course_id or not user_id:
logger.warning(f"[Online Validate] Invalid token payload - course_id={course_id} user_id={user_id}")
raise AppAPIException({'message': 'Token payload is invalid.'}, status_code=status.HTTP_400_BAD_REQUEST)
logger.info(f"[Online Validate] Processing for user_id={user_id} course_id={course_id}")
detail_view = CourseDetailAPIView()
queryset = detail_view.get_queryset()
course = get_object_or_404(queryset, pk=course_id)
user = get_object_or_404(UserModel.objects.all(), pk=user_id)
logger.info(f"[Online Validate] Course found - slug={course.slug} is_online={course.is_online}")
course_data = CourseDetailSerializer(course, context={'request': request}).data
user_data = UserProfileSerializer(user, context={'request': request}).data
metadata = self._build_metadata(course, payload)
metadata = self._build_metadata(course, payload, user=user)
logger.info(f"[Online Validate] Success - user_id={user_id} course={course.slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}")
return Response({
'course': course_data,
@ -465,55 +488,80 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
'metadata': metadata,
}, status=status.HTTP_200_OK)
def _build_metadata(self, course: Course, payload: dict) -> dict:
def _build_metadata(self, course: Course, payload: dict, user=None) -> dict:
status_value = course.status
has_started = status_value in [Course.StatusChoices.ONGOING, Course.StatusChoices.FINISHED]
timing_data = course.timing if isinstance(course.timing, dict) else {}
user_id = payload.get('user_id')
can_start_online_class = course.professor_id == user_id
user = user or UserModel.objects.filter(pk=payload.get('user_id')).first()
user_id = getattr(user, 'id', None)
can_manage = bool(user and user.can_manage_course(course))
live_context = self._build_live_session_context(course)
can_join_live_session = live_context['is_online'] and self._user_can_join_live_session(user, course)
logger.debug(f"[Online Validate Metadata] user_id={user_id} course={course.slug} can_manage={can_manage} is_online={live_context['is_online']} can_join={can_join_live_session}")
metadata = {
'status': status_value,
'has_started': has_started,
'has_finished': status_value == Course.StatusChoices.FINISHED,
'professor_in_class': payload.get('extra', {}).get('professor_in_class', False),
'can_start_online_class': can_start_online_class,
'can_create_live_session': can_manage and not live_context['is_online'],
'can_join_live_session': can_join_live_session,
'scheduled_times': timing_data,
'generated_at': payload.get('generated_at'),
'validated_at': timezone.now().isoformat(),
'redirect_path': payload.get('redirect_path'),
}
metadata.update(self._resolve_live_session_timings(course, payload))
metadata.update(live_context)
return metadata
def _resolve_live_session_timings(self, course: Course, payload: dict) -> dict:
def _build_live_session_context(self, course: Course) -> dict:
latest_session = (
CourseLiveSession.objects.filter(course=course)
.order_by('-started_at')
.order_by('-started_at', '-id')
.first()
)
started_at = None
if latest_session and latest_session.started_at:
started_at = latest_session.started_at
else:
started_at = payload.get('generated_at')
ended_at = None
if latest_session and latest_session.ended_at:
ended_at = latest_session.ended_at
elif started_at:
ended_at = timezone.now()
is_online = False
if latest_session and latest_session.started_at and not latest_session.ended_at:
is_online = True
if not latest_session:
return {
'is_online': False,
'live_session': None,
'active_room_id': None,
'livesession_started_at': None,
'livesession_ended_at': None,
}
started_at = latest_session.started_at
ended_at = latest_session.ended_at
is_online = bool(started_at and not ended_at)
live_session_data = {
'id': latest_session.id,
'room_id': latest_session.room_id,
'subject': latest_session.subject,
'started_at': self._format_datetime(started_at),
'ended_at': self._format_datetime(ended_at),
}
return {
'is_online': is_online,
'livesession_started_at': self._format_datetime(started_at),
'livesession_ended_at': self._format_datetime(ended_at),
'live_session': live_session_data,
'active_room_id': live_session_data['room_id'] if is_online and live_session_data['room_id'] else None,
'livesession_started_at': live_session_data['started_at'],
'livesession_ended_at': live_session_data['ended_at'],
}
@staticmethod
def _user_can_join_live_session(user, course: Course) -> bool:
if not user:
return False
if user.can_manage_course(course):
return True
return Participant.objects.filter(course=course, student_id=user.id, is_active=True).exists()
@staticmethod
def _format_datetime(value):
if not value:

231
apps/course/views/live_session.py

@ -0,0 +1,231 @@
import logging
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.course.models import Course, CourseLiveSession, Participant
from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer
from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError
from utils.exceptions import AppAPIException
logger = logging.getLogger(__name__)
class CourseLiveSessionRoomCreateAPIView(GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = LiveSessionRoomCreateSerializer
def post(self, request, slug, *args, **kwargs):
logger.info(f"[LiveSession Create] Request from user_id={request.user.id} for course={slug}")
serializer = self.get_serializer(data=request.data or {})
serializer.is_valid(raise_exception=True)
course = get_object_or_404(Course, slug=slug)
if not request.user.can_manage_course(course):
logger.warning(f"[LiveSession Create] Permission denied - user_id={request.user.id} course={slug}")
raise AppAPIException({'message': 'You do not have permission to create a live session for this course.'}, status_code=status.HTTP_403_FORBIDDEN)
logger.info(f"[LiveSession Create] Permission granted for user_id={request.user.id} course={slug}")
subject = serializer.validated_data.get('subject') or f"{course.title} Live Session"
room_id = serializer.validated_data.get('room_id') or self._build_room_id(course)
metadata = self._merge_metadata(subject, serializer.validated_data.get('metadata') or {})
payload = {
'room_id': room_id,
'metadata': metadata,
}
logger.info(f"[LiveSession Create] Calling PlugNMeet API - room_id={room_id} course={slug}")
try:
client = PlugNMeetClient()
plugnmeet_response = client.create_room(payload)
logger.info(f"[LiveSession Create] PlugNMeet room created successfully - room_id={room_id}")
except ImproperlyConfigured as exc:
logger.error(f"[LiveSession Create] Configuration error - {str(exc)}")
raise AppAPIException({'message': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
except PlugNMeetError as exc:
logger.error(f"[LiveSession Create] PlugNMeet API error - room_id={room_id} error={str(exc)}")
detail = exc.response_data or {'message': str(exc)}
status_code = exc.status_code or status.HTTP_502_BAD_GATEWAY
raise AppAPIException(detail, status_code=status_code)
session, created = CourseLiveSession.objects.get_or_create(
course=course,
room_id=room_id,
defaults={
'subject': subject,
'started_at': timezone.now(),
},
)
if created:
logger.info(f"[LiveSession Create] New session created - session_id={session.id} room_id={room_id} course={slug}")
else:
logger.info(f"[LiveSession Create] Existing session reactivated - session_id={session.id} room_id={room_id} course={slug}")
updates = {}
if session.subject != subject:
session.subject = subject
updates['subject'] = subject
if session.room_id != room_id:
session.room_id = room_id
updates['room_id'] = room_id
if session.started_at is None:
session.started_at = timezone.now()
updates['started_at'] = session.started_at
if updates:
session.save(update_fields=list(updates.keys()))
logger.info(f"[LiveSession Create] Session updated - session_id={session.id} fields={list(updates.keys())}")
logger.info(f"[LiveSession Create] Success - session_id={session.id} room_id={room_id} course={slug} user_id={request.user.id}")
return Response({
'session': {
'id': session.id,
'room_id': session.room_id,
'subject': session.subject,
'started_at': session.started_at,
},
'plugnmeet': plugnmeet_response,
}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
@staticmethod
def _build_room_id(course: Course) -> str:
timestamp = timezone.now().strftime('%Y%m%d%H%M%S')
return f"{course.slug}-{timestamp}"
def _merge_metadata(self, subject: str, overrides: dict) -> dict:
base = {
'room_title': subject,
'default_lock_settings': {
'lock_microphone': True,
'lock_webcam': True,
'lock_screen_sharing': True,
},
'room_features': {
'mute_on_start': True,
'waiting_room_features': {
'is_active': False,
},
},
}
return self._deep_update(base, overrides)
def _deep_update(self, base: dict, overrides: dict) -> dict:
for key, value in overrides.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
base[key] = self._deep_update(base.get(key, {}), value)
else:
base[key] = value
return base
class CourseLiveSessionTokenAPIView(GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = LiveSessionTokenSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
course_slug = serializer.validated_data['course_slug']
user = request.user
logger.info(f"[LiveSession Token] Request from user_id={user.id} for course={course_slug}")
try:
course = Course.objects.get(slug=course_slug)
except Course.DoesNotExist:
logger.warning(f"[LiveSession Token] Course not found - course={course_slug} user_id={user.id}")
raise AppAPIException({'message': 'Course not found.'}, status_code=status.HTTP_404_NOT_FOUND)
if not course.is_online:
logger.warning(f"[LiveSession Token] Course not configured for online - course={course_slug} user_id={user.id}")
raise AppAPIException({'message': 'Course is not configured for online sessions.'}, status_code=status.HTTP_400_BAD_REQUEST)
try:
session = CourseLiveSession.objects.select_related('course').get(
course=course,
ended_at__isnull=True
)
logger.info(f"[LiveSession Token] Active session found - session_id={session.id} room_id={session.room_id} course={course_slug}")
except CourseLiveSession.DoesNotExist:
logger.warning(f"[LiveSession Token] No active session found - course={course_slug} user_id={user.id}")
raise AppAPIException({'message': 'No active live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND)
room_id = session.room_id
is_admin = user.can_manage_course(course)
user_role = "professor" if is_admin else "student"
logger.info(f"[LiveSession Token] User role determined - user_id={user.id} role={user_role} course={course_slug}")
if not is_admin and not Participant.objects.filter(course=course, student_id=user.id, is_active=True).exists():
logger.warning(f"[LiveSession Token] Access denied - user_id={user.id} not enrolled in course={course_slug}")
raise AppAPIException({'message': 'You do not have access to this live session.'}, status_code=status.HTTP_403_FORBIDDEN)
user_info = {
'user_id': str(user.id),
'name': user.get_full_name() or user.email or user.username or f"user-{user.id}",
'is_admin': is_admin,
}
user_metadata = {}
profile_pic = self._build_profile_url(request, user)
if profile_pic:
user_metadata['profilePic'] = profile_pic
if not is_admin:
user_metadata['lock_settings'] = {
'lock_microphone': True,
'lock_screen_sharing': True,
'lock_webcam': True,
}
else:
user_metadata['is_hidden'] = False
if user_metadata:
user_info['user_metadata'] = user_metadata
payload = {
'room_id': room_id,
'user_info': user_info,
}
logger.info(f"[LiveSession Token] Requesting token from PlugNMeet - room_id={room_id} user_id={user.id} role={user_role}")
try:
client = PlugNMeetClient()
plugnmeet_response = client.get_join_token(payload)
logger.info(f"[LiveSession Token] Token generated successfully - room_id={room_id} user_id={user.id}")
except ImproperlyConfigured as exc:
logger.error(f"[LiveSession Token] Configuration error - {str(exc)}")
raise AppAPIException({'message': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
except PlugNMeetError as exc:
logger.error(f"[LiveSession Token] PlugNMeet API error - room_id={room_id} user_id={user.id} error={str(exc)}")
detail = exc.response_data or {'message': str(exc)}
status_code = exc.status_code or status.HTTP_502_BAD_GATEWAY
raise AppAPIException(detail, status_code=status_code)
logger.info(f"[LiveSession Token] Success - room_id={room_id} user_id={user.id} role={user_role} course={course_slug}")
return Response({
'room_id': room_id,
'token': plugnmeet_response.get('token'),
'plugnmeet': plugnmeet_response,
})
@staticmethod
def _build_profile_url(request, user):
avatar = getattr(user, 'avatar', None)
if avatar and getattr(avatar, 'url', None):
return request.build_absolute_uri(avatar.url)
return None

4
config/settings/base.py

@ -275,6 +275,10 @@ ADMIN_INDEX_TITLE = 'Imam Javad Administration'
SITE_DOMAIN = "https://imamjavad.nwhco.ir"
ONLINE_CLASS_FRONTEND_DOMAIN = env('ONLINE_CLASS_FRONTEND_DOMAIN', default=SITE_DOMAIN)
ONLINE_CLASS_TOKEN_TTL = env.int('ONLINE_CLASS_TOKEN_TTL', default=3000)
PLUGNMEET_SERVER_URL = env('PLUGNMEET_SERVER_URL', default='https://meet.newhorizonco.uk')
PLUGNMEET_API_KEY = env('PLUGNMEET_API_KEY', default='habibmeet_api_key_2024')
PLUGNMEET_API_SECRET = env('PLUGNMEET_API_SECRET', default='habibmeet_secret_zumyyYWqv7KR2kUqvYdq4z4sXg7XTBD2ljT6_2024')
PLUGNMEET_TIMEOUT = env.float('PLUGNMEET_TIMEOUT', default=10.0)
# Static files (CSS, JavaScript, Images)

333
docs/live-session-api.md

@ -0,0 +1,333 @@
# راهنمای اتصال فرانت‌اند به API لایو کلاس
این مستند جریان کامل «ایجاد روم لایو»، «گرفتن توکن ورود» و «مدیریت ورود استاد و دانشجو» را توضیح می‌دهد.
## ۱. اعتبارسنجی وضعیت دوره
```
GET /api/courses/<course-slug>/online/validate/
Headers:
Authorization: Token <USER_TOKEN>
```
### پاسخ نمونه (استاد، کلاس آنلاین در حال اجرا)
```json
{
"course": {
"id": 42,
"slug": "algebra-10",
"title": "کلاس جبر",
"is_online": true,
"online_link": "https://imamjavad.app/courses/algebra-10/live",
"status": "ongoing",
"professor": {
"id": 10,
"fullname": "استاد نمونه",
"slug": "ostad-nemoone",
"avatar": "https://imamjavad.app/media/users/avatars/2025/10/ostad.jpg"
}
},
"user": {
"id": 10,
"email": "prof@example.com",
"fullname": "استاد نمونه",
"avatar": "https://imamjavad.app/media/users/avatars/2025/10/ostad.jpg",
"roles": ["professor"],
"is_staff": false
},
"metadata": {
"status": "ongoing",
"has_started": true,
"has_finished": false,
"professor_in_class": false,
"can_create_live_session": false,
"can_join_live_session": true,
"scheduled_times": {
"day": "monday",
"start_time": "09:00",
"timezone": "Asia/Tehran"
},
"generated_at": "2025-10-14T01:32:45+03:30",
"validated_at": "2025-10-14T01:33:10+03:30",
"redirect_path": null,
"is_online": true,
"active_room_id": "algebra-1402",
"livesession_started_at": "2025-10-14T01:15:00+03:30",
"livesession_ended_at": null,
"live_session": {
"id": 7,
"room_id": "algebra-1402",
"subject": "کلاس جبر فصل ۱",
"started_at": "2025-10-14T01:15:00+03:30",
"ended_at": null
}
}
}
```
- `can_create_live_session`: اگر `true` → استاد می‌تواند روم جدید بسازد (فقط وقتی کلاس آفلاین است)
- `can_join_live_session`: اگر `true` → کاربر می‌تواند به کلاس فعال بپیوندد (استاد یا دانشجو)
- `active_room_id`: room_id کلاس فعال (برای نمایش در UI)
- `livesession_started_at`: زمان شروع - برای محاسبه مدت سپری‌شده
### پاسخ نمونه (استاد، کلاس آفلاین)
```json
{
"course": { "id": 42, "title": "کلاس جبر" },
"user": { "id": 10, "fullname": "استاد نمونه" },
"metadata": {
"status": "ongoing",
"has_started": true,
"has_finished": false,
"professor_in_class": false,
"can_create_live_session": true,
"can_join_live_session": false,
"scheduled_times": { "day": "monday", "time": "09:00" },
"generated_at": "2025-10-14T01:32:45+03:30",
"validated_at": "2025-10-14T01:33:10+03:30",
"redirect_path": null,
"is_online": false,
"active_room_id": null,
"livesession_started_at": null,
"livesession_ended_at": null,
"live_session": null
}
}
```
### پاسخ نمونه (دانشجو ثبت‌نام‌کرده، کلاس آنلاین)
```json
{
"course": { "id": 42, "title": "کلاس جبر" },
"user": { "id": 27, "fullname": "دانشجو نمونه" },
"metadata": {
"status": "ongoing",
"has_started": true,
"has_finished": false,
"professor_in_class": false,
"can_create_live_session": false,
"can_join_live_session": true,
"scheduled_times": { "day": "monday", "time": "09:00" },
"generated_at": "2025-10-14T01:32:45+03:30",
"validated_at": "2025-10-14T01:33:10+03:30",
"redirect_path": null,
"is_online": true,
"active_room_id": "algebra-1402",
"livesession_started_at": "2025-10-14T01:15:00+03:30",
"livesession_ended_at": null,
"live_session": {
"id": 7,
"room_id": "algebra-1402",
"subject": "کلاس جبر فصل ۱",
"started_at": "2025-10-14T01:15:00+03:30",
"ended_at": null
}
}
}
```
### پاسخ نمونه (دانشجو ثبت‌نام‌کرده، کلاس آفلاین)
```json
{
"course": { "id": 42, "title": "کلاس جبر" },
"user": { "id": 27, "fullname": "دانشجو نمونه" },
"metadata": {
"status": "ongoing",
"has_started": true,
"has_finished": false,
"professor_in_class": false,
"can_create_live_session": false,
"can_join_live_session": false,
"scheduled_times": { "day": "monday", "time": "09:00" },
"generated_at": "2025-10-14T01:32:45+03:30",
"validated_at": "2025-10-14T01:33:10+03:30",
"redirect_path": null,
"is_online": false,
"active_room_id": null,
"livesession_started_at": null,
"livesession_ended_at": null,
"live_session": null
}
}
```
### پاسخ نمونه (کاربر بدون دسترسی)
```json
{
"status": "error",
"code": "app_api_error",
"status_code": 403,
"message": "An error occurred while processing the request.",
"errors": [
{ "message": "You do not have access to this course." }
]
}
```
## ۲. ساخت یا فعال کردن روم (استاد)
```
POST /api/courses/<course-slug>/online/room/create/
Headers:
Authorization: Token <USER_TOKEN>
Content-Type: application/json
Body (نمونه):
{
"subject": "کلاس جبر فصل ۱" // اختیاری؛ پیش‌فرض عنوان دوره + "Live Session"
}
```
**⚠️ نکات مهم:**
- **فرانت نباید `metadata` ارسال کند!**
- بک‌اند به‌طور خودکار تنظیمات امنیتی را اعمال می‌کند:
- `lock_microphone: true` - میکروفون برای همه قفل است
- `lock_webcam: true` - وبکم برای همه قفل است
- `lock_screen_sharing: true` - اشتراک صفحه برای همه قفل است
- `mute_on_start: true` - همه با میکروفون خاموش وارد می‌شوند
- **فقط میزبان (استاد)** می‌تواند این محدودیت‌ها را برداشته و به دانشجو اجازه دهد
### پاسخ موفق (۲۰۱ یا ۲۰۰)
```json
{
"session": {
"id": 7,
"room_id": "algebra-1402",
"subject": "کلاس جبر فصل ۱",
"started_at": "2025-10-14T01:32:45+03:30"
},
"plugnmeet": {
"status": "success",
"room_id": "algebra-1402",
"...": "پاسخ کامل PlugNMeet"
}
}
```
## ۳. گرفتن توکن ورود به روم
```
POST /api/courses/online/room/token/
Headers:
Authorization: Token <USER_TOKEN>
Content-Type: application/json
Body:
{
"course_slug": "algebra-10"
}
```
**⚠️ نکات مهم:**
- **فرانت فقط `course_slug` ارسال می‌کند!**
- بک‌اند از `Authorization` header کاربر را شناسایی می‌کند
- بک‌اند خودش live session فعال دوره را پیدا می‌کند:
```python
course = Course.objects.get(slug=course_slug)
session = CourseLiveSession.objects.get(course=course, ended_at__isnull=True)
room_id = session.room_id
```
- بک‌اند خودش همه اطلاعات کاربر را می‌سازد:
- `user_id` از `request.user.id`
- `name` از `user.get_full_name()` یا `user.email`
- `is_admin` از `user.can_manage_course(course)` - تشخیص خودکار استاد/دانشجو
- `profilePic` از `user.avatar`
- `lock_settings` خودکار برای دانشجو (همه قفل)
### پاسخ موفق
```json
{
"room_id": "algebra-1402",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"plugnmeet": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires": 300,
"...": "پاسخ کامل PlugNMeet"
}
}
```
### نحوه استفاده:
- `token` باید در URL سرویس PlugNMeet استفاده شود:
```
https://meet.newhorizonco.uk/?access_token=<token>
```
- بک‌اند خودکار تشخیص می‌دهد:
- **استاد**: `is_admin: true` → همه دسترسی‌ها بدون محدودیت
- **دانشجو**: `is_admin: false` + `lock_settings` → میکروفون، وبکم و اشتراک صفحه قفل است
## سناریوهای پیشنهادی برای پیاده‌سازی فرانت
### سناریوی استاد
1. **دریافت وضعیت**: با `GET /online/validate/` وضعیت را بگیرید.
- اگر `can_create_live_session = true` است → دکمه «ساخت کلاس» را نشان دهید.
- اگر `can_join_live_session = true` است → دکمه «ورود به کلاس» را نشان دهید.
2. **ساخت روم** (در صورت نیاز):
```json
POST /online/room/create/
Body: {
"room_id": "algebra-10-1704880365000", // اختیاری
"subject": "کلاس جبر" // اختیاری
}
```
**نکته**: فقط این دو فیلد! بک‌اند خودش `metadata` و تنظیمات امنیتی را اعمال می‌کند.
3. **گرفتن توکن**:
```json
POST /online/room/token/
Body: {
"course_slug": "algebra-10" // فقط این!
}
```
**نکته**: بک‌اند خودش live session فعال را پیدا می‌کند و `room_id` را می‌گیرد.
4. **ورود به کلاس**:
```javascript
window.open(`https://meet.newhorizonco.uk/?access_token=${token}`, "_blank");
```
### سناریوی دانشجو
1. **دریافت وضعیت**: با `GET /online/validate/` وضعیت را بگیرید.
- اگر `is_online = true` و `can_join_live_session = true` → دکمه «ورود به کلاس» را نمایش دهید.
2. **گرفتن توکن و ورود**:
```json
POST /online/room/token/
Body: {
"course_slug": "algebra-10" // فقط این!
}
```
سپس با `token` دریافتی به PlugNMeet وارد شوید.
### ✅ آنچه بک‌اند خودکار انجام می‌دهد:
#### موقع ساخت روم:
- ✅ تعیین `default_lock_settings` (همه `true`)
- ✅ تعیین `room_features.mute_on_start: true`
- ✅ ساخت `metadata` کامل برای PlugNMeet
#### موقع گرفتن توکن:
- ✅ پیدا کردن live session فعال از `course_slug`
- ✅ گرفتن `room_id` از session
- ✅ تشخیص `is_admin` با `user.can_manage_course(course)`
- ✅ ساخت `user_info` کامل (user_id, name, profilePic)
- ✅ اضافه کردن `lock_settings` برای دانشجو
### ❌ آنچه فرانت نباید ارسال کند:
- ❌ `metadata` موقع ساخت روم
- ❌ `room_id` موقع گرفتن توکن
- ❌ `user_info`, `is_admin`, `lock_settings`
### 🔐 نکات امنیتی:
- همه تنظیمات امنیتی در سمت سرور کنترل می‌شود
- فرانت نمی‌تواند تنظیمات را دستکاری کند
- بک‌اند تعیین می‌کند چه کسی استاد است و چه کسی دانشجو
- زمانی که استاد وارد لایو شده است، `can_create_live_session` برابر `false` می‌شود
- برای نمایش مدت سپری‌شده، از `livesession_started_at` استفاده کرده و در فرانت اختلاف با زمان فعلی را محاسبه کنید

361
docs/live-session-logs.md

@ -0,0 +1,361 @@
# راهنمای لاگ‌های Live Session API
این مستند توضیح می‌دهد که هر API چه لاگ‌هایی تولید می‌کند و چگونه می‌توان از آنها برای debug استفاده کرد.
## 📋 فرمت لاگ‌ها
همه لاگ‌ها با یک prefix مشخص شروع می‌شوند:
- `[LiveSession Create]` - مربوط به ساخت روم
- `[LiveSession Token]` - مربوط به گرفتن توکن ورود
- `[Online Validate]` - مربوط به اعتبارسنجی وضعیت دوره
## 🔹 API: ساخت روم (Create Room)
**Endpoint:** `POST /api/courses/<course-slug>/online/room/create/`
### جریان لاگ‌ها:
1. **شروع درخواست:**
```
INFO [LiveSession Create] Request from user_id=10 for course=algebra-10
```
2. **بررسی دسترسی:**
- **موفق:**
```
INFO [LiveSession Create] Permission granted for user_id=10 course=algebra-10
```
- **رد شده:**
```
WARNING [LiveSession Create] Permission denied - user_id=27 course=algebra-10
```
3. **فراخوانی PlugNMeet:**
```
INFO [LiveSession Create] Calling PlugNMeet API - room_id=algebra-10-20231014102530 course=algebra-10
```
4. **نتیجه PlugNMeet:**
- **موفق:**
```
INFO [LiveSession Create] PlugNMeet room created successfully - room_id=algebra-10-20231014102530
```
- **خطای پیکربندی:**
```
ERROR [LiveSession Create] Configuration error - PLUGNMEET_API_KEY is not configured
```
- **خطای API:**
```
ERROR [LiveSession Create] PlugNMeet API error - room_id=algebra-10-20231014102530 error=Room already exists
```
5. **ذخیره در دیتابیس:**
- **Session جدید:**
```
INFO [LiveSession Create] New session created - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10
```
- **Session موجود:**
```
INFO [LiveSession Create] Existing session reactivated - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10
INFO [LiveSession Create] Session updated - session_id=7 fields=['subject', 'started_at']
```
6. **نتیجه نهایی:**
```
INFO [LiveSession Create] Success - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10 user_id=10
```
### سناریوهای خطا:
| خطا | لاگ | دلیل |
|-----|-----|------|
| Permission Denied | `WARNING [LiveSession Create] Permission denied - user_id=27 course=algebra-10` | کاربر استاد نیست |
| Configuration Error | `ERROR [LiveSession Create] Configuration error - ...` | تنظیمات PlugNMeet ناقص |
| PlugNMeet API Error | `ERROR [LiveSession Create] PlugNMeet API error - ...` | سرویس PlugNMeet پاسخ خطا داده |
---
## 🔹 API: گرفتن توکن (Get Join Token)
**Endpoint:** `POST /api/courses/online/room/token/`
### جریان لاگ‌ها:
1. **شروع درخواست:**
```
INFO [LiveSession Token] Request from user_id=27 for course=algebra-10
```
2. **پیدا کردن دوره:**
- **موفق:**
```
(بدون لاگ - به مرحله بعد می‌رود)
```
- **پیدا نشد:**
```
WARNING [LiveSession Token] Course not found - course=algebra-10 user_id=27
```
3. **بررسی تنظیمات دوره:**
- **دوره آنلاین نیست:**
```
WARNING [LiveSession Token] Course not configured for online - course=algebra-10 user_id=27
```
4. **پیدا کردن Session فعال:**
- **موفق:**
```
INFO [LiveSession Token] Active session found - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10
```
- **پیدا نشد:**
```
WARNING [LiveSession Token] No active session found - course=algebra-10 user_id=27
```
5. **تشخیص نقش کاربر:**
```
INFO [LiveSession Token] User role determined - user_id=27 role=student course=algebra-10
```
یا
```
INFO [LiveSession Token] User role determined - user_id=10 role=professor course=algebra-10
```
6. **بررسی دسترسی (برای دانشجو):**
- **ثبت‌نام نشده:**
```
WARNING [LiveSession Token] Access denied - user_id=27 not enrolled in course=algebra-10
```
7. **درخواست توکن از PlugNMeet:**
```
INFO [LiveSession Token] Requesting token from PlugNMeet - room_id=algebra-10-20231014102530 user_id=27 role=student
```
8. **نتیجه PlugNMeet:**
- **موفق:**
```
INFO [LiveSession Token] Token generated successfully - room_id=algebra-10-20231014102530 user_id=27
```
- **خطا:**
```
ERROR [LiveSession Token] Configuration error - PLUGNMEET_API_KEY is not configured
```
یا
```
ERROR [LiveSession Token] PlugNMeet API error - room_id=algebra-10-20231014102530 user_id=27 error=Room not found
```
9. **نتیجه نهایی:**
```
INFO [LiveSession Token] Success - room_id=algebra-10-20231014102530 user_id=27 role=student course=algebra-10
```
### سناریوهای خطا:
| خطا | لاگ | دلیل |
|-----|-----|------|
| Course Not Found | `WARNING [LiveSession Token] Course not found - ...` | slug اشتباه است |
| Course Not Online | `WARNING [LiveSession Token] Course not configured for online - ...` | دوره آنلاین نیست |
| No Active Session | `WARNING [LiveSession Token] No active session found - ...` | هیچ session فعالی وجود ندارد |
| Access Denied | `WARNING [LiveSession Token] Access denied - user_id=X not enrolled in course=Y` | دانشجو در کلاس ثبت‌نام نکرده |
| PlugNMeet Error | `ERROR [LiveSession Token] PlugNMeet API error - ...` | مشکل در سرویس PlugNMeet |
---
## 🔹 API: اعتبارسنجی (Online Validate)
**Endpoint:** `GET /api/courses/<course-slug>/online/validate/`
### جریان لاگ‌ها:
1. **شروع درخواست:**
```
INFO [Online Validate] Request received
```
2. **Decode توکن:**
- **موفق:**
```
INFO [Online Validate] Token decoded successfully
```
- **خطا:**
```
ERROR [Online Validate] Token decode failed - error=Token has expired
```
3. **بررسی Payload:**
- **نامعتبر:**
```
WARNING [Online Validate] Invalid token payload - course_id=None user_id=10
```
4. **شروع پردازش:**
```
INFO [Online Validate] Processing for user_id=10 course_id=42
```
5. **پیدا کردن دوره:**
```
INFO [Online Validate] Course found - slug=algebra-10 is_online=True
```
6. **ساخت Metadata:**
```
DEBUG [Online Validate Metadata] user_id=10 course=algebra-10 can_manage=True is_online=True can_join=True
```
7. **نتیجه نهایی:**
```
INFO [Online Validate] Success - user_id=10 course=algebra-10 can_create=False can_join=True
```
### سناریوهای خطا:
| خطا | لاگ | دلیل |
|-----|-----|------|
| Token Decode Error | `ERROR [Online Validate] Token decode failed - ...` | توکن منقضی یا نامعتبر |
| Invalid Payload | `WARNING [Online Validate] Invalid token payload - ...` | payload توکن ناقص است |
---
## 🔍 نحوه استفاده از لاگ‌ها برای Debug
### 1. بررسی جریان کامل یک درخواست:
برای دنبال کردن یک درخواست خاص، بر اساس `user_id` یا `course` فیلتر کنید:
```bash
# برای user خاص
tail -f /var/log/app.log | grep "user_id=27"
# برای course خاص
tail -f /var/log/app.log | grep "course=algebra-10"
# برای room خاص
tail -f /var/log/app.log | grep "room_id=algebra-10-20231014102530"
```
### 2. پیدا کردن خطاها:
```bash
# همه خطاهای LiveSession
tail -f /var/log/app.log | grep -E "\[LiveSession (Create|Token)\]" | grep ERROR
# خطاهای PlugNMeet
tail -f /var/log/app.log | grep "PlugNMeet API error"
# خطاهای دسترسی
tail -f /var/log/app.log | grep -E "(Permission denied|Access denied)"
```
### 3. بررسی وضعیت سیستم:
```bash
# همه درخواست‌های ساخت روم
tail -f /var/log/app.log | grep "\[LiveSession Create\] Request"
# همه درخواست‌های توکن
tail -f /var/log/app.log | grep "\[LiveSession Token\] Request"
# تشخیص نقش کاربران
tail -f /var/log/app.log | grep "User role determined"
```
### 4. آمار سریع:
```bash
# تعداد room های ساخته شده امروز
grep "LiveSession Create.*Success" /var/log/app.log | grep "$(date +%Y-%m-%d)" | wc -l
# تعداد توکن‌های صادر شده
grep "LiveSession Token.*Success" /var/log/app.log | grep "$(date +%Y-%m-%d)" | wc -l
# تعداد خطاها
grep "ERROR.*LiveSession" /var/log/app.log | grep "$(date +%Y-%m-%d)" | wc -l
```
---
## ⚙️ تنظیمات Logging
برای فعال کردن لاگ‌ها، در `settings.py`:
```python
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{levelname} {asctime} {module} {message}',
'style': '{',
},
},
'handlers': {
'file': {
'level': 'INFO',
'class': 'logging.FileHandler',
'filename': '/var/log/app.log',
'formatter': 'verbose',
},
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
},
'loggers': {
'apps.course.views.live_session': {
'handlers': ['file', 'console'],
'level': 'INFO',
'propagate': False,
},
'apps.course.views.course': {
'handlers': ['file', 'console'],
'level': 'INFO',
'propagate': False,
},
},
}
```
### سطوح لاگ:
- **INFO**: جریان عادی - شروع درخواست، موفقیت عملیات
- **WARNING**: وضعیت غیرعادی اما قابل کنترل - دسترسی رد شده، دوره پیدا نشد
- **ERROR**: خطاهای سیستم - مشکل PlugNMeet، خطای پیکربندی
- **DEBUG**: جزئیات بیشتر - ساخت metadata، مقادیر داخلی
برای debug بیشتر، `level` را به `DEBUG` تغییر دهید.
---
## 📊 مثال: جریان کامل یک Session
```
INFO [LiveSession Create] Request from user_id=10 for course=algebra-10
INFO [LiveSession Create] Permission granted for user_id=10 course=algebra-10
INFO [LiveSession Create] Calling PlugNMeet API - room_id=algebra-10-20231014102530 course=algebra-10
INFO [LiveSession Create] PlugNMeet room created successfully - room_id=algebra-10-20231014102530
INFO [LiveSession Create] New session created - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10
INFO [LiveSession Create] Success - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10 user_id=10
INFO [LiveSession Token] Request from user_id=10 for course=algebra-10
INFO [LiveSession Token] Active session found - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10
INFO [LiveSession Token] User role determined - user_id=10 role=professor course=algebra-10
INFO [LiveSession Token] Requesting token from PlugNMeet - room_id=algebra-10-20231014102530 user_id=10 role=professor
INFO [LiveSession Token] Token generated successfully - room_id=algebra-10-20231014102530 user_id=10
INFO [LiveSession Token] Success - room_id=algebra-10-20231014102530 user_id=10 role=professor course=algebra-10
INFO [LiveSession Token] Request from user_id=27 for course=algebra-10
INFO [LiveSession Token] Active session found - session_id=7 room_id=algebra-10-20231014102530 course=algebra-10
INFO [LiveSession Token] User role determined - user_id=27 role=student course=algebra-10
INFO [LiveSession Token] Requesting token from PlugNMeet - room_id=algebra-10-20231014102530 user_id=27 role=student
INFO [LiveSession Token] Token generated successfully - room_id=algebra-10-20231014102530 user_id=27
INFO [LiveSession Token] Success - room_id=algebra-10-20231014102530 user_id=27 role=student course=algebra-10
```
این جریان نشان می‌دهد:
1. استاد روم را ساخت
2. استاد توکن گرفت و وارد شد (role=professor)
3. دانشجو توکن گرفت و وارد شد (role=student)
Loading…
Cancel
Save