Mohsen Taba 3 months ago
parent
commit
3085683f71
  1. 10
      apps/account/middleware/admin_access.py
  2. 4
      apps/account/serializers/user.py
  3. 1660
      apps/course/services/api.md
  4. 153
      apps/course/views/course.py
  5. 315
      apps/course/views/live_session.py
  6. 5
      config/enhanced_auth_middleware.py
  7. 22
      config/middleware/__init__.py
  8. 32
      config/middleware/site_middleware.py
  9. 1
      config/settings/base.py
  10. 22
      config/urls.py
  11. 26
      config/urls_dovoodi.py
  12. 26
      config/urls_imamjavad.py
  13. 19
      nginx/dovodi.conf
  14. 223
      nginx/imamjavad.conf
  15. 21
      utils/admin.py
  16. 46
      utils/redis.py

10
apps/account/middleware/admin_access.py

@ -94,7 +94,10 @@ class AdminAccessMiddleware:
def handle_restricted_access(self, request):
"""مدیریت دسترسی محدود شده"""
if not request.user.is_authenticated:
return redirect('admin:login')
# Redirect to admin login page using direct URL path
from django.utils.translation import get_language
language = get_language() or 'en'
return redirect(f'/{language}/admin/login/?next={request.path}')
# اگر کاربر استاد است، در همان admin panel می‌ماند
if request.user.is_authenticated and request.user.has_role('professor'):
@ -111,4 +114,7 @@ class AdminAccessMiddleware:
request,
_('You do not have permission to access this page.')
)
return redirect('admin:login')
# Redirect to admin login page using direct URL path
from django.utils.translation import get_language
language = get_language() or 'en'
return redirect(f'/{language}/admin/login/?next={request.path}')

4
apps/account/serializers/user.py

@ -26,6 +26,10 @@ class UserProfileSerializer(serializers.ModelSerializer):
read_only_fields = ['email', 'info', 'skill', 'device_id', 'slug', 'saved_location']
def get_saved_location(self, obj):
# Check if user is authenticated and has location_history attribute
if not obj.is_authenticated or not hasattr(obj, 'location_history'):
return None
last_location = obj.location_history.order_by('-at_time').first()
if last_location:
return {

1660
apps/course/services/api.md
File diff suppressed because it is too large
View File

153
apps/course/views/course.py

@ -9,6 +9,7 @@ from django.utils import timezone
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.authentication import TokenAuthentication
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import NotFound
from rest_framework.filters import SearchFilter
@ -415,14 +416,12 @@ class CourseOnlineClassTokenAPIView(GenericAPIView):
class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
# Changed from AllowAny to enable DRF authentication
# Users can still access without auth, but if token is provided, it will be authenticated
authentication_classes = [TokenAuthentication]
permission_classes = [AllowAny]
serializer_class = OnlineClassTokenVerifySerializer
def get_permissions(self):
if self.request.method == 'GET':
return [IsAuthenticated()]
return [AllowAny()]
@swagger_auto_schema(
tags=['Imam-Javad - Course'],
operation_description="Get course and user data for authenticated user.",
@ -453,11 +452,24 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
}
)
def get(self, request, slug, *args, **kwargs):
print("=" * 80)
print(f"[Online Validate GET] REQUEST RECEIVED {request.data}")
print(f"[Online Validate GET] slug={slug}")
print(f"[Online Validate GET] user={request.user}")
print(f"[Online Validate GET] user.is_authenticated={request.user.is_authenticated}")
print(f"[Online Validate GET] user.id={request.user.id if request.user.is_authenticated else 'N/A'}")
print("=" * 80)
logger.info(f"[Online Validate GET] Request received - slug={slug} user_id={request.user.id if request.user.is_authenticated else 'anonymous'}")
detail_view = CourseDetailAPIView()
queryset = detail_view.get_queryset()
course = get_object_or_404(queryset, slug=slug)
user = request.user
print(f"[Online Validate GET] Course found - course_id={course.id} slug={slug} is_online={course.is_online}")
logger.info(f"[Online Validate GET] Course found - course_id={course.id} slug={slug} is_online={course.is_online}")
# DEPRECATED: Polling approach replaced by webhook integration
# Room status is now updated automatically via PlugNMeet webhooks
# self._sync_room_status_with_plugnmeet(course)
@ -466,10 +478,13 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
user_data = UserProfileSerializer(user, context={'request': request}).data
metadata = self._build_metadata(
course,
{'user_id': user.id, 'extra': {}, 'generated_at': timezone.now().isoformat()},
{'user_id': user.id if user.is_authenticated else None, 'extra': {}, 'generated_at': timezone.now().isoformat()},
user=user,
)
print(f"[Online Validate GET] Success - metadata={metadata}")
logger.info(f"[Online Validate GET] Success - user_id={user.id if user.is_authenticated else 'anonymous'} course={slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}")
return Response({
'course': course_data,
'user': user_data,
@ -499,41 +514,56 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
}
)
def post(self, request, *args, **kwargs):
logger.info(f"[Online Validate] Request received")
print("=" * 80)
print(f"[Online Validate POST] REQUEST RECEIVED")
print(f"[Online Validate POST] request.data={request.data}")
print(f"[Online Validate POST] has_token={'token' in request.data}")
print("=" * 80)
logger.info(f"[Online Validate POST] Request received - has_token={'token' in request.data}")
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
token_value = serializer.validated_data['token']
print(f"[Online Validate POST] Token extracted - token={token_value[:16]}...")
logger.info(f"[Online Validate POST] Token extracted - token={token_value[:16]}...")
manager = OnlineClassTokenManager()
try:
payload = manager.get_payload(token_value)
logger.info(f"[Online Validate] Token decoded successfully")
print(f"[Online Validate POST] Token decoded successfully - payload={payload}")
logger.info(f"[Online Validate POST] Token decoded successfully - payload={payload}")
except Exception as e:
logger.error(f"[Online Validate] Token decode failed - error={str(e)}")
print(f"[Online Validate POST] Token decode FAILED - error={str(e)} type={type(e).__name__}")
logger.error(f"[Online Validate POST] Token decode failed - error={str(e)} type={type(e).__name__}")
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}")
print(f"[Online Validate POST] Invalid token payload - course_id={course_id} user_id={user_id}")
logger.warning(f"[Online Validate POST] 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}")
print(f"[Online Validate POST] Processing for user_id={user_id} course_id={course_id}")
logger.info(f"[Online Validate POST] 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}")
print(f"[Online Validate POST] Course found - slug={course.slug} is_online={course.is_online}")
logger.info(f"[Online Validate POST] 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, 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')}")
print(f"[Online Validate POST] Success - metadata={metadata}")
logger.info(f"[Online Validate POST] 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,
@ -572,6 +602,15 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
return metadata
def _build_live_session_context(self, course: Course) -> dict:
"""
Build live session context with real-time PlugNMeet verification.
This method:
1. Finds the latest session for the course
2. Verifies with PlugNMeet if the room is actually active
3. Auto-closes sessions if PlugNMeet reports room is inactive
4. Returns accurate session state independent of webhook delays
"""
latest_session = (
CourseLiveSession.objects.filter(course=course)
.order_by('-started_at', '-id')
@ -579,6 +618,7 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
)
if not latest_session:
logger.debug(f"[Live Session Context] No session found for course={course.slug}")
return {
'is_online': False,
'live_session': None,
@ -591,6 +631,13 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
ended_at = latest_session.ended_at
is_online = bool(started_at and not ended_at)
# CRITICAL: Verify room status with PlugNMeet if session appears online
# This ensures we don't rely solely on webhooks which may fail or be delayed
if is_online and latest_session.room_id:
is_online = self._verify_and_sync_room_status(latest_session)
# Refresh ended_at in case session was closed
ended_at = latest_session.ended_at
live_session_data = {
'id': latest_session.id,
'room_id': latest_session.room_id,
@ -599,7 +646,7 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
'ended_at': self._format_datetime(ended_at),
}
return {
context = {
'is_online': is_online,
'live_session': live_session_data,
'active_room_id': live_session_data['room_id'] if is_online and live_session_data['room_id'] else None,
@ -607,6 +654,81 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
'livesession_ended_at': live_session_data['ended_at'],
}
logger.debug(f"[Live Session Context] course={course.slug} is_online={is_online} room_id={live_session_data['room_id']}")
return context
def _verify_and_sync_room_status(self, session: CourseLiveSession) -> bool:
"""
Verify room status with PlugNMeet and sync local database.
Args:
session: The CourseLiveSession to verify
Returns:
bool: True if room is active, False if inactive or verification failed
Side effects:
- Closes session in database if PlugNMeet reports room is inactive
- Updates LiveSessionUser records accordingly
"""
if not session.room_id:
logger.warning(f"[Room Sync] Session has no room_id - session_id={session.id}")
return False
try:
client = PlugNMeetClient()
response = client.is_room_active(session.room_id)
# Debug: Log full response to understand structure
logger.debug(f"[Room Sync] PlugNMeet response - room_id={session.room_id} response={response}")
# PlugNMeet returns: {"status": true, "msg": "...", "isActive": true/false}
# Note: isActive might be boolean or string, handle both
is_active_raw = response.get('isActive', False)
is_active = is_active_raw if isinstance(is_active_raw, bool) else str(is_active_raw).lower() == 'true'
response_msg = response.get('msg', 'unknown')
response_status = response.get('status', False)
# Additional check: if status is true and msg says "active", trust that
if response_status and 'active' in response_msg.lower() and 'not' not in response_msg.lower():
is_active = True
if is_active:
logger.debug(f"[Room Sync] ✓ Room verified active - room_id={session.room_id} session_id={session.id} msg={response_msg}")
return True
else:
# Room is not active in PlugNMeet but active in our database
# This happens when:
# 1. Webhook failed to fire
# 2. Room was ended externally
# 3. Room crashed or timed out
logger.warning(f"[Room Sync] ✗ Room inactive in PlugNMeet - auto-closing session_id={session.id} room_id={session.room_id} msg={response_msg}")
self._close_live_session(session)
return False
except PlugNMeetError as e:
# PlugNMeet API returned an error
error_msg = str(e)
logger.error(f"[Room Sync] PlugNMeet API error - room_id={session.room_id} session_id={session.id} error={error_msg}")
# Check if error message indicates room doesn't exist
if 'not found' in error_msg.lower() or 'does not exist' in error_msg.lower():
logger.warning(f"[Room Sync] Room not found in PlugNMeet - closing session_id={session.id}")
self._close_live_session(session)
return False
# For other API errors, assume room might still be active (fail-safe)
logger.warning(f"[Room Sync] Cannot verify room status, assuming inactive for safety - room_id={session.room_id}")
return False
except Exception as e:
# Network error or unexpected exception
logger.error(f"[Room Sync] Unexpected error verifying room - room_id={session.room_id} session_id={session.id} error={type(e).__name__}: {str(e)}")
# For network errors, fail-safe: assume room might still be active
# but log a warning for monitoring
logger.warning(f"[Room Sync] Network/system error, assuming room inactive for safety")
return False
@staticmethod
def _user_can_join_live_session(user, course: Course) -> bool:
if not user:
@ -656,8 +778,7 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
# except (PlugNMeetError, Exception) as e:
# logger.warning(f"[Room Sync] Failed to check room status - room_id={active_session.room_id} error={str(e)}")
@staticmethod
def _close_live_session(session: CourseLiveSession):
def _close_live_session(self, session: CourseLiveSession):
"""
Close a live session and all related user entries.
Sets ended_at for session and exited_at/is_online for users.

315
apps/course/views/live_session.py

@ -53,59 +53,94 @@ class CourseLiveSessionRoomCreateAPIView(GenericAPIView):
# 2. Setup ID and Metadata
room_id = f"room-{course.id}-imamjavad"
subject = f"{course.title} Live Session"
metadata = self._build_metadata(subject)
# 3. Use your CLEAN PlugNMeetClient
try:
client = PlugNMeetClient() # Loads keys from settings automatically
# This uses the keys internally to talk to the server
plugnmeet_response = client.create_room({
'room_id': room_id,
'metadata': metadata,
})
# 4. Generate the JOIN TOKEN (The Entry Ticket)
# Users CANNOT enter without this.
token_payload = {
"room_id": room_id,
"user_info": {
"name": f"{request.user.first_name} {request.user.last_name}",
"user_id": str(request.user.id),
"is_admin": True,
"is_hidden": False
}
}
# Check if your client has a method for this, otherwise use manual:
# access_token = client.get_join_token(token_payload)
# If not, we do it manually here using the SAME secret from settings:
# 3. Database Logic - Check FIRST before calling PlugNMeet
# Strategy:
# 1. Try to find active session (ended_at is NULL)
# 2. If not found, try to find ended session with same room_id and reactivate it
# 3. If not found, create new session
session = None
needs_room_creation = False
pnm_token = jwt.encode(
{
"iss": settings.PLUGNMEET_API_KEY,
"exp": int(time.time()) + 3600,
"sub": str(request.user.id),
**token_payload
},
settings.PLUGNMEET_API_SECRET,
algorithm="HS256"
try:
# Try to get active session first
session = CourseLiveSession.objects.get(
course=course, room_id=room_id, ended_at__isnull=True
)
needs_room_creation = False
logger.info(f"[LiveSession Create] Found active session - session_id={session.id} room_id={room_id}")
except CourseLiveSession.DoesNotExist:
# No active session, check if there's an old one with same room_id
try:
session = CourseLiveSession.objects.get(
course=course, room_id=room_id
)
# Reactivate the old session and mark for room recreation
session.ended_at = None
session.started_at = timezone.now()
session.subject = subject
session.save(update_fields=['ended_at', 'started_at', 'subject', 'updated_at'])
needs_room_creation = True
logger.info(f"[LiveSession Create] Reactivated ended session - session_id={session.id} room_id={room_id}")
except CourseLiveSession.DoesNotExist:
# No session exists at all, create new one and mark for room creation
session = CourseLiveSession.objects.create(
course=course,
room_id=room_id,
subject=subject,
started_at=timezone.now()
)
needs_room_creation = True
logger.info(f"[LiveSession Create] Created new session - session_id={session.id} room_id={room_id}")
# 4. Create room in PlugNMeet ONLY if needed
if needs_room_creation:
metadata = self._build_metadata(subject)
try:
client = PlugNMeetClient()
plugnmeet_response = client.create_room({
'room_id': room_id,
'metadata': metadata,
})
logger.info(f"[LiveSession Create] Room created in PlugNMeet - room_id={room_id}")
except Exception as exc:
logger.error(f"[LiveSession Create] PlugNMeet Error: {exc}")
# If room creation fails, revert the session changes
if session.ended_at is None:
session.ended_at = timezone.now()
session.save(update_fields=['ended_at', 'updated_at'])
raise AppAPIException({'message': f'Failed to create room: {str(exc)}'}, status_code=500)
else:
logger.info(f"[LiveSession Create] Skipping room creation - room already exists - room_id={room_id}")
# 5. Generate the JOIN TOKEN (The Entry Ticket)
token_payload = {
"room_id": room_id,
"user_info": {
"name": f"{request.user.first_name} {request.user.last_name}",
"user_id": str(request.user.id),
"is_admin": True,
"is_hidden": False
}
}
except Exception as exc:
logger.error(f"PlugNMeet Error: {exc}")
raise AppAPIException({'message': str(exc)}, status_code=500)
# 5. Database Logic
session, created = CourseLiveSession.objects.get_or_create(
course=course, room_id=room_id,
defaults={'subject': subject, 'started_at': timezone.now()}
pnm_token = jwt.encode(
{
"iss": settings.PLUGNMEET_API_KEY,
"exp": int(time.time()) + 3600,
"sub": str(request.user.id),
**token_payload
},
settings.PLUGNMEET_API_SECRET,
algorithm="HS256"
)
logger.info(f"[LiveSession Create] Success - session_id={session.id} room_id={room_id} user_id={request.user.id}")
return Response({
'success': True,
'session': {'id': session.id, 'room_id': session.room_id},
'access_token': pnm_token # <--- REQUIRED for frontend
'access_token': pnm_token
}, status=201)
@ -295,17 +330,45 @@ class CourseLiveSessionTokenAPIView(GenericAPIView):
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}")
logger.info(f"[LiveSession Token] Active session found in DB - 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
# Check user role first to determine permissions
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}")
# CRITICAL: Verify the room is actually active in PlugNMeet before issuing token
# This prevents issuing tokens for rooms that have crashed or ended without webhook notification
room_id = session.room_id
room_is_active = self._verify_room_is_active(session)
if not room_is_active:
# Room is not active in PlugNMeet but we have a session record
if is_admin:
# For professors: Auto-recreate the room in PlugNMeet
logger.info(f"[LiveSession Token] Room inactive but professor requesting - recreating room - room_id={room_id} session_id={session.id}")
try:
self._recreate_room_in_plugnmeet(course, session)
logger.info(f"[LiveSession Token] Room recreated successfully - room_id={room_id}")
except Exception as e:
logger.error(f"[LiveSession Token] Failed to recreate room - room_id={room_id} error={str(e)}")
raise AppAPIException({
'status': 'False',
'message': f'Failed to recreate room: {str(e)}',
'msg': f'Failed to recreate room: {str(e)}'
}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
# For students: Refuse token - they cannot create rooms
logger.error(f"[LiveSession Token] Room not active and user is student - refusing token - room_id={room_id} user_id={user.id}")
raise AppAPIException({
'status': 'False',
'message': 'room is not active. Please wait for the professor to start the class.',
'msg': 'room is not active. Please wait for the professor to start the class.'
}, status_code=status.HTTP_400_BAD_REQUEST)
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)
@ -368,6 +431,164 @@ class CourseLiveSessionTokenAPIView(GenericAPIView):
'plugnmeet': plugnmeet_response,
})
def _build_metadata(self, subject: str) -> dict:
# Build secured, centralized metadata. Client overrides are NOT allowed.
return {
'room_title': subject,
'default_lock_settings': {
'lock_microphone': True,
'lock_webcam': True,
'lock_screen_sharing': True,
'lock_whiteboard': False,
'lock_shared_notepad': False,
'lock_chat': False,
'lock_chat_send_message': False,
'lock_chat_file_share': False,
'lock_private_chat': False,
},
'room_features': {
'allow_webcams': True,
'mute_on_start': True,
'allow_screen_sharing': True,
'allow_recording': True,
'allow_rtmp': False,
'allow_view_other_webcams': True,
'allow_view_other_participants_list': True,
'admin_only_webcams': False,
'allow_polls': True,
'room_duration': 0,
'chat_features': {
'allow_chat': True,
'allow_file_upload': True,
},
'shared_note_pad_features': {
'allowed_shared_note_pad': True,
},
'whiteboard_features': {
'allowed_whiteboard': True,
},
'breakout_room_features': {
'is_allow': True,
'allowed_number_rooms': 6,
},
'waiting_room_features': {
'is_active': False,
},
'recording_features': {
'is_allow': True,
'is_allow_cloud': True,
'is_allow_local': True,
'enable_auto_cloud_recording': False,
'only_record_admin_webcams': False,
},
},
}
@staticmethod
def _verify_room_is_active(session: CourseLiveSession) -> bool:
"""
Verify that the room is actually active in PlugNMeet.
Args:
session: The CourseLiveSession to verify
Returns:
bool: True if room is active in PlugNMeet, False otherwise
Side effects:
- Closes session in database if PlugNMeet reports room is inactive
"""
if not session.room_id:
logger.warning(f"[Room Verify] Session has no room_id - session_id={session.id}")
return False
try:
client = PlugNMeetClient()
response = client.is_room_active(session.room_id)
# Debug: Log full response
logger.debug(f"[Room Verify] PlugNMeet response - room_id={session.room_id} response={response}")
# Handle isActive as boolean or string
is_active_raw = response.get('isActive', False)
is_active = is_active_raw if isinstance(is_active_raw, bool) else str(is_active_raw).lower() == 'true'
response_msg = response.get('msg', 'unknown')
response_status = response.get('status', False)
# Trust status and msg if they indicate active room
if response_status and 'active' in response_msg.lower() and 'not' not in response_msg.lower():
is_active = True
if is_active:
logger.debug(f"[Room Verify] ✓ Room is active - room_id={session.room_id} session_id={session.id}")
return True
else:
logger.warning(f"[Room Verify] ✗ Room is NOT active - room_id={session.room_id} session_id={session.id} msg={response_msg}")
# Auto-close the session since room is not active
now = timezone.now()
session.ended_at = now
session.save(update_fields=['ended_at', 'updated_at'])
logger.info(f"[Room Verify] Session auto-closed - session_id={session.id} room_id={session.room_id}")
return False
except PlugNMeetError as e:
error_msg = str(e)
logger.error(f"[Room Verify] PlugNMeet API error - room_id={session.room_id} error={error_msg}")
# If room not found, close the session
if 'not found' in error_msg.lower() or 'does not exist' in error_msg.lower():
now = timezone.now()
session.ended_at = now
session.save(update_fields=['ended_at', 'updated_at'])
logger.warning(f"[Room Verify] Room not found - session closed - session_id={session.id}")
return False
except Exception as e:
logger.error(f"[Room Verify] Unexpected error - room_id={session.room_id} error={type(e).__name__}: {str(e)}")
return False
def _recreate_room_in_plugnmeet(self, course: Course, session: CourseLiveSession) -> None:
"""
Recreate a room in PlugNMeet when session exists but room is inactive.
This happens when:
- Webhook failed to notify us of room closure
- PlugNMeet server restarted
- Room was manually ended
Args:
course: The course for which to recreate the room
session: The existing session to reactivate
Raises:
PlugNMeetError: If room creation fails
"""
subject = session.subject or f"{course.title} Live Session"
room_id = session.room_id
metadata = self._build_metadata(subject)
payload = {
'room_id': room_id,
'metadata': metadata,
}
logger.info(f"[Room Recreate] Recreating room in PlugNMeet - room_id={room_id} session_id={session.id}")
try:
client = PlugNMeetClient()
plugnmeet_response = client.create_room(payload)
logger.info(f"[Room Recreate] Room recreated successfully - room_id={room_id} response={plugnmeet_response}")
# Reset session ended_at to mark it as active again
session.ended_at = None
session.save(update_fields=['ended_at', 'updated_at'])
logger.info(f"[Room Recreate] Session reactivated - session_id={session.id}")
except PlugNMeetError as exc:
logger.error(f"[Room Recreate] Failed to recreate room - room_id={room_id} error={str(exc)}")
raise
@staticmethod
def _build_profile_url(request, user):
avatar = getattr(user, 'avatar', None)

5
config/enhanced_auth_middleware.py

@ -6,6 +6,7 @@ from django.contrib import messages
User = get_user_model()
def enhanced_auth_middleware(get_response):
"""
Enhanced middleware for API authentication with admin restriction
@ -50,7 +51,9 @@ def enhanced_auth_middleware(get_response):
if '/swagger-auth/' not in request.path:
# Redirect to admin login for other protected paths
messages.warning(request, 'You must be logged in as a staff member to access API documentation.')
return redirect(f"{reverse('admin:login')}?next={request.path}")
from django.utils.translation import get_language
language = get_language() or 'en'
return redirect(f"/{language}/admin/login/?next={request.path}")
# For non-protected API paths, handle normal authentication
elif "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None:

22
config/middleware/__init__.py

@ -0,0 +1,22 @@
"""
Middleware utilities and helpers
"""
def get_admin_namespace(request):
"""
Determine the admin namespace based on the request domain.
Returns the appropriate admin namespace for use in reverse() calls.
Usage:
from config.middleware import get_admin_namespace
admin_ns = get_admin_namespace(request)
url = reverse(f'{admin_ns}:model_changelist')
"""
host = request.get_host()
# Check if the request is from Dovoodi domain
if 'dovodi' in host or 'dovoodi' in host:
return 'dovoodi_admin'
else:
return 'imam_javad_admin'

32
config/middleware/site_middleware.py

@ -0,0 +1,32 @@
"""
Domain-based URL Configuration Middleware
This middleware detects the request domain and routes to the appropriate
URLconf (URL configuration) for each site:
- Dovoodi domains config.urls_dovoodi
- Imam Javad domains config.urls_imamjavad
"""
class SiteMiddleware:
"""
Middleware to route requests to different URL configurations based on domain.
This allows each domain to have clean /admin/ URLs instead of path-based
differentiation (/imam-javad/admin vs /dovoodi/admin).
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.get_host()
# Check if the request is from Dovoodi domain
if 'dovodi' in host or 'dovoodi' in host:
request.urlconf = 'config.urls_dovoodi'
# Otherwise, use Imam Javad configuration (default)
else:
request.urlconf = 'config.urls_imamjavad'
return self.get_response(request)

1
config/settings/base.py

@ -116,6 +116,7 @@ PHONENUMBER_DEFAULT_FORMAT = 'INTERNATIONAL'
AUTH_USER_MODEL = "account.User"
MIDDLEWARE = [
'config.middleware.site_middleware.SiteMiddleware', # Must be first to route by domain
'django.middleware.security.SecurityMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware',

22
config/urls.py

@ -93,19 +93,18 @@ api_patterns = [
]
# Base URL patterns (common to all domains)
# These patterns are shared by both Imam Javad and Dovoodi sites
urlpatterns = [
path("admin/", HomeView.as_view(), name="home"),
path("admin/", HomeView.as_view(), name="home"), # Redirect to appropriate admin based on domain
path("i18n/", include("django.conf.urls.i18n")),
# path('admin/', admin.site.urls),
path('api/', include(api_patterns)),
# path('test/', include('apps.api.urls'))
path('oneapi-translation/', oneapi_translate),
path('admin/filer/', include('filer.urls')),
path('filer/', include('filer.urls')),
path('filer/', include('filer.urls')),
]
# Protected swagger URL patterns
# Protected swagger URL patterns (to be used in domain-specific configs)
swagger_urlpatterns = [
path('swagger-auth/', SwaggerTokenAuthView.as_view(), name='swagger-token-auth'),
path('swagger-auth/clear/', clear_swagger_auth, name='clear-swagger-auth'),
@ -118,15 +117,6 @@ swagger_urlpatterns = [
name='schema-redoc'),
]
urlpatterns+= i18n_patterns(
path("imam-javad/admin/", project_admin_site.urls),
path("dovoodi/admin/", dovoodi_admin_site.urls ),
path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'),
*swagger_urlpatterns,
path('admin/filer/', include('filer.urls')),
path('filer/', include('filer.urls')),
)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

26
config/urls_dovoodi.py

@ -0,0 +1,26 @@
"""
URL configuration for Dovoodi domain
This configuration is loaded when accessing from dovodi.* or dovoodi.* domains
"""
from django.urls import path, include
from django.conf.urls.i18n import i18n_patterns
from config.urls import urlpatterns as base_urlpatterns, swagger_urlpatterns
from utils.admin import dovoodi_admin_site
from apps.api.views import CustomAPIDocumentationView
# Combine base patterns with Dovoodi specific admin
urlpatterns = base_urlpatterns + i18n_patterns(
# Admin panel accessible at /admin/ (Django will redirect to /en/admin/ or /fa/admin/)
path("admin/", dovoodi_admin_site.urls),
# API documentation
path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'),
# Swagger and API documentation
*swagger_urlpatterns,
# Filer (Django file manager)
path('admin/filer/', include('filer.urls')),
path('filer/', include('filer.urls')),
)

26
config/urls_imamjavad.py

@ -0,0 +1,26 @@
"""
URL configuration for Imam Javad domain
This configuration is loaded when accessing from imamjavad.* domains
"""
from django.urls import path, include
from django.conf.urls.i18n import i18n_patterns
from config.urls import urlpatterns as base_urlpatterns, swagger_urlpatterns
from utils.admin import project_admin_site
from apps.api.views import CustomAPIDocumentationView
# Combine base patterns with Imam Javad specific admin
urlpatterns = base_urlpatterns + i18n_patterns(
# Admin panel accessible at /admin/ (Django will redirect to /en/admin/ or /fa/admin/)
path("admin/", project_admin_site.urls),
# API documentation
path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'),
# Swagger and API documentation
*swagger_urlpatterns,
# Filer (Django file manager)
path('admin/filer/', include('filer.urls')),
path('filer/', include('filer.urls')),
)

19
nginx/dovodi.conf

@ -1,10 +1,10 @@
server {
listen 80;
listen 443 ssl;
server_name dovodi.nwhco.ir;
# ssl_certificate /etc/nginx/certs/nwhco.pem;
# ssl_certificate_key /etc/nginx/certs/nwhco.key;
# include /etc/nginx/options-ssl-nginx.conf;
server_name dovodi.nwhco.ir dovodi.newhorizonco.uk;
ssl_certificate /etc/nginx/certs/nwhco.pem;
ssl_certificate_key /etc/nginx/certs/nwhco.key;
include /etc/nginx/options-ssl-nginx.conf;
client_max_body_size 500M;
client_body_timeout 600s;
@ -15,17 +15,6 @@ server {
# ========== Django Admin Paths (باید قبل از location / باشند) ==========
# Add this inside the 'server' block of the existing config
location /agent/ {
proxy_pass http://88.99.212.243:8098;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# با prefix زبانی
location /en/dovoodi/ {
proxy_pass http://88.99.212.243:8010;

223
nginx/imamjavad.conf

@ -1,15 +1,38 @@
# ========================================
# Imam Javad Platform - Nginx Configuration
# Single Domain - Clean Architecture
# ========================================
# ========== Upstream Definitions ==========
upstream django_backend {
server 88.99.212.243:8010;
keepalive 32;
}
upstream nextjs_frontend {
server 88.99.212.243:7226;
keepalive 32;
}
upstream chat_service {
server 88.99.212.243:8020;
keepalive 32;
}
# ========== Proxy Settings (Reusable) ==========
# Include this file or copy these settings
# /etc/nginx/snippets/proxy-params.conf
server {
listen 80;
<<<<<<< HEAD
# listen 443 ssl;
=======
listen 443 ssl;
>>>>>>> a1b3ddf (Add Nginx configuration files for dovoodi and imamjavad services)
server_name imamjavad.nwhco.ir imamjavad.newhorizonco.uk;
# ssl_certificate /etc/nginx/certs/nwhco.pem;
# ssl_certificate_key /etc/nginx/certs/nwhco.key;
# include /etc/nginx/options-ssl-nginx.conf;
# ========== Global Settings ==========
client_max_body_size 500M;
client_body_timeout 600s;
client_header_timeout 60s;
@ -17,167 +40,141 @@ server {
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# ========== Django Admin Paths (باید قبل از location / باشند) ==========
# ========== 1. Static Files (Highest Priority) ==========
location /static/ {
alias /home/app/web/imam_javad_staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# با prefix زبانی
location /en/imam-javad/ {
proxy_pass http://88.99.212.243:8010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location /media/ {
alias /home/app/web/imam_javad_mediafiles/;
expires 30d;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
access_log off;
}
# ========== 2. Next.js Assets ==========
location /_next/ {
proxy_pass http://nextjs_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 1200M;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location /fa/imam-javad/ {
proxy_pass http://88.99.212.243:8010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# ========== 3. API Endpoints ==========
location /api/ {
proxy_pass http://django_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
client_max_body_size 1200M;
}
# بدون prefix زبانی (fallback)
location /imam-javad/ {
proxy_pass http://88.99.212.243:8010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# ========== 4. Chat Service (WebSocket) ==========
location /chat {
proxy_pass http://chat_service;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 1200M;
}
# ========== Admin Entry Points ==========
location = /admin {
return 301 /admin/;
# WebSocket timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /admin/ {
proxy_pass http://88.99.212.243:8010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# ========== 5. i18n Language Switcher ==========
location /i18n/ {
proxy_pass http://django_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 1200M;
}
location = /admin/logout {
return 301 /en/imam-javad/admin/logout/;
}
# مسیر عمومی admin (سازگاری با config قدیمی)
location /en/admin {
proxy_pass http://88.99.212.243:8010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# ========== 6. Django Admin Panel (Single Pattern) ==========
# Matches: /en/imam-javad/admin/, /fa/imam-javad/admin/, /imam-javad/admin/
location ~ ^/(en|fa)?/?imam-javad/ {
proxy_pass http://django_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 1200M;
}
# ========== Django Services ==========
location /api {
proxy_pass http://88.99.212.243:8010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# ========== 7. Dovoodi Admin Panel (Single Pattern) ==========
# Matches: /en/dovoodi/admin/, /fa/dovoodi/admin/, /dovoodi/admin/
location ~ ^/(en|fa)?/?dovoodi/ {
proxy_pass http://django_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_method $request_method;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
<<<<<<< HEAD
# 🔥 CRITICAL: Forward Authorization header for Token authentication
=======
>>>>>>> a1b3ddf (Add Nginx configuration files for dovoodi and imamjavad services)
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
client_max_body_size 1200M;
}
location /en/swagger {
proxy_pass http://88.99.212.243:8010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# ========== 8. Swagger & API Documentation ==========
# Matches: /en/swagger/, /fa/swagger/, /en/redoc/, /fa/redoc/, /en/docs/
location ~ ^/(en|fa)/(swagger|redoc|docs) {
proxy_pass http://django_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
}
location /en/redoc {
proxy_pass http://88.99.212.243:8010;
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
}
location /i18n/ {
proxy_pass http://88.99.212.243:8010;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
# ========== Other Services ==========
location /chat {
proxy_pass http://88.99.212.243:8020;
client_max_body_size 1200M;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# ========== 9. Filer (Django File Manager) ==========
location /filer/ {
proxy_pass http://django_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 1200M;
}
<<<<<<< HEAD
location /static/ {
alias /home/app/web/imam_javad_static/static/;
}
location /media/ {
alias /home/app/web/imam_javad_static/media/;
=======
location /static/ {
alias /home/app/web/imam_javad_staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /media/{
alias /home/app/web/imam_javad_mediafiles/;
expires 30d;
add_header Cache-Control "public, immutable";
# Enable CORS for media files
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept";
>>>>>>> a1b3ddf (Add Nginx configuration files for dovoodi and imamjavad services)
# ========== 10. Admin Entry Point Redirect ==========
# /admin → /en/imam-javad/admin/
location = /admin {
return 301 /en/imam-javad/admin/;
}
# ========== Next.js Frontend ==========
location /_next/ {
proxy_pass http://88.99.212.243:7226/_next/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 1y;
add_header Cache-Control "public, immutable";
location = /admin/ {
return 301 /en/imam-javad/admin/;
}
<<<<<<< HEAD
# ⚠️ این باید آخرین location باشد (fallback)
=======
>>>>>>> a1b3ddf (Add Nginx configuration files for dovoodi and imamjavad services)
# ========== 11. Next.js Frontend (Catch-all - Lowest Priority) ==========
location / {
proxy_pass http://88.99.212.243:7226;
client_max_body_size 1200M;
proxy_pass http://nextjs_frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

21
utils/admin.py

@ -130,7 +130,7 @@ class FormulaAdminSite(UnfoldAdminSite):
},
{
"title": _("Dovoodi Admin"),
"link": "https://dovodi.newhorizonco.uk/en/dovoodi/admin/",
"link": "https://dovodi.newhorizonco.uk/admin/",
"icon": "diamond",
}
]
@ -236,7 +236,7 @@ class DovoodiAdminSite(UnfoldAdminSite):
},
{
"title": _("Imam Javad Admin"),
"link": "https://imamjavad.newhorizonco.uk/en/imam-javad/admin/",
"link": "https://imamjavad.newhorizonco.uk/admin/",
"icon": "diamond",
}
]
@ -464,20 +464,17 @@ def replace_placeholders_with_real_sites():
# This ensures proper CSS loading for admin templates
class HomeView(RedirectView):
"""
Redirects /admin/ to the language-prefixed admin URL.
The domain-based routing middleware will handle which admin site to use.
"""
def get_redirect_url(self, *args, **kwargs):
host = self.request.get_host()
# دریافت زبان فعلی (پیش‌فرض: en)
language = get_language() or 'en'
# دامنه‌های داوودی
dovoodi_domains = ['dovodi.newhorizonco.uk', 'dovoodi.newhorizonco.uk']
# تصمیم‌گیری بر اساس دامنه و برگرداندن URL با prefix زبانی
if any(domain in host for domain in dovoodi_domains):
return f'/{language}/dovoodi/admin/'
else:
return f'/{language}/imam-javad/admin/'
# Now we simply redirect to /language/admin/
# The SiteMiddleware will route to the correct admin based on domain
return f'/{language}/admin/'
# ---------------------------------------------------------
# 4. Dummy Data for Dashboard Charts

46
utils/redis.py

@ -2,6 +2,7 @@ import json
import hashlib
import random
import secrets
import logging
from datetime import datetime, timedelta
from typing import Optional
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
@ -13,6 +14,8 @@ from django.conf import settings
from config.redis_config import RedisConfig
from utils.exceptions import ServiceUnavailableException, NotFoundException
logger = logging.getLogger(__name__)
class RedisManager(RedisConfig):
def __serialize(self, code, fullname, password):
@ -91,23 +94,52 @@ class OnlineClassTokenManager(RedisConfig):
def generate_token(self, course_id: int, user_identifier: str) -> str:
seed = f"{course_id}:{user_identifier}:{secrets.token_urlsafe(16)}"
return hashlib.sha256(seed.encode()).hexdigest()
token = hashlib.sha256(seed.encode()).hexdigest()
logger.info(f"[OnlineClassToken] Token generated - course_id={course_id} user={user_identifier} token={token[:16]}...")
return token
def store_token(self, token: str, payload: dict, ttl: Optional[int] = None) -> None:
data = {
**payload,
"generated_at": datetime.utcnow().isoformat() + "Z",
}
self.redis.set(self._build_key(token), json.dumps(data), ex=ttl or self.ttl)
key = self._build_key(token)
ttl_value = ttl or self.ttl
logger.info(f"[OnlineClassToken] Storing token - key={key} ttl={ttl_value}s payload={payload}")
try:
self.redis.set(key, json.dumps(data), ex=ttl_value)
logger.info(f"[OnlineClassToken] Token stored successfully - key={key}")
except RedisError as e:
logger.error(f"[OnlineClassToken] Failed to store token - key={key} error={str(e)}")
raise
def get_payload(self, token: str) -> dict:
stored = self.redis.get(self._build_key(token))
if not stored:
raise NotFoundException("Token not found or has expired.")
return json.loads(stored)
key = self._build_key(token)
logger.info(f"[OnlineClassToken] Retrieving token - key={key} token={token[:16]}...")
try:
stored = self.redis.get(key)
if not stored:
logger.warning(f"[OnlineClassToken] Token not found or expired - key={key}")
raise NotFoundException("Token not found or has expired.")
payload = json.loads(stored)
logger.info(f"[OnlineClassToken] Token retrieved successfully - key={key} payload={payload}")
return payload
except RedisError as e:
logger.error(f"[OnlineClassToken] Redis error retrieving token - key={key} error={str(e)}")
raise
except json.JSONDecodeError as e:
logger.error(f"[OnlineClassToken] Invalid JSON in stored token - key={key} error={str(e)}")
raise NotFoundException("Invalid token data.")
def delete_token(self, token: str) -> None:
self.redis.delete(self._build_key(token))
key = self._build_key(token)
logger.info(f"[OnlineClassToken] Deleting token - key={key}")
try:
result = self.redis.delete(key)
logger.info(f"[OnlineClassToken] Token deleted - key={key} deleted={result}")
except RedisError as e:
logger.error(f"[OnlineClassToken] Failed to delete token - key={key} error={str(e)}")
raise
@staticmethod
def build_entry_url(token: str, base_url: Optional[str] = None) -> str:

Loading…
Cancel
Save