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. 267
      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. 20
      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. 42
      utils/redis.py

10
apps/account/middleware/admin_access.py

@ -94,7 +94,10 @@ class AdminAccessMiddleware:
def handle_restricted_access(self, request): def handle_restricted_access(self, request):
"""مدیریت دسترسی محدود شده""" """مدیریت دسترسی محدود شده"""
if not request.user.is_authenticated: 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 می‌ماند # اگر کاربر استاد است، در همان admin panel می‌ماند
if request.user.is_authenticated and request.user.has_role('professor'): if request.user.is_authenticated and request.user.has_role('professor'):
@ -111,4 +114,7 @@ class AdminAccessMiddleware:
request, request,
_('You do not have permission to access this page.') _('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'] read_only_fields = ['email', 'info', 'skill', 'device_id', 'slug', 'saved_location']
def get_saved_location(self, obj): 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() last_location = obj.location_history.order_by('-at_time').first()
if last_location: if last_location:
return { 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 import openapi
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from rest_framework import status from rest_framework import status
from rest_framework.authentication import TokenAuthentication
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
@ -415,14 +416,12 @@ class CourseOnlineClassTokenAPIView(GenericAPIView):
class CourseOnlineClassTokenValidateAPIView(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] permission_classes = [AllowAny]
serializer_class = OnlineClassTokenVerifySerializer serializer_class = OnlineClassTokenVerifySerializer
def get_permissions(self):
if self.request.method == 'GET':
return [IsAuthenticated()]
return [AllowAny()]
@swagger_auto_schema( @swagger_auto_schema(
tags=['Imam-Javad - Course'], tags=['Imam-Javad - Course'],
operation_description="Get course and user data for authenticated user.", operation_description="Get course and user data for authenticated user.",
@ -453,11 +452,24 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
} }
) )
def get(self, request, slug, *args, **kwargs): 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() detail_view = CourseDetailAPIView()
queryset = detail_view.get_queryset() queryset = detail_view.get_queryset()
course = get_object_or_404(queryset, slug=slug) course = get_object_or_404(queryset, slug=slug)
user = request.user 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 # DEPRECATED: Polling approach replaced by webhook integration
# Room status is now updated automatically via PlugNMeet webhooks # Room status is now updated automatically via PlugNMeet webhooks
# self._sync_room_status_with_plugnmeet(course) # self._sync_room_status_with_plugnmeet(course)
@ -466,10 +478,13 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
user_data = UserProfileSerializer(user, context={'request': request}).data user_data = UserProfileSerializer(user, context={'request': request}).data
metadata = self._build_metadata( metadata = self._build_metadata(
course, 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, 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({ return Response({
'course': course_data, 'course': course_data,
'user': user_data, 'user': user_data,
@ -499,41 +514,56 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
} }
) )
def post(self, request, *args, **kwargs): 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 = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
token_value = serializer.validated_data['token'] 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() manager = OnlineClassTokenManager()
try: try:
payload = manager.get_payload(token_value) 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: 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 raise
course_id = payload.get('course_id') course_id = payload.get('course_id')
user_id = payload.get('user_id') user_id = payload.get('user_id')
if not course_id or not 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) 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() detail_view = CourseDetailAPIView()
queryset = detail_view.get_queryset() queryset = detail_view.get_queryset()
course = get_object_or_404(queryset, pk=course_id) course = get_object_or_404(queryset, pk=course_id)
user = get_object_or_404(UserModel.objects.all(), pk=user_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 course_data = CourseDetailSerializer(course, context={'request': request}).data
user_data = UserProfileSerializer(user, context={'request': request}).data user_data = UserProfileSerializer(user, context={'request': request}).data
metadata = self._build_metadata(course, payload, user=user) 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({ return Response({
'course': course_data, 'course': course_data,
@ -572,6 +602,15 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
return metadata return metadata
def _build_live_session_context(self, course: Course) -> dict: 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 = ( latest_session = (
CourseLiveSession.objects.filter(course=course) CourseLiveSession.objects.filter(course=course)
.order_by('-started_at', '-id') .order_by('-started_at', '-id')
@ -579,6 +618,7 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
) )
if not latest_session: if not latest_session:
logger.debug(f"[Live Session Context] No session found for course={course.slug}")
return { return {
'is_online': False, 'is_online': False,
'live_session': None, 'live_session': None,
@ -591,6 +631,13 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
ended_at = latest_session.ended_at ended_at = latest_session.ended_at
is_online = bool(started_at and not 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 = { live_session_data = {
'id': latest_session.id, 'id': latest_session.id,
'room_id': latest_session.room_id, 'room_id': latest_session.room_id,
@ -599,7 +646,7 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
'ended_at': self._format_datetime(ended_at), 'ended_at': self._format_datetime(ended_at),
} }
return {
context = {
'is_online': is_online, 'is_online': is_online,
'live_session': live_session_data, 'live_session': live_session_data,
'active_room_id': live_session_data['room_id'] if is_online and live_session_data['room_id'] else None, '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'], '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 @staticmethod
def _user_can_join_live_session(user, course: Course) -> bool: def _user_can_join_live_session(user, course: Course) -> bool:
if not user: if not user:
@ -656,8 +778,7 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
# except (PlugNMeetError, Exception) as e: # except (PlugNMeetError, Exception) as e:
# logger.warning(f"[Room Sync] Failed to check room status - room_id={active_session.room_id} error={str(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. Close a live session and all related user entries.
Sets ended_at for session and exited_at/is_online for users. Sets ended_at for session and exited_at/is_online for users.

267
apps/course/views/live_session.py

@ -53,20 +53,67 @@ class CourseLiveSessionRoomCreateAPIView(GenericAPIView):
# 2. Setup ID and Metadata # 2. Setup ID and Metadata
room_id = f"room-{course.id}-imamjavad" room_id = f"room-{course.id}-imamjavad"
subject = f"{course.title} Live Session" subject = f"{course.title} Live Session"
metadata = self._build_metadata(subject)
# 3. Use your CLEAN PlugNMeetClient
# 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
try: try:
client = PlugNMeetClient() # Loads keys from settings automatically
# 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}")
# This uses the keys internally to talk to the server
# 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({ plugnmeet_response = client.create_room({
'room_id': room_id, 'room_id': room_id,
'metadata': metadata, '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}")
# 4. Generate the JOIN TOKEN (The Entry Ticket)
# Users CANNOT enter without this.
# 5. Generate the JOIN TOKEN (The Entry Ticket)
token_payload = { token_payload = {
"room_id": room_id, "room_id": room_id,
"user_info": { "user_info": {
@ -77,10 +124,6 @@ class CourseLiveSessionRoomCreateAPIView(GenericAPIView):
} }
} }
# 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:
pnm_token = jwt.encode( pnm_token = jwt.encode(
{ {
"iss": settings.PLUGNMEET_API_KEY, "iss": settings.PLUGNMEET_API_KEY,
@ -92,20 +135,12 @@ class CourseLiveSessionRoomCreateAPIView(GenericAPIView):
algorithm="HS256" algorithm="HS256"
) )
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()}
)
logger.info(f"[LiveSession Create] Success - session_id={session.id} room_id={room_id} user_id={request.user.id}")
return Response({ return Response({
'success': True, 'success': True,
'session': {'id': session.id, 'room_id': session.room_id}, 'session': {'id': session.id, 'room_id': session.room_id},
'access_token': pnm_token # <--- REQUIRED for frontend
'access_token': pnm_token
}, status=201) }, status=201)
@ -295,17 +330,45 @@ class CourseLiveSessionTokenAPIView(GenericAPIView):
course=course, course=course,
ended_at__isnull=True 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: except CourseLiveSession.DoesNotExist:
logger.warning(f"[LiveSession Token] No active session found - course={course_slug} user_id={user.id}") 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) 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) is_admin = user.can_manage_course(course)
user_role = "professor" if is_admin else "student" 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}") 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(): 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}") 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) 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, '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 @staticmethod
def _build_profile_url(request, user): def _build_profile_url(request, user):
avatar = getattr(user, 'avatar', None) avatar = getattr(user, 'avatar', None)

5
config/enhanced_auth_middleware.py

@ -6,6 +6,7 @@ from django.contrib import messages
User = get_user_model() User = get_user_model()
def enhanced_auth_middleware(get_response): def enhanced_auth_middleware(get_response):
""" """
Enhanced middleware for API authentication with admin restriction 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: if '/swagger-auth/' not in request.path:
# Redirect to admin login for other protected paths # Redirect to admin login for other protected paths
messages.warning(request, 'You must be logged in as a staff member to access API documentation.') 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 # For non-protected API paths, handle normal authentication
elif "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: 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" AUTH_USER_MODEL = "account.User"
MIDDLEWARE = [ MIDDLEWARE = [
'config.middleware.site_middleware.SiteMiddleware', # Must be first to route by domain
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',

20
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 = [ 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("i18n/", include("django.conf.urls.i18n")),
# path('admin/', admin.site.urls),
path('api/', include(api_patterns)), path('api/', include(api_patterns)),
# path('test/', include('apps.api.urls'))
path('oneapi-translation/', oneapi_translate), path('oneapi-translation/', oneapi_translate),
path('admin/filer/', include('filer.urls')), 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 = [ swagger_urlpatterns = [
path('swagger-auth/', SwaggerTokenAuthView.as_view(), name='swagger-token-auth'), path('swagger-auth/', SwaggerTokenAuthView.as_view(), name='swagger-token-auth'),
path('swagger-auth/clear/', clear_swagger_auth, name='clear-swagger-auth'), path('swagger-auth/clear/', clear_swagger_auth, name='clear-swagger-auth'),
@ -118,15 +117,6 @@ swagger_urlpatterns = [
name='schema-redoc'), 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: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 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 { server {
listen 80; listen 80;
listen 443 ssl; 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_max_body_size 500M;
client_body_timeout 600s; client_body_timeout 600s;
@ -15,17 +15,6 @@ server {
# ========== Django Admin Paths (باید قبل از location / باشند) ========== # ========== 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 زبانی # با prefix زبانی
location /en/dovoodi/ { location /en/dovoodi/ {
proxy_pass http://88.99.212.243:8010; 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 { server {
listen 80; listen 80;
<<<<<<< HEAD
# listen 443 ssl;
=======
listen 443 ssl; listen 443 ssl;
>>>>>>> a1b3ddf (Add Nginx configuration files for dovoodi and imamjavad services)
server_name imamjavad.nwhco.ir imamjavad.newhorizonco.uk; server_name imamjavad.nwhco.ir imamjavad.newhorizonco.uk;
# ssl_certificate /etc/nginx/certs/nwhco.pem; # ssl_certificate /etc/nginx/certs/nwhco.pem;
# ssl_certificate_key /etc/nginx/certs/nwhco.key; # ssl_certificate_key /etc/nginx/certs/nwhco.key;
# include /etc/nginx/options-ssl-nginx.conf; # include /etc/nginx/options-ssl-nginx.conf;
# ========== Global Settings ==========
client_max_body_size 500M; client_max_body_size 500M;
client_body_timeout 600s; client_body_timeout 600s;
client_header_timeout 60s; client_header_timeout 60s;
@ -17,167 +40,141 @@ server {
proxy_send_timeout 600s; proxy_send_timeout 600s;
proxy_read_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 Host $host;
proxy_set_header X-Real-IP $remote_addr; 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 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; 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 X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
client_max_body_size 1200M; 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; 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; 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; 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 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 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; 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_set_header Host $host;
proxy_method $request_method;
proxy_set_header X-Real-IP $remote_addr; 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 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; 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 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_set_header Authorization $http_authorization;
proxy_pass_header 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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 / { 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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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"), "title": _("Dovoodi Admin"),
"link": "https://dovodi.newhorizonco.uk/en/dovoodi/admin/",
"link": "https://dovodi.newhorizonco.uk/admin/",
"icon": "diamond", "icon": "diamond",
} }
] ]
@ -236,7 +236,7 @@ class DovoodiAdminSite(UnfoldAdminSite):
}, },
{ {
"title": _("Imam Javad Admin"), "title": _("Imam Javad Admin"),
"link": "https://imamjavad.newhorizonco.uk/en/imam-javad/admin/",
"link": "https://imamjavad.newhorizonco.uk/admin/",
"icon": "diamond", "icon": "diamond",
} }
] ]
@ -464,20 +464,17 @@ def replace_placeholders_with_real_sites():
# This ensures proper CSS loading for admin templates # This ensures proper CSS loading for admin templates
class HomeView(RedirectView): 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): def get_redirect_url(self, *args, **kwargs):
host = self.request.get_host()
# دریافت زبان فعلی (پیش‌فرض: en) # دریافت زبان فعلی (پیش‌فرض: en)
language = get_language() or '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 # 4. Dummy Data for Dashboard Charts

42
utils/redis.py

@ -2,6 +2,7 @@ import json
import hashlib import hashlib
import random import random
import secrets import secrets
import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse 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 config.redis_config import RedisConfig
from utils.exceptions import ServiceUnavailableException, NotFoundException from utils.exceptions import ServiceUnavailableException, NotFoundException
logger = logging.getLogger(__name__)
class RedisManager(RedisConfig): class RedisManager(RedisConfig):
def __serialize(self, code, fullname, password): def __serialize(self, code, fullname, password):
@ -91,23 +94,52 @@ class OnlineClassTokenManager(RedisConfig):
def generate_token(self, course_id: int, user_identifier: str) -> str: def generate_token(self, course_id: int, user_identifier: str) -> str:
seed = f"{course_id}:{user_identifier}:{secrets.token_urlsafe(16)}" 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: def store_token(self, token: str, payload: dict, ttl: Optional[int] = None) -> None:
data = { data = {
**payload, **payload,
"generated_at": datetime.utcnow().isoformat() + "Z", "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: def get_payload(self, token: str) -> dict:
stored = self.redis.get(self._build_key(token))
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: if not stored:
logger.warning(f"[OnlineClassToken] Token not found or expired - key={key}")
raise NotFoundException("Token not found or has expired.") raise NotFoundException("Token not found or has expired.")
return json.loads(stored)
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: 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 @staticmethod
def build_entry_url(token: str, base_url: Optional[str] = None) -> str: def build_entry_url(token: str, base_url: Optional[str] = None) -> str:

Loading…
Cancel
Save