From 1b0f8138559a778e0a35810a44f43f3a0e7aee8e Mon Sep 17 00:00:00 2001 From: mortezaei Date: Tue, 14 Oct 2025 11:27:32 +0330 Subject: [PATCH] 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: - /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. --- .../migrations/0009_auto_20251014_0051.py | 22 ++ apps/course/models/live_session.py | 9 + apps/course/serializers/online.py | 22 ++ apps/course/services/__init__.py | 3 + apps/course/services/plugnmeet.py | 74 ++++ apps/course/tests/test_live_session_api.py | 182 +++++++++ apps/course/token-join-guide.md | 312 +++++++++++++++ apps/course/urls.py | 2 + apps/course/views/__init__.py | 3 +- apps/course/views/course.py | 102 +++-- apps/course/views/live_session.py | 231 +++++++++++ config/settings/base.py | 4 + docs/live-session-api.md | 333 ++++++++++++++++ docs/live-session-logs.md | 361 ++++++++++++++++++ 14 files changed, 1632 insertions(+), 28 deletions(-) create mode 100644 apps/course/migrations/0009_auto_20251014_0051.py create mode 100644 apps/course/services/__init__.py create mode 100644 apps/course/services/plugnmeet.py create mode 100644 apps/course/tests/test_live_session_api.py create mode 100644 apps/course/token-join-guide.md create mode 100644 apps/course/views/live_session.py create mode 100644 docs/live-session-api.md create mode 100644 docs/live-session-logs.md diff --git a/apps/course/migrations/0009_auto_20251014_0051.py b/apps/course/migrations/0009_auto_20251014_0051.py new file mode 100644 index 0000000..3407766 --- /dev/null +++ b/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'), + ), + ] diff --git a/apps/course/models/live_session.py b/apps/course/models/live_session.py index cd31bf8..06dcccd 100644 --- a/apps/course/models/live_session.py +++ b/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"]), ] diff --git a/apps/course/serializers/online.py b/apps/course/serializers/online.py index 86e0188..44d7396 100644 --- a/apps/course/serializers/online.py +++ b/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 diff --git a/apps/course/services/__init__.py b/apps/course/services/__init__.py new file mode 100644 index 0000000..2dc8b89 --- /dev/null +++ b/apps/course/services/__init__.py @@ -0,0 +1,3 @@ +from .plugnmeet import PlugNMeetClient, PlugNMeetError + +__all__ = ['PlugNMeetClient', 'PlugNMeetError'] diff --git a/apps/course/services/plugnmeet.py b/apps/course/services/plugnmeet.py new file mode 100644 index 0000000..d5859d5 --- /dev/null +++ b/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"] diff --git a/apps/course/tests/test_live_session_api.py b/apps/course/tests/test_live_session_api.py new file mode 100644 index 0000000..54ccac8 --- /dev/null +++ b/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']) diff --git a/apps/course/token-join-guide.md b/apps/course/token-join-guide.md new file mode 100644 index 0000000..9e0e157 --- /dev/null +++ b/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//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 + 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= +``` + +## گام ۳: ورود کلاینت با توکن +۱. توکن را در URL یا کوکی قرار دهید؛ کلاینت مقدار را از `access_token` در کوئری‌استرینگ یا از کوکی `pnm_access_token` می‌خواند (`getAccessToken` در `client/src/helpers/utils.ts`). +۲. آدرس ورود: `https://meet.newhorizonco.uk/?access_token=`. +۳. اپلیکیشن React موجود در `client/src/components/app/index.tsx` پس از بارگذاری: + - درخواست `POST /api/verifyToken` را با هدر `Authorization: ` می‌فرستد (`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 ` در 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 فعال است diff --git a/apps/course/urls.py b/apps/course/urls.py index 3511ab9..e8eb33b 100644 --- a/apps/course/urls.py +++ b/apps/course/urls.py @@ -16,6 +16,8 @@ urlpatterns = [ path('/online/token/', views.CourseOnlineClassTokenAPIView.as_view(), name='course-online-token'), path('/online/validate/', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-validate'), path('online/token/validate/', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-token-validate'), + path('/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('/', views.CourseDetailAPIView.as_view(), name='course-detail'), path('/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), diff --git a/apps/course/views/__init__.py b/apps/course/views/__init__.py index edc809c..014ada2 100644 --- a/apps/course/views/__init__.py +++ b/apps/course/views/__init__.py @@ -1,4 +1,5 @@ from .course import * from .lesson import * from .participant import * -from .professor import * \ No newline at end of file +from .professor import * +from .live_session import * \ No newline at end of file diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 23ff3c9..fbcd85c 100644 --- a/apps/course/views/course.py +++ b/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: diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py new file mode 100644 index 0000000..a0be3c0 --- /dev/null +++ b/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 diff --git a/config/settings/base.py b/config/settings/base.py index 6970bfa..b6b70be 100755 --- a/config/settings/base.py +++ b/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) diff --git a/docs/live-session-api.md b/docs/live-session-api.md new file mode 100644 index 0000000..2fd40fc --- /dev/null +++ b/docs/live-session-api.md @@ -0,0 +1,333 @@ +# راهنمای اتصال فرانت‌اند به API لایو کلاس + +این مستند جریان کامل «ایجاد روم لایو»، «گرفتن توکن ورود» و «مدیریت ورود استاد و دانشجو» را توضیح می‌دهد. + +## ۱. اعتبارسنجی وضعیت دوره + +``` +GET /api/courses//online/validate/ +Headers: + Authorization: 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//online/room/create/ +Headers: + Authorization: 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 + 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= + ``` +- بک‌اند خودکار تشخیص می‌دهد: + - **استاد**: `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` استفاده کرده و در فرانت اختلاف با زمان فعلی را محاسبه کنید diff --git a/docs/live-session-logs.md b/docs/live-session-logs.md new file mode 100644 index 0000000..52a7efa --- /dev/null +++ b/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//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//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)