Browse Source
feat(api): add live session room APIs and metadata
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
14 changed files with 1632 additions and 28 deletions
-
22apps/course/migrations/0009_auto_20251014_0051.py
-
9apps/course/models/live_session.py
-
22apps/course/serializers/online.py
-
3apps/course/services/__init__.py
-
74apps/course/services/plugnmeet.py
-
182apps/course/tests/test_live_session_api.py
-
312apps/course/token-join-guide.md
-
2apps/course/urls.py
-
3apps/course/views/__init__.py
-
102apps/course/views/course.py
-
231apps/course/views/live_session.py
-
4config/settings/base.py
-
333docs/live-session-api.md
-
361docs/live-session-logs.md
@ -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'), |
|||
), |
|||
] |
|||
@ -0,0 +1,3 @@ |
|||
from .plugnmeet import PlugNMeetClient, PlugNMeetError |
|||
|
|||
__all__ = ['PlugNMeetClient', 'PlugNMeetError'] |
|||
@ -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"] |
|||
@ -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']) |
|||
@ -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 فعال است |
|||
@ -1,4 +1,5 @@ |
|||
from .course import * |
|||
from .lesson import * |
|||
from .participant import * |
|||
from .professor import * |
|||
from .professor import * |
|||
from .live_session import * |
|||
@ -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 |
|||
@ -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` استفاده کرده و در فرانت اختلاف با زمان فعلی را محاسبه کنید |
|||
@ -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) |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue