diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py
index 9a62d4f..f621fc4 100644
--- a/apps/account/admin/user.py
+++ b/apps/account/admin/user.py
@@ -17,11 +17,10 @@ from unfold.sections import TableSection
from unfold.contrib.filters.admin import RangeDateTimeFilter
# Import Models
-from apps.account.models import User, ClientUser, StudentUser, ProfessorUser, LocationHistory
-from apps.course.models import Participant
+from apps.account.models import User, ClientUser, LocationHistory
# Import Admin Sites from utils
-from utils.admin import project_admin_site, dovoodi_admin_site , is_dovoodi_panel
+from utils.admin import dovoodi_admin_site , is_dovoodi_panel
from apps.account.admin.location import LocationHistoryInline
from unfold.widgets import UnfoldAdminSelectWidget
@@ -198,262 +197,7 @@ class GuestUserAdmin(UserAdmin):
return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-"
-class StudentParticipantInline(StackedInline):
- """Inline to show courses a student has joined"""
- model = Participant
- extra = 0
- readonly_fields = ('course', 'joined_date', 'course_status', 'course_professor')
- fields = ('course', 'course_status', 'course_professor', 'joined_date', 'is_active')
- verbose_name = _('Course Participation')
- verbose_name_plural = _('Course Participations')
- autocomplete_fields = ['course']
- tab = True
-
- def get_queryset(self, request):
- qs = super().get_queryset(request)
- return qs.select_related('course').prefetch_related('course__professors')
-
- @admin.display(description=_('Course Status'))
- def course_status(self, obj):
- if obj.course:
- return obj.course.get_status_display()
- return '-'
-
- @admin.display(description=_('Professor'))
- def course_professor(self, obj):
- if obj.course:
- professors = obj.course.professors.all()
- if professors:
- return ", ".join(p.fullname or p.email for p in professors)
- return '-'
-
- def has_add_permission(self, request, obj=None):
- return True
- def has_change_permission(self, request, obj=None):
- return True
- def has_delete_permission(self, request, obj=None):
- return True
-
-
-class StudentUserAdmin(UserAdmin):
- form = UserAdminChangeForm
- add_form = UserAdminCreationForm
- list_display = ('display_header', 'email', 'gender', 'display_age', 'courses_count')
-
- add_fieldsets = (
- (None, {
- 'classes': ('wide',),
- 'fields': (('fullname', 'email'), 'phone_number', 'avatar', 'birthdate', 'gender'),
- }),
- (_('Location'), {
- 'fields': (('city', 'country'),),
- 'classes': ('collapse',),
- }),
- (_('password'), {
- 'fields': ('password1', 'password2',),
- 'classes': ('collapse',),
- }),
- )
- inlines = [StudentParticipantInline, LocationHistoryInline]
-
- @display(description=_("Student"), header=True)
- def display_header(self, instance: StudentUser):
- avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png")
- return [
- instance.fullname,
- None,
- None,
- {
- "path": avatar_path,
- "height": 30,
- "width": 36,
- "borderless": True,
- },
- ]
-
- @display(description=_("Age"))
- def display_age(self, instance: StudentUser):
- from datetime import date
- if not instance.birthdate:
- return "-"
- today = date.today()
- birthdate = instance.birthdate
- age = today.year - birthdate.year - ((today.month, today.day) < (birthdate.month, birthdate.day))
- formatted_date = birthdate.strftime("%Y-%m-%d")
- return format_html('{}', _("Born on {date}").format(date=formatted_date), age)
-
- @display(description=_("Courses"), dropdown=True)
- def courses_count(self, instance: StudentUser):
- total = instance.participated_courses.count()
- items = []
- for participant in instance.participated_courses.all():
- course = participant.course
- title = format_html(
- """
-
- """,
- course.title,
- course.id
- )
- items.append({"title": title})
-
- if total == 0:
- return "-"
-
- return {
- "title": ngettext("{total} course", "{total} courses", total).format(total=total),
- "items": items,
- "striped": True,
- }
-
-
- def get_queryset(self, request):
- return super().get_queryset(request).prefetch_related(
- "participated_courses",
- "participated_courses__course",
- )
-
-
-class CourseTableSection(TableSection):
- verbose_name = _("Course Categories")
- related_name = "courses"
- height = 380
- fields = ["title", "status", "edit_link"]
-
- def edit_link(self, instance):
- return format_html(
- ''
- 'visibility'
- '',
- instance.id
- )
- edit_link.short_description = _("Edit")
-
-class ProfessorUpgradeForm(forms.ModelForm):
- existing_user = forms.ModelChoiceField(
- queryset=User.objects.filter(is_active=True, email__isnull=False).exclude(groups__name="Professor Group"),
- required=True,
- label=_("Select Existing User"),
- help_text=_("Choose an existing user to upgrade to Professor."),
- widget=UnfoldAdminSelectWidget,
- )
-
- class Meta:
- model = ProfessorUser
- fields = ("existing_user", "is_active", "is_staff", "is_superuser", "groups")
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- if 'groups' in self.fields:
- self.fields['groups'].required = False
-
- def _post_clean(self):
- # جلوگیری از اعتبارسنجی مدل خالی برای جلوگیری از ارور فیلدهای اجباری
- pass
-
- def save(self, commit=True):
- # کاربر موجود (که هنوز پروفسور نیست) را میگیریم
- user = self.cleaned_data.get('existing_user')
-
- # ابتدا user_type را تغییر میدهیم تا با Manager پروفسور سازگار شود
- user.user_type = User.UserType.PROFESSOR
- user.is_active = self.cleaned_data.get('is_active', user.is_active)
- user.is_staff = self.cleaned_data.get('is_staff', user.is_staff)
- user.is_superuser = self.cleaned_data.get('is_superuser', user.is_superuser)
- user.save() # ذخیره با مدل User
-
- # حالا که user_type آپدیت شد، میتوانیم آن را به عنوان ProfessorUser واکشی کنیم
- prof_user = ProfessorUser.objects.get(pk=user.pk)
-
- # برای ذخیرهسازی ManyToMany (مثل groups)، باید instance فرم ست شود
- self.instance = prof_user
-
- def save_m2m():
- groups = self.cleaned_data.get('groups')
- if groups is not None:
- self.instance.groups.set(groups)
- # اضافهکردن کاربر به گروه پروفسورها و ساخت اسلاگ (در صورت نیاز)
- self.instance.ensure_professor_profile(commit=True)
-
- self.save_m2m = save_m2m
-
- if commit:
- self.save_m2m()
-
- return prof_user
-
-
-class ProfessorUserAdmin(UserAdmin):
- form = UserAdminChangeForm
- add_form = ProfessorUpgradeForm # <--- آپدیت شد به فرم ارتقا
- list_display = ('display_header', 'email', 'courses_count')
- list_sections = [CourseTableSection]
- save_as = True
-
- # بازنویسی کامل فیلدستهای صفحه Add (ساخت)
- add_fieldsets = (
- (None, {
- 'classes': ('wide',),
- 'fields': ('existing_user',),
- }),
- (_('Permissions'), {
- 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'),
- 'classes': ('wide',),
- }),
- )
-
- @display(description=_("Professor"), header=True)
- def display_header(self, instance: ProfessorUser):
- avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png")
- return [
- instance.fullname,
- None,
- None,
- {
- "path": avatar_path,
- "height": 30,
- "width": 50,
- "borderless": True,
- "squared": True,
- },
- ]
-
- @display(description=_("Courses"), dropdown=True)
- def courses_count(self, instance: ProfessorUser):
- total = instance.courses.count()
- items = []
- for course in instance.courses.all():
- title = format_html(
- """
-
- """,
- course.title,
- course.id
- )
- items.append({"title": title})
-
- if total == 0:
- return "-"
-
- return {
- "title": ngettext("{total} course", "{total} courses", total).format(total=total),
- "items": items,
- "striped": True,
- }
-
-
- def get_queryset(self, request):
- return super().get_queryset(request).prefetch_related("courses")
+# Student and Professor admins removed
class GroupAdmin(BaseGroupAdmin, ModelAdmin):
@@ -489,13 +233,6 @@ try:
except admin.sites.AlreadyRegistered:
pass
-# B. PROJECT ADMIN SITE (Imam Javad)
-project_admin_site.register(User, UserAdmin)
-project_admin_site.register(ClientUser, GuestUserAdmin)
-project_admin_site.register(StudentUser, StudentUserAdmin)
-project_admin_site.register(ProfessorUser, ProfessorUserAdmin)
-project_admin_site.register(Group, GroupAdmin)
-
# C. DOVOODI ADMIN SITE
dovoodi_admin_site.register(User, UserAdmin)
dovoodi_admin_site.register(ClientUser, GuestUserAdmin)
diff --git a/apps/account/management/commands/migrate_user_roles.py b/apps/account/management/commands/migrate_user_roles.py
deleted file mode 100644
index f5c49f7..0000000
--- a/apps/account/management/commands/migrate_user_roles.py
+++ /dev/null
@@ -1,158 +0,0 @@
-"""
-Management command برای migration دادههای موجود به سیستم نقشهای چندگانه
-"""
-from django.core.management.base import BaseCommand
-from django.contrib.auth.models import Group
-from apps.account.models import User
-from apps.course.models import Course, Participant
-
-
-class Command(BaseCommand):
- help = 'Migrate existing user data to multiple roles system'
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--dry-run',
- action='store_true',
- help='Show what would be done without making changes',
- )
-
- def handle(self, *args, **options):
- dry_run = options['dry_run']
-
- if dry_run:
- self.stdout.write(
- self.style.WARNING('DRY RUN MODE - No changes will be made')
- )
-
- # اطمینان از وجود گروهها
- self.ensure_groups_exist(dry_run)
-
- # Migration کاربران بر اساس user_type فعلی
- self.migrate_user_types(dry_run)
-
- # Migration کاربرانی که هم استاد و هم دانشآموز هستند
- self.migrate_professor_students(dry_run)
-
- self.stdout.write(
- self.style.SUCCESS('Migration completed successfully!')
- )
-
- def ensure_groups_exist(self, dry_run):
- """اطمینان از وجود گروههای مورد نیاز"""
- groups = [
- "Professor Group",
- "Student Group",
- "Client Group",
- "Admin Group",
- "Super Admin Group"
- ]
-
- for group_name in groups:
- if dry_run:
- exists = Group.objects.filter(name=group_name).exists()
- if not exists:
- self.stdout.write(f'Would create group: {group_name}')
- else:
- group, created = Group.objects.get_or_create(name=group_name)
- if created:
- self.stdout.write(f'Created group: {group_name}')
-
- def migrate_user_types(self, dry_run):
- """Migration کاربران بر اساس user_type فعلی"""
- users = User.objects.all()
-
- for user in users:
- # چک کنیم که آیا کاربر قبلاً در گروه مناسب است یا خیر
- expected_group_name = f"{user.user_type.capitalize()} Group"
-
- if not user.groups.filter(name=expected_group_name).exists():
- if dry_run:
- self.stdout.write(
- f'Would add user {user.email} to group {expected_group_name}'
- )
- else:
- try:
- group = Group.objects.get(name=expected_group_name)
- user.groups.add(group)
- self.stdout.write(
- f'Added user {user.email} to group {expected_group_name}'
- )
- except Group.DoesNotExist:
- self.stdout.write(
- self.style.ERROR(f'Group {expected_group_name} does not exist')
- )
-
- def migrate_professor_students(self, dry_run):
- """شناسایی و migration کاربرانی که هم استاد و هم دانشآموز هستند"""
- # کاربرانی که دوره ساختهاند (استاد هستند)
- professors = User.objects.filter(courses__isnull=False).distinct()
-
- # کاربرانی که در دوره شرکت کردهاند (دانشآموز هستند)
- students = User.objects.filter(participated_courses__isnull=False).distinct()
-
- # کاربرانی که هم استاد و هم دانشآموز هستند
- professor_students = professors.filter(
- id__in=students.values_list('id', flat=True)
- )
-
- self.stdout.write(
- f'Found {professor_students.count()} users who are both professors and students'
- )
-
- for user in professor_students:
- # اطمینان از اینکه در هر دو گروه هستند
- professor_group_exists = user.groups.filter(name="Professor Group").exists()
- student_group_exists = user.groups.filter(name="Student Group").exists()
-
- if not professor_group_exists:
- if dry_run:
- self.stdout.write(
- f'Would add professor role to user {user.email}'
- )
- else:
- user.add_role('professor')
- self.stdout.write(
- f'Added professor role to user {user.email}'
- )
-
- if not student_group_exists:
- if dry_run:
- self.stdout.write(
- f'Would add student role to user {user.email}'
- )
- else:
- user.add_role('student')
- self.stdout.write(
- f'Added student role to user {user.email}'
- )
-
- # نمایش آمار
- courses_taught = Course.objects.filter(professor=user).count()
- courses_enrolled = Participant.objects.filter(student=user).count()
-
- self.stdout.write(
- f' User {user.email}: teaches {courses_taught} courses, '
- f'enrolled in {courses_enrolled} courses'
- )
-
- def get_user_statistics(self):
- """نمایش آمار کاربران"""
- total_users = User.objects.count()
- professors = User.objects.filter(groups__name="Professor Group").count()
- students = User.objects.filter(groups__name="Student Group").count()
- clients = User.objects.filter(groups__name="Client Group").count()
-
- # کاربرانی که چندین نقش دارند
- multi_role_users = User.objects.filter(
- groups__name__in=["Professor Group", "Student Group"]
- ).annotate(
- role_count=models.Count('groups')
- ).filter(role_count__gt=1).count()
-
- self.stdout.write('\n--- User Statistics ---')
- self.stdout.write(f'Total users: {total_users}')
- self.stdout.write(f'Professors: {professors}')
- self.stdout.write(f'Students: {students}')
- self.stdout.write(f'Clients: {clients}')
- self.stdout.write(f'Multi-role users: {multi_role_users}')
diff --git a/apps/account/notification_service.py b/apps/account/notification_service.py
index 7958280..70abcb5 100644
--- a/apps/account/notification_service.py
+++ b/apps/account/notification_service.py
@@ -15,161 +15,19 @@ class SafeFormatter(string.Formatter):
def get_template_context(user, notification_type, data):
"""
- Builds a dynamic context dictionary containing user info and target entity details
- (course, lesson, quiz, session) to format templates.
+ Builds a dynamic context dictionary containing user info to format templates.
"""
context = {
'student_name': getattr(user, 'fullname', user.username) or user.username,
'fullname': getattr(user, 'fullname', user.username) or user.username,
'username': user.username,
}
-
- if not data or not isinstance(data, dict):
- return context
-
- # Import models dynamically inside function to avoid circular imports
- from apps.course.models.course import Course
- from apps.course.models.lesson import CourseLesson
- from apps.course.models.live_session import CourseLiveSession
- from apps.quiz.models import Quiz
-
- try:
- # 1. Course details
- course_id = data.get('course_id')
- if not course_id and 'session_id' in data:
- session = CourseLiveSession.objects.filter(id=data['session_id']).first()
- if session:
- course_id = session.course_id
-
- if course_id:
- course = Course.objects.filter(id=course_id).first()
- if course:
- title_val = course.title
- # course.title might be a JSON list or dict or string
- if isinstance(title_val, list) and title_val:
- course_title = title_val[0].get('title', '')
- elif isinstance(title_val, dict):
- course_title = title_val.get('title', '')
- else:
- course_title = str(title_val)
- context['course_title'] = course_title
- context['course_name'] = course_title
-
- slug_val = course.slug
- if isinstance(slug_val, list) and slug_val:
- course_slug = slug_val[0].get('title', '')
- elif isinstance(slug_val, dict):
- course_slug = slug_val.get('title', '')
- else:
- course_slug = str(slug_val)
- context['course_slug'] = course_slug
-
- # 2. Lesson details
- lesson_id = data.get('lesson_id')
- if lesson_id:
- lesson = CourseLesson.objects.filter(id=lesson_id).first()
- if lesson:
- title_val = lesson.title
- if isinstance(title_val, list) and title_val:
- lesson_title = title_val[0].get('title', '')
- elif isinstance(title_val, dict):
- lesson_title = title_val.get('title', '')
- else:
- lesson_title = str(title_val)
- context['lesson_title'] = lesson_title
-
- # 3. Quiz details
- quiz_id = data.get('quiz_id')
- if quiz_id:
- quiz = Quiz.objects.filter(id=quiz_id).first()
- if quiz:
- title_val = quiz.title
- if isinstance(title_val, list) and title_val:
- quiz_title = title_val[0].get('title', '')
- elif isinstance(title_val, dict):
- quiz_title = title_val.get('title', '')
- else:
- quiz_title = str(title_val)
- context['quiz_title'] = quiz_title
-
- # 4. Live Session details
- session_id = data.get('session_id')
- if session_id:
- session = CourseLiveSession.objects.filter(id=session_id).first()
- if session:
- context['session_subject'] = session.subject
- if session.start_at:
- context['session_start_time'] = session.start_at.strftime('%H:%M')
-
- except Exception as e:
- logger.error(f"Error building notification template context: {e}")
-
return context
def resolve_notification_route(notification_type, data):
"""
Resolves standard GoRouter paths based on notification type and payload data.
"""
- if not data or not isinstance(data, dict):
- return None
-
- # Import models dynamically inside function to avoid circular imports
- from apps.course.models.course import Course
- from apps.course.models.live_session import CourseLiveSession
-
- try:
- # Group 1: Course related paths (/course_info?slug=...)
- if notification_type in ['course_registered', 'course_completed', 'new_course_weekly',
- 'live_class_rescheduled', 'live_class_cancelled',
- 'live_recording_available', 'missed_live_sessions',
- 'course_access_granted']:
- course_id = data.get('course_id')
- if not course_id and 'session_id' in data:
- session = CourseLiveSession.objects.filter(id=data['session_id']).first()
- if session:
- course_id = session.course_id
-
- if course_id:
- course = Course.objects.filter(id=course_id).first()
- if course:
- slug = course.slug[0].get('title') if isinstance(course.slug, list) and course.slug else course.slug
- return f"/course_info?slug={slug}"
-
- # Group 2: Lesson path (/lesson?slug=...)
- elif notification_type == 'lesson_completed':
- course_id = data.get('course_id')
- if course_id:
- course = Course.objects.filter(id=course_id).first()
- if course:
- slug = course.slug[0].get('title') if isinstance(course.slug, list) and course.slug else course.slug
- return f"/lesson?slug={slug}"
-
- # Group 3: Quiz path (/course_info/quiz?slug=...)
- elif notification_type == 'low_quiz_result':
- from apps.quiz.models import Quiz
- quiz = Quiz.objects.filter(id=data.get('quiz_id')).first()
- if quiz and quiz.course:
- course = quiz.course
- slug = course.slug[0].get('title') if isinstance(course.slug, list) and course.slug else course.slug
- return f"/course_info/quiz?slug={slug}"
-
- # Group 4: Chat Room path (/chat/chat_details?room_id=...)
- elif notification_type in ['teacher_reply', 'teacher_reply_private', 'teacher_reply_course', 'support_reply']:
- room_id = data.get('room_id')
- if room_id:
- return f"/chat/chat_details?room_id={room_id}"
-
- # Group 5: Transactions path
- elif notification_type in ['payment_successful', 'payment_failed', 'refund_completed']:
- return "/profile/transactions"
-
- # Group 6: Home path
- elif notification_type == 'student_inactivity':
- return "/home"
-
- except Exception as e:
- logger.error(f"Error resolving route for type={notification_type}, data={data}: {e}")
-
return None
def create_and_send_notification(user, title_en, body_en, title_fa=None, body_fa=None, service='imam-javad',
diff --git a/apps/account/tasks.py b/apps/account/tasks.py
index 0b549d3..9cfc542 100644
--- a/apps/account/tasks.py
+++ b/apps/account/tasks.py
@@ -159,243 +159,6 @@ def send_otp_code_whatsapp(phone_number, code):
@shared_task
-def check_live_class_reminders_task():
- """
- Checks for any non-cancelled live classes starting in the next 2 hours and 15 minutes
- and sends a reminder to all enrolled students.
- """
- from django.utils import timezone
- from datetime import timedelta
- from apps.course.models import CourseLiveSession, Participant
- from apps.course.models.course import get_localized_field
- from apps.account.notification_service import create_and_send_notification
-
- now = timezone.now()
- sessions = CourseLiveSession.objects.filter(
- started_at__gt=now,
- started_at__lte=now + timedelta(hours=2, minutes=15),
- reminder_sent=False,
- is_cancelled=False
- )
-
- logger.info(f"[Live Session Reminder Check] Found {sessions.count()} sessions starting within 2h 15m.")
-
- for session in sessions:
- participants = Participant.objects.filter(course=session.course, is_active=True).select_related('student')
- for p in participants:
- student_user = p.student
- course_title_en = get_localized_field('en', session.course.title)
- course_title_fa = get_localized_field('fa', session.course.title)
-
- create_and_send_notification(
- user=student_user,
- title_en="LIVE Class Reminder",
- body_en=f"The live class for '{course_title_en}' starts in 2 hours and 15 minutes.",
- title_fa="یادآوری کلاس زنده",
- body_fa=f"کلاس زنده دوره «{course_title_fa}» تا ۲ ساعت و ۱۵ دقیقه دیگر شروع میشود.",
- service='imam-javad',
- data={'type': 'live_class_reminder', 'session_id': session.id}
- )
-
- session.reminder_sent = True
- session.save(update_fields=['reminder_sent'])
-
-
-@shared_task
-def check_new_course_registration_task():
- """
- Weekly check (Fridays) for courses with 'registering' status published in the last 7 days.
- Notifies all registered students about new courses.
- """
- from django.utils import timezone
- from datetime import timedelta
- from django.contrib.auth import get_user_model
- from apps.course.models import Course
- from apps.course.models.course import get_localized_field, normalize_status
- from apps.account.notification_service import create_and_send_notification
-
- User = get_user_model()
- now = timezone.now()
-
- candidate_courses = Course.objects.filter(
- created_at__gte=now - timedelta(days=7),
- notified_new_registration=False
- )
-
- new_courses = []
- for course in candidate_courses:
- if normalize_status(course.status) == 'registering':
- new_courses.append(course)
-
- if not new_courses:
- logger.info("[New Course Registration Check] No new registering courses found in the last 7 days.")
- return
-
- logger.info(f"[New Course Registration Check] Found {len(new_courses)} new registering courses.")
-
- if len(new_courses) == 1:
- course = new_courses[0]
- name_en = get_localized_field('en', course.title) or "New Course"
- name_fa = get_localized_field('fa', course.title) or "دوره جدید"
-
- title_en = "New Course Registration"
- body_en = f"Registration is open for: {name_en}"
-
- title_fa = "ثبتنام دوره جدید"
- body_fa = f"ثبتنام برای دوره «{name_fa}» آغاز شد."
- elif len(new_courses) <= 3:
- names_en = ", ".join(get_localized_field('en', c.title) for c in new_courses[:-1]) + f", and {get_localized_field('en', new_courses[-1].title)}"
- names_fa = "، ".join(get_localized_field('fa', c.title) for c in new_courses[:-1]) + f" و {get_localized_field('fa', new_courses[-1].title)}"
-
- title_en = "New Courses Registration"
- body_en = f"Great news! Enrollment is open for: {names_en}, and others"
-
- title_fa = "ثبتنام دورههای جدید"
- body_fa = f"خبر خوب! ثبتنام برای دورههای «{names_fa}» و دیگر دورهها آغاز شد."
- else:
- title_en = "New Courses at Imam al-Jawad School"
- body_en = "New directions are open at Imam al-Jawad School. Choose the right one for you!"
-
- title_fa = "دورههای جدید در مدرسه امام جواد"
- body_fa = "دورههای جدید در مدرسه امام جواد (ع) آغاز شده است. مسیر مناسب خود را انتخاب کنید!"
-
- students = User.objects.filter(user_type='student', is_active=True)
- logger.info(f"[New Course Registration Check] Notifying {students.count()} students.")
-
- for student in students:
- create_and_send_notification(
- user=student,
- title_en=title_en,
- body_en=body_en,
- title_fa=title_fa,
- body_fa=body_fa,
- service='imam-javad',
- data={'type': 'new_course_registration'}
- )
-
- for course in new_courses:
- course.notified_new_registration = True
- course.save(update_fields=['notified_new_registration'])
-
-
-@shared_task
-def check_low_quiz_results_task():
- """
- Checks for failed quiz attempts from exactly 2, 7, or 21 days ago (score < 70)
- and suggests a retake to the student, if they haven't passed or retaken the quiz since.
- """
- from django.utils import timezone
- from datetime import timedelta
- from apps.quiz.models import QuizParticipant
- from apps.course.models.course import get_localized_field
- from apps.account.notification_service import create_and_send_notification
- from django.core.cache import cache
-
- now = timezone.now()
- today = now.date()
-
- intervals = [2, 7, 21]
-
- for days in intervals:
- target_date = today - timedelta(days=days)
- # Find attempts that ended on target_date with total_score < 70
- candidates = QuizParticipant.objects.filter(
- ended_at__date=target_date,
- total_score__lt=70
- ).select_related('user', 'quiz')
-
- for candidate in candidates:
- # Check if user has retaken the quiz since this attempt
- has_newer_attempt = QuizParticipant.objects.filter(
- user=candidate.user,
- quiz=candidate.quiz,
- ended_at__gt=candidate.ended_at
- ).exists()
-
- # Check if user has passed the quiz at least once
- has_passed = QuizParticipant.objects.filter(
- user=candidate.user,
- quiz=candidate.quiz,
- total_score__gte=70
- ).exists()
-
- if has_newer_attempt or has_passed:
- continue
-
- # Prevent double notification on the same day for this interval
- cache_key = f"notified_quiz_low_score_{candidate.id}_{days}"
- if not cache.get(cache_key):
- cache.set(cache_key, True, timeout=86400) # 24 hours
-
- quiz_title_en = get_localized_field('en', candidate.quiz.title) or f"Quiz {candidate.quiz.id}"
- quiz_title_fa = get_localized_field('fa', candidate.quiz.title) or f"آزمون {candidate.quiz.id}"
-
- create_and_send_notification(
- user=candidate.user,
- title_en="Quiz Retake Suggestion",
- body_en=f"You scored below the passing threshold on quiz '{quiz_title_en}'. We suggest taking it again to improve your score.",
- title_fa="پیشنهاد شرکت مجدد در آزمون",
- body_fa=f"امتیاز شما در آزمون «{quiz_title_fa}» کمتر از حد نصاب شده است. پیشنهاد میکنیم مجدداً در این آزمون شرکت کنید تا نمره خود را بهبود ببخشید.",
- service='imam-javad',
- data={'type': 'low_quiz_result', 'quiz_id': candidate.quiz.id, 'days_since_failure': days}
- )
-
-
-@shared_task
-def check_inactive_students_task():
- """
- Finds active student users who have been registered for at least 7 days,
- but have no login or lesson completions in the last 7 days, and sends an inactivity alert.
- """
- from django.utils import timezone
- from datetime import timedelta
- from django.contrib.auth import get_user_model
- from apps.course.models.lesson import LessonCompletion
- from apps.account.notification_service import create_and_send_notification
- from django.core.cache import cache
-
- User = get_user_model()
- now = timezone.now()
- cutoff_date = now - timedelta(days=7)
-
- # Active students registered >= 7 days ago
- students = User.objects.filter(
- user_type='student',
- is_active=True,
- date_joined__lte=cutoff_date
- )
-
- logger.info(f"[Inactivity Check] Checking {students.count()} students for inactivity...")
-
- for student in students:
- # Check login activity in last 7 days
- if student.last_login and student.last_login > cutoff_date:
- continue
-
- # Check lesson completions in last 7 days
- recent_completion = LessonCompletion.objects.filter(
- student=student,
- completed_at__gt=cutoff_date
- ).exists()
-
- if recent_completion:
- continue
-
- # Check cache to prevent spamming (limit to once every 7 days)
- cache_key = f"notified_student_inactivity_{student.id}"
- if not cache.get(cache_key):
- cache.set(cache_key, True, timeout=86400 * 7) # 7-day cooldown
-
- create_and_send_notification(
- user=student,
- title_en="We Miss You!",
- body_en="You haven't taken any lessons in the last week. Come back and continue your learning journey!",
- title_fa="دلمان برایتان تنگ شده!",
- body_fa="در یک هفته گذشته هیچ درسی را مطالعه نکردهاید. منتظرتان هستیم تا مسیر یادگیری خود را ادامه دهید!",
- service='imam-javad',
- data={'type': 'student_inactivity'}
- )
-
@shared_task
def run_scheduled_notification_task(campaign_id):
diff --git a/apps/account/tests/notifications/__init__.py b/apps/account/tests/notifications/__init__.py
deleted file mode 100644
index 62a4bef..0000000
--- a/apps/account/tests/notifications/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-# Notifications tests package
diff --git a/apps/account/tests/notifications/test_communication_notifications.py b/apps/account/tests/notifications/test_communication_notifications.py
deleted file mode 100644
index 23299fb..0000000
--- a/apps/account/tests/notifications/test_communication_notifications.py
+++ /dev/null
@@ -1,242 +0,0 @@
-import datetime
-from django.utils import timezone
-from django.test import TestCase
-from unittest.mock import patch
-from django.core.files.uploadedfile import SimpleUploadedFile
-
-from dj_language.models import Language
-from apps.account.models import StudentUser, User, Notification
-from apps.course.models.course import Course, CourseCategory
-from apps.course.models.participant import Participant
-from apps.chat.models import RoomMessage, ChatMessage
-
-class CommunicationNotificationsFlowTests(TestCase):
- def setUp(self):
- # Create languages
- self.lang_ru, _ = Language.objects.update_or_create(
- code='ru',
- defaults={'name': 'Russian', 'status': True, 'countries': []}
- )
- self.lang_en, _ = Language.objects.update_or_create(
- code='en',
- defaults={'name': 'English', 'status': True, 'countries': []}
- )
-
- # Create category
- self.category = CourseCategory.objects.create(
- name=[{"title": "Category", "language_code": "en"}],
- slug=[{"title": "category", "language_code": "en"}]
- )
-
- # Create student user
- self.student = StudentUser.objects.create(
- email='student_comm@example.com',
- username='student_comm',
- fullname='Comm Student',
- language=self.lang_ru,
- fcm='token_comm_student'
- )
-
- # Create teacher user
- self.teacher = User.objects.create(
- email='teacher_comm@example.com',
- username='teacher_comm',
- fullname='Teacher Comm',
- user_type='professor',
- is_active=True,
- language=self.lang_ru
- )
-
- # Create support user (admin)
- self.support = User.objects.create(
- email='support_comm@example.com',
- username='support_comm',
- fullname='Support Comm',
- user_type='admin',
- is_active=True,
- language=self.lang_ru
- )
-
- # Create Course
- thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
- self.course = Course.objects.create(
- title=[
- {"title": "Course 1", "language_code": "en"},
- {"title": "Курс 1", "language_code": "ru"}
- ],
- slug=[{"title": "course-1", "language_code": "en"}],
- category=self.category,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=False,
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=0,
- description='Description',
- short_description='Short description',
- status=[{"title": "ongoing", "language_code": "en"}],
- is_free=True,
- )
-
- # Enroll student in the course
- self.participant = Participant.objects.create(
- student=self.student,
- course=self.course,
- is_active=True
- )
-
- @patch('apps.account.notification_service.send_notification')
- def test_teacher_replied_private_chat(self, mock_send):
- # Create a private chat room between student and teacher
- room = RoomMessage.objects.create(
- name="Private chat with teacher",
- initiator=self.student,
- recipient=self.teacher,
- room_type=RoomMessage.RoomTypeChoices.PRIVATE
- )
-
- # Teacher sends a message
- ChatMessage.objects.create(
- room=room,
- sender=self.teacher,
- content="Hello from teacher",
- content_type=ChatMessage.ChatTypeChoices.TEXT
- )
-
- # Verify notification sent to the student
- notif = Notification.objects.filter(user=self.student, title="Новое сообщение от преподавателя").first()
- self.assertIsNotNone(notif)
- self.assertIn("Hello from teacher", notif.message)
- self.assertTrue(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_support_replied_private_chat(self, mock_send):
- # Create a private chat room between student and support
- room = RoomMessage.objects.create(
- name="Support chat",
- initiator=self.student,
- recipient=self.support,
- room_type=RoomMessage.RoomTypeChoices.PRIVATE
- )
-
- # Support agent sends a message
- ChatMessage.objects.create(
- room=room,
- sender=self.support,
- content="How can I help you?",
- content_type=ChatMessage.ChatTypeChoices.TEXT
- )
-
- # Verify notification sent to the student
- notif = Notification.objects.filter(user=self.student, title="Новое сообщение от поддержки").first()
- self.assertIsNotNone(notif)
- self.assertIn("How can I help you?", notif.message)
- self.assertTrue(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_teacher_posted_in_course_chat(self, mock_send):
- # Create a course chat room
- room = RoomMessage.objects.create(
- name="Course group chat",
- course=self.course,
- initiator=self.teacher,
- room_type=RoomMessage.RoomTypeChoices.GROUP
- )
-
- # Teacher sends a message to course chat
- ChatMessage.objects.create(
- room=room,
- sender=self.teacher,
- content="Hello class, remember the homework",
- content_type=ChatMessage.ChatTypeChoices.TEXT
- )
-
- # Verify notification sent to the student enrolled
- notif = Notification.objects.filter(user=self.student, title="Сообщение от преподавателя в чате курса").first()
- self.assertIsNotNone(notif)
- self.assertIn("homework", notif.message)
- self.assertIn("Курс 1", notif.message)
- self.assertTrue(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_student_message_no_notification(self, mock_send):
- # Create a private chat room
- room = RoomMessage.objects.create(
- name="Private chat",
- initiator=self.student,
- recipient=self.teacher,
- room_type=RoomMessage.RoomTypeChoices.PRIVATE
- )
-
- # Clear notifications
- Notification.objects.filter(user=self.student).delete()
- mock_send.reset_mock()
-
- # Student sends a message
- ChatMessage.objects.create(
- room=room,
- sender=self.student,
- content="Hello professor",
- content_type=ChatMessage.ChatTypeChoices.TEXT
- )
-
- # Student should not receive a notification for their own message
- self.assertFalse(Notification.objects.filter(user=self.student).exists())
- self.assertFalse(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_webhook_unauthorized(self, mock_send):
- from django.urls import reverse
- url = reverse('chat-notify-message')
- response = self.client.post(url, {'message_id': 1}, HTTP_X_INTERNAL_SECRET='wrong-token')
- self.assertEqual(response.status_code, 403)
-
- @patch('apps.account.notification_service.send_notification')
- def test_webhook_authorized_success(self, mock_send):
- from django.urls import reverse
- from django.conf import settings
- from django.db.models.signals import post_save
- from apps.chat.signals import notify_on_new_chat_message
-
- # Create a private chat room
- room = RoomMessage.objects.create(
- name="Private chat with teacher",
- initiator=self.student,
- recipient=self.teacher,
- room_type=RoomMessage.RoomTypeChoices.PRIVATE
- )
-
- # Temporarily disconnect the signal to avoid trigger on save, so we only test webhook execution
- post_save.disconnect(notify_on_new_chat_message, sender=ChatMessage)
-
- try:
- # Teacher sends a message
- msg = ChatMessage.objects.create(
- room=room,
- sender=self.teacher,
- content="How are you?",
- content_type=ChatMessage.ChatTypeChoices.TEXT
- )
-
- # Clear notifications created (if any)
- Notification.objects.all().delete()
- mock_send.reset_mock()
-
- url = reverse('chat-notify-message')
- secret = getattr(settings, 'INTERNAL_WEBHOOK_SECRET', 'super-secret-key-12345')
- response = self.client.post(
- url,
- {'message_id': msg.id},
- HTTP_X_INTERNAL_SECRET=secret
- )
- self.assertEqual(response.status_code, 200)
-
- # Verify notification was sent
- notif = Notification.objects.filter(user=self.student).first()
- self.assertIsNotNone(notif)
- self.assertIn("How are you?", notif.message)
- self.assertTrue(mock_send.called)
- finally:
- # Reconnect signal
- post_save.connect(notify_on_new_chat_message, sender=ChatMessage)
diff --git a/apps/account/tests/notifications/test_live_notifications.py b/apps/account/tests/notifications/test_live_notifications.py
deleted file mode 100644
index 8e78098..0000000
--- a/apps/account/tests/notifications/test_live_notifications.py
+++ /dev/null
@@ -1,122 +0,0 @@
-import datetime
-from django.utils import timezone
-from django.test import TestCase
-from unittest.mock import patch
-from django.core.files.uploadedfile import SimpleUploadedFile
-
-from dj_language.models import Language
-from apps.account.models import StudentUser, Notification
-from apps.course.models.course import Course, CourseCategory
-from apps.course.models.participant import Participant
-from apps.course.models.live_session import CourseLiveSession, LiveSessionRecording
-
-class LiveNotificationsFlowTests(TestCase):
- def setUp(self):
- # Create languages
- self.lang_ru, _ = Language.objects.update_or_create(
- code='ru',
- defaults={'name': 'Russian', 'status': True, 'countries': []}
- )
- self.lang_en, _ = Language.objects.update_or_create(
- code='en',
- defaults={'name': 'English', 'status': True, 'countries': []}
- )
-
- # Create category
- self.category = CourseCategory.objects.create(
- name=[{"title": "Category", "language_code": "en"}],
- slug=[{"title": "category", "language_code": "en"}]
- )
-
- # Create student user
- self.student = StudentUser.objects.create(
- email='student_live@example.com',
- username='student_live',
- fullname='Live Student',
- language=self.lang_ru,
- fcm='token_live_student'
- )
-
- # Create Course
- thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
- self.course = Course.objects.create(
- title=[
- {"title": "Course 1", "language_code": "en"},
- {"title": "Курс 1", "language_code": "ru"}
- ],
- slug=[{"title": "course-1", "language_code": "en"}],
- category=self.category,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=False,
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=0,
- description='Description',
- short_description='Short description',
- status=[{"title": "ongoing", "language_code": "en"}],
- is_free=True,
- )
-
- # Enroll student in the course
- self.participant = Participant.objects.create(
- student=self.student,
- course=self.course,
- is_active=True
- )
-
- # Create Live Session
- self.session = CourseLiveSession.objects.create(
- course=self.course,
- subject='Lesson One Live',
- started_at=timezone.now() + datetime.timedelta(days=1),
- is_cancelled=False,
- reminder_sent=False
- )
-
- @patch('apps.account.notification_service.send_notification')
- def test_live_class_cancelled_notification(self, mock_send):
- # Event 1: LIVE Class Cancelled
- self.session.is_cancelled = True
- self.session.save()
-
- # Check DB notification for Russian student
- notif = Notification.objects.filter(user=self.student, title="Живой урок отменен").first()
- self.assertIsNotNone(notif)
- self.assertIn("Lesson One Live", notif.message)
- self.assertIn("Курс 1", notif.message)
- self.assertTrue(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_live_class_rescheduled_notification(self, mock_send):
- # Event 2: LIVE Class Rescheduled
- old_time = self.session.started_at
- new_time = old_time + datetime.timedelta(hours=2)
-
- self.session.started_at = new_time
- self.session.save()
-
- # Check DB notification
- notif = Notification.objects.filter(user=self.student, title="Время живого урока изменено").first()
- self.assertIsNotNone(notif)
- self.assertIn("Lesson One Live", notif.message)
- self.assertIn("Курс 1", notif.message)
- self.assertTrue(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_live_recording_available_notification(self, mock_send):
- # Event 3: LIVE Recording Available
- # Create a LiveSessionRecording with a dummy file
- recording_file = SimpleUploadedFile('rec.mp4', b'video_data', content_type='video/mp4')
- recording = LiveSessionRecording.objects.create(
- session=self.session,
- file=recording_file
- )
-
- # Check DB notification
- notif = Notification.objects.filter(user=self.student, title="Доступна запись урока").first()
- self.assertIsNotNone(notif)
- self.assertIn("Lesson One Live", notif.message)
- self.assertIn("Курс 1", notif.message)
- self.assertTrue(mock_send.called)
diff --git a/apps/account/tests/notifications/test_notifications.py b/apps/account/tests/notifications/test_notifications.py
deleted file mode 100644
index 02ea0e0..0000000
--- a/apps/account/tests/notifications/test_notifications.py
+++ /dev/null
@@ -1,316 +0,0 @@
-import datetime
-from django.utils import timezone
-from django.test import TestCase
-from unittest.mock import patch
-from django.core.files.uploadedfile import SimpleUploadedFile
-
-from dj_language.models import Language
-from apps.account.models import StudentUser, User, Notification
-from apps.course.models.course import Course, CourseCategory
-from apps.course.models.lesson import CourseChapter, CourseLesson, Lesson, LessonCompletion
-from apps.course.models.participant import Participant
-from apps.course.models.live_session import CourseLiveSession
-from apps.certificate.models import Certificate
-from apps.account.tasks import check_live_class_reminders_task, check_new_course_registration_task
-
-class NotificationFlowTests(TestCase):
- def setUp(self):
- # Create languages
- self.lang_ru, _ = Language.objects.update_or_create(
- code='ru',
- defaults={'name': 'Russian', 'status': True, 'countries': []}
- )
- self.lang_en, _ = Language.objects.update_or_create(
- code='en',
- defaults={'name': 'English', 'status': True, 'countries': []}
- )
-
- # Create category
- self.category = CourseCategory.objects.create(
- name=[{"title": "Category", "language_code": "en"}],
- slug=[{"title": "category", "language_code": "en"}]
- )
-
- # Create student user
- self.student = StudentUser.objects.create(
- email='student_test@example.com',
- username='student_test',
- fullname='Test Student',
- language=self.lang_ru, # Russian template check
- fcm='token_student'
- )
-
- # Create English student user for template check
- self.student_en = StudentUser.objects.create(
- email='student_en@example.com',
- username='student_en',
- fullname='Test Student EN',
- language=self.lang_en,
- fcm='token_student_en'
- )
-
- def _create_course(self, slug, title_en, title_ru, status='ongoing'):
- thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
- return Course.objects.create(
- title=[
- {"title": title_en, "language_code": "en"},
- {"title": title_ru, "language_code": "ru"}
- ],
- slug=[{"title": slug, "language_code": "en"}],
- category=self.category,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=False,
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=0,
- description='Description',
- short_description='Short description',
- status=[
- {"title": status, "language_code": "en"},
- {"title": status, "language_code": "ru"}
- ],
- is_free=True,
- )
-
- @patch('apps.account.notification_service.send_notification')
- def test_course_access_granted_notification(self, mock_send):
- # Event 1: Course Access Granted
- course = self._create_course('course-access', 'Course 1', 'Курс 1')
-
- # Create participant (active=True by default)
- Participant.objects.create(student=self.student, course=course)
-
- # Verify notification created in DB (in Russian)
- notif_ru = Notification.objects.filter(user=self.student).first()
- self.assertIsNotNone(notif_ru)
- self.assertEqual(notif_ru.title, "Доступ к курсу активирован")
- self.assertIn("Курс 1", notif_ru.message)
- self.assertEqual(notif_ru.notification_type, "course_access_granted")
- self.assertEqual(notif_ru.action, "navigate")
- self.assertEqual(notif_ru.navigate_to, "/course_info?slug=course-access")
-
- # Create participant for English student
- Participant.objects.create(student=self.student_en, course=course)
- notif_en = Notification.objects.filter(user=self.student_en).first()
- self.assertIsNotNone(notif_en)
- self.assertEqual(notif_en.title, "Course Access Granted")
- self.assertIn("Course 1", notif_en.message)
- self.assertEqual(notif_en.notification_type, "course_access_granted")
- self.assertEqual(notif_en.action, "navigate")
- self.assertEqual(notif_en.navigate_to, "/course_info?slug=course-access")
-
- # Verify send_notification was called
- self.assertTrue(mock_send.called)
- # Check that target routing keys were passed to send_notification call
- called_args, called_kwargs = mock_send.call_args
- sent_data = called_args[3] if len(called_args) > 3 else called_kwargs.get('data')
- self.assertEqual(sent_data.get('type'), "course_access_granted")
- self.assertEqual(sent_data.get('action'), "navigate")
- self.assertEqual(sent_data.get('navigate_to'), "/course_info?slug=course-access")
-
- @patch('apps.account.notification_service.send_notification')
- def test_live_class_reminder_notification(self, mock_send):
- # Event 2: LIVE Class Reminder
- course = self._create_course('course-live', 'Course 2', 'Курс 2')
- Participant.objects.create(student=self.student, course=course)
-
- # Create a live session starting in 2 hours
- session = CourseLiveSession.objects.create(
- course=course,
- subject='Live Lesson 1',
- started_at=timezone.now() + datetime.timedelta(hours=2),
- is_cancelled=False,
- reminder_sent=False
- )
-
- # Run periodic task
- check_live_class_reminders_task()
-
- # Verify reminder sent
- session.refresh_from_db()
- self.assertTrue(session.reminder_sent)
-
- notif = Notification.objects.filter(user=self.student, title="Напоминание о живом уроке").first()
- self.assertIsNotNone(notif)
- self.assertIn("Live Lesson 1", notif.message)
-
- # Test rescheduling resets reminder_sent
- session.started_at = timezone.now() + datetime.timedelta(hours=5)
- session.save()
-
- session.refresh_from_db()
- self.assertFalse(session.reminder_sent)
-
- @patch('apps.account.notification_service.send_notification')
- def test_course_completed_notification(self, mock_send):
- # Event 3: Course Completed
- course = self._create_course('course-complete', 'Course 3', 'Курс 3')
- Participant.objects.create(student=self.student, course=course)
-
- chapter = CourseChapter.objects.create(
- course=course,
- title='Chapter 1',
- priority=1,
- is_active=True
- )
-
- # Add 2 lessons
- l1 = Lesson.objects.create(title='Lesson 1', content_type='video_file', duration=5)
- cl1 = CourseLesson.objects.create(course=course, chapter=chapter, lesson=l1, priority=1, is_active=True)
-
- l2 = Lesson.objects.create(title='Lesson 2', content_type='video_file', duration=5)
- cl2 = CourseLesson.objects.create(course=course, chapter=chapter, lesson=l2, priority=2, is_active=True)
-
- # Complete first lesson
- LessonCompletion.objects.create(student=self.student, course_lesson=cl1)
- self.assertFalse(Notification.objects.filter(user=self.student, title="Курс завершен").exists())
-
- # Complete second (last) lesson
- LessonCompletion.objects.create(student=self.student, course_lesson=cl2)
-
- # Verify notification triggered
- self.assertTrue(Notification.objects.filter(user=self.student, title="Курс завершен").exists())
-
- @patch('apps.account.notification_service.send_notification')
- def test_certificate_issued_notification(self, mock_send):
- # Event 4: Certificate Issued
- course = self._create_course('course-cert', 'Course 4', 'Курс 4')
-
- cert = Certificate.objects.create(
- student=self.student,
- course=course,
- status='pending'
- )
-
- # Verify no notification yet
- self.assertFalse(Notification.objects.filter(user=self.student, title="Сертификат выдан").exists())
-
- # Approve certificate
- cert.status = 'approved'
- cert.save()
-
- # Verify notification triggered
- self.assertTrue(Notification.objects.filter(user=self.student, title="Сертификат выдан").exists())
-
- @patch('apps.account.notification_service.send_notification')
- def test_new_course_registration_notification(self, mock_send):
- # Event 5: New Course Registration
- student_recipient = User.objects.create(
- email='student_recipient@example.com',
- username='student_recipient',
- fullname='Recipient Student',
- user_type='student',
- is_active=True,
- language=self.lang_ru,
- fcm='token_recipient'
- )
-
- course = self._create_course('course-new', 'New Course A', 'Новый курс А', status='registering')
-
- check_new_course_registration_task()
-
- course.refresh_from_db()
- self.assertTrue(course.notified_new_registration)
-
- notif = Notification.objects.filter(user=student_recipient, title="Добавлен новый курс").first()
- self.assertIsNotNone(notif)
- self.assertIn("Новый курс А", notif.message)
-
- @patch('apps.account.notification_service.send_notification')
- def test_notification_template_customization_and_deactivation(self, mock_send):
- from apps.account.models import NotificationTemplate
- # 1. Create a custom template in the database
- template = NotificationTemplate.objects.create(
- notification_type="custom_test_type",
- name="Test Template",
- title_ru="Привет {student_name} к курсу {course_title}",
- title_en="Hello {student_name} to course {course_title}",
- body_ru="Русское описание {student_name}",
- body_en="English description {student_name}",
- is_active=True
- )
-
- course = self._create_course('test-tpl-course', 'Tpl Course', 'Курс Шаблон')
-
- # 2. Trigger notification and verify custom text formatting
- from apps.account.notification_service import create_and_send_notification
- create_and_send_notification(
- user=self.student,
- title_en="Default Title EN",
- body_en="Default Body EN",
- title_ru="Default Title RU",
- body_ru="Default Body RU",
- service='imam-javad',
- data={'type': 'custom_test_type', 'course_id': course.id}
- )
-
- notif = Notification.objects.filter(user=self.student).order_by('-id').first()
- self.assertIsNotNone(notif)
- # Verify it loaded from template and formatted using course name & student name
- self.assertEqual(notif.title, f"Привет {self.student.fullname} к курсу Курс Шаблон")
- self.assertEqual(notif.message, f"Русское описание {self.student.fullname}")
-
- # 3. Disable template and verify notification is discarded
- template.is_active = False
- template.save()
-
- count_before = Notification.objects.filter(user=self.student).count()
- result = create_and_send_notification(
- user=self.student,
- title_en="Default Title EN",
- body_en="Default Body EN",
- title_ru="Default Title RU",
- body_ru="Default Body RU",
- service='imam-javad',
- data={'type': 'custom_test_type', 'course_id': course.id}
- )
- self.assertIsNone(result)
- count_after = Notification.objects.filter(user=self.student).count()
- self.assertEqual(count_before, count_after)
-
- def test_scheduled_notification_syncs_periodic_task(self):
- from apps.account.models import NotificationTemplate, ScheduledNotification
- from django_celery_beat.models import PeriodicTask
-
- template = NotificationTemplate.objects.create(
- notification_type="sched_test",
- name="Scheduled Test",
- title_ru="Тест планирования",
- title_en="Scheduled Test",
- body_ru="Тест",
- body_en="Test"
- )
-
- # Create ScheduledNotification
- sched_notif = ScheduledNotification.objects.create(
- name="My Weekly Campaign",
- template=template,
- target_group=ScheduledNotification.TargetGroupChoices.ALL_STUDENTS,
- schedule_type=ScheduledNotification.ScheduleTypeChoices.WEEKLY,
- days_of_week="6,0",
- time_of_day="14:30:00",
- is_active=True
- )
-
- # Verify PeriodicTask and CrontabSchedule are created
- self.assertIsNotNone(sched_notif.periodic_task)
- pt = sched_notif.periodic_task
- self.assertEqual(pt.name, "Scheduled Notification: My Weekly Campaign")
- self.assertEqual(pt.crontab.minute, "30")
- self.assertEqual(pt.crontab.hour, "14")
- self.assertEqual(pt.crontab.day_of_week, "6,0")
- self.assertTrue(pt.enabled)
-
- # Deactivate and verify sync
- sched_notif.is_active = False
- sched_notif.save()
- pt.refresh_from_db()
- self.assertFalse(pt.enabled)
-
- # Delete and verify dynamic cleanup
- task_id = pt.id
- sched_notif.delete()
- self.assertFalse(PeriodicTask.objects.filter(id=task_id).exists())
-
diff --git a/apps/account/tests/notifications/test_quiz_notifications.py b/apps/account/tests/notifications/test_quiz_notifications.py
deleted file mode 100644
index 8a27ef7..0000000
--- a/apps/account/tests/notifications/test_quiz_notifications.py
+++ /dev/null
@@ -1,176 +0,0 @@
-import datetime
-from django.utils import timezone
-from django.test import TestCase
-from unittest.mock import patch
-from django.core.files.uploadedfile import SimpleUploadedFile
-
-from dj_language.models import Language
-from apps.account.models import StudentUser, User, Notification
-from apps.course.models.course import Course, CourseCategory
-from apps.quiz.models import Quiz, QuizParticipant
-from apps.account.tasks import check_low_quiz_results_task
-
-class QuizNotificationFlowTests(TestCase):
- def setUp(self):
- # Create languages
- self.lang_ru, _ = Language.objects.update_or_create(
- code='ru',
- defaults={'name': 'Russian', 'status': True, 'countries': []}
- )
- self.lang_en, _ = Language.objects.update_or_create(
- code='en',
- defaults={'name': 'English', 'status': True, 'countries': []}
- )
-
- # Create category
- self.category = CourseCategory.objects.create(
- name=[{"title": "Category", "language_code": "en"}],
- slug=[{"title": "category", "language_code": "en"}]
- )
-
- # Create student user
- self.student = StudentUser.objects.create(
- email='student_quiz@example.com',
- username='student_quiz',
- fullname='Quiz Student',
- language=self.lang_ru,
- fcm='token_quiz_student'
- )
-
- # Create Course
- thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
- self.course = Course.objects.create(
- title=[
- {"title": "Course 1", "language_code": "en"},
- {"title": "Курс 1", "language_code": "ru"}
- ],
- slug=[{"title": "course-1", "language_code": "en"}],
- category=self.category,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=False,
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=0,
- description='Description',
- short_description='Short description',
- status=[{"title": "ongoing", "language_code": "en"}],
- is_free=True,
- )
-
- # Create Quiz
- self.quiz = Quiz.objects.create(
- course=self.course,
- title=[
- {"title": "Quiz A", "language_code": "en"},
- {"title": "Тест А", "language_code": "ru"}
- ],
- each_question_timing=60,
- status=True
- )
-
- @patch('apps.account.notification_service.send_notification')
- def test_low_quiz_result_triggers_on_intervals(self, mock_send):
- # Event: Low Quiz Result (score < 70)
- # Create failed attempt exactly 2 days ago
- attempt_2_days = QuizParticipant.objects.create(
- quiz=self.quiz,
- user=self.student,
- started_at=timezone.now() - datetime.timedelta(days=2, hours=1),
- ended_at=timezone.now() - datetime.timedelta(days=2),
- total_timing=300,
- question_score=50,
- timing_score=0,
- total_score=50
- )
-
- # Run periodic task
- check_low_quiz_results_task()
-
- # Verify reminder sent to the student (Russian template check)
- notif = Notification.objects.filter(user=self.student, title="Предложение пересдать тест").first()
- self.assertIsNotNone(notif)
- self.assertIn("Тест А", notif.message)
- self.assertTrue(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_no_reminder_if_passed_initially(self, mock_send):
- # Create successful attempt exactly 2 days ago (score = 80)
- QuizParticipant.objects.create(
- quiz=self.quiz,
- user=self.student,
- started_at=timezone.now() - datetime.timedelta(days=2, hours=1),
- ended_at=timezone.now() - datetime.timedelta(days=2),
- total_timing=300,
- question_score=80,
- timing_score=0,
- total_score=80
- )
-
- check_low_quiz_results_task()
-
- self.assertFalse(Notification.objects.filter(user=self.student, title="Предложение пересдать тест").exists())
- self.assertFalse(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_no_reminder_if_retaken_and_passed(self, mock_send):
- # Create failed attempt exactly 7 days ago
- attempt_failed = QuizParticipant.objects.create(
- quiz=self.quiz,
- user=self.student,
- started_at=timezone.now() - datetime.timedelta(days=7, hours=1),
- ended_at=timezone.now() - datetime.timedelta(days=7),
- total_timing=300,
- question_score=40,
- timing_score=0,
- total_score=40
- )
-
- # Create a newer successful attempt 3 days ago
- QuizParticipant.objects.create(
- quiz=self.quiz,
- user=self.student,
- started_at=timezone.now() - datetime.timedelta(days=3, hours=1),
- ended_at=timezone.now() - datetime.timedelta(days=3),
- total_timing=300,
- question_score=90,
- timing_score=0,
- total_score=90
- )
-
- check_low_quiz_results_task()
-
- # Should not suggest retake since they passed subsequently
- self.assertFalse(Notification.objects.filter(user=self.student, title="Предложение пересдать тест").exists())
-
- @patch('apps.account.notification_service.send_notification')
- def test_no_reminder_if_already_retaken_even_if_failed(self, mock_send):
- # Create failed attempt exactly 21 days ago
- attempt_failed = QuizParticipant.objects.create(
- quiz=self.quiz,
- user=self.student,
- started_at=timezone.now() - datetime.timedelta(days=21, hours=1),
- ended_at=timezone.now() - datetime.timedelta(days=21),
- total_timing=300,
- question_score=30,
- timing_score=0,
- total_score=30
- )
-
- # Create a newer attempt 10 days ago (also failed, e.g. score = 50)
- # Since they already attempted a retake 10 days ago, we shouldn't trigger the 21-day reminder of the first failure.
- QuizParticipant.objects.create(
- quiz=self.quiz,
- user=self.student,
- started_at=timezone.now() - datetime.timedelta(days=10, hours=1),
- ended_at=timezone.now() - datetime.timedelta(days=10),
- total_timing=300,
- question_score=50,
- timing_score=0,
- total_score=50
- )
-
- check_low_quiz_results_task()
-
- self.assertFalse(Notification.objects.filter(user=self.student, title="Предложение пересдать тест").exists())
diff --git a/apps/account/tests/notifications/test_retention_notifications.py b/apps/account/tests/notifications/test_retention_notifications.py
deleted file mode 100644
index 3f197da..0000000
--- a/apps/account/tests/notifications/test_retention_notifications.py
+++ /dev/null
@@ -1,154 +0,0 @@
-import datetime
-from django.utils import timezone
-from django.test import TestCase
-from unittest.mock import patch
-from django.core.files.uploadedfile import SimpleUploadedFile
-
-from dj_language.models import Language
-from apps.account.models import StudentUser, Notification
-from apps.course.models.course import Course, CourseCategory
-from apps.course.models.lesson import CourseChapter, CourseLesson, Lesson, LessonCompletion
-from apps.course.models.participant import Participant
-from apps.course.models.live_session import CourseLiveSession, LiveSessionUser
-from apps.account.tasks import check_inactive_students_task
-
-class RetentionNotificationsFlowTests(TestCase):
- def setUp(self):
- # Create languages
- self.lang_ru, _ = Language.objects.update_or_create(
- code='ru',
- defaults={'name': 'Russian', 'status': True, 'countries': []}
- )
- self.lang_en, _ = Language.objects.update_or_create(
- code='en',
- defaults={'name': 'English', 'status': True, 'countries': []}
- )
-
- # Create category
- self.category = CourseCategory.objects.create(
- name=[{"title": "Category", "language_code": "en"}],
- slug=[{"title": "category", "language_code": "en"}]
- )
-
- # Create student user
- self.student = StudentUser.objects.create(
- email='student_ret@example.com',
- username='student_ret',
- fullname='Ret Student',
- language=self.lang_ru,
- fcm='token_ret_student'
- )
-
- # Create Course
- thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
- self.course = Course.objects.create(
- title=[
- {"title": "Course 1", "language_code": "en"},
- {"title": "Курс 1", "language_code": "ru"}
- ],
- slug=[{"title": "course-1", "language_code": "en"}],
- category=self.category,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=False,
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=0,
- description='Description',
- short_description='Short description',
- status=[{"title": "ongoing", "language_code": "en"}],
- is_free=True,
- )
-
- # Enroll student in the course
- self.participant = Participant.objects.create(
- student=self.student,
- course=self.course,
- is_active=True
- )
-
- @patch('apps.account.notification_service.send_notification')
- def test_inactive_student_inactivity_notification(self, mock_send):
- # Event 1: Inactivity (Self-paced)
- # Setup student to be registered 8 days ago with last_login 8 days ago
- self.student.date_joined = timezone.now() - datetime.timedelta(days=8)
- self.student.last_login = timezone.now() - datetime.timedelta(days=8)
- self.student.save()
-
- # Run task
- check_inactive_students_task()
-
- # Check DB notification
- notif = Notification.objects.filter(user=self.student, title="Мы скучаем по вам!").first()
- self.assertIsNotNone(notif)
- self.assertTrue(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_active_student_no_inactivity_notification(self, mock_send):
- # Date joined 8 days ago, but login 3 days ago
- self.student.date_joined = timezone.now() - datetime.timedelta(days=8)
- self.student.last_login = timezone.now() - datetime.timedelta(days=3)
- self.student.save()
-
- check_inactive_students_task()
-
- self.assertFalse(Notification.objects.filter(user=self.student, title="Мы скучаем по вам!").exists())
- self.assertFalse(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_student_with_recent_lesson_completion_no_inactivity_notification(self, mock_send):
- # Date joined 8 days ago, last login 8 days ago, but completed a lesson 2 days ago
- self.student.date_joined = timezone.now() - datetime.timedelta(days=8)
- self.student.last_login = timezone.now() - datetime.timedelta(days=8)
- self.student.save()
-
- chapter = CourseChapter.objects.create(course=self.course, title='Chapter 1', priority=1, is_active=True)
- l1 = Lesson.objects.create(title='Lesson 1', content_type='video_file', duration=5)
- cl1 = CourseLesson.objects.create(course=self.course, chapter=chapter, lesson=l1, priority=1, is_active=True)
-
- # Complete lesson 2 days ago
- completion = LessonCompletion.objects.create(student=self.student, course_lesson=cl1)
- # Manually force completed_at to be 2 days ago (since auto_now_add makes it now)
- LessonCompletion.objects.filter(id=completion.id).update(completed_at=timezone.now() - datetime.timedelta(days=2))
-
- # Reset mock_send because LessonCompletion creation triggered a "Lesson Completed" notification
- mock_send.reset_mock()
-
- check_inactive_students_task()
-
- self.assertFalse(Notification.objects.filter(user=self.student, title="Мы скучаем по вам!").exists())
- self.assertFalse(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_missed_live_sessions_notification(self, mock_send):
- # Event 2: Missed LIVE Sessions
- # Create session 1 (ended)
- s1 = CourseLiveSession.objects.create(
- course=self.course,
- subject='Live Class One',
- started_at=timezone.now() - datetime.timedelta(days=3),
- ended_at=timezone.now() - datetime.timedelta(days=3, hours=2),
- is_cancelled=False,
- reminder_sent=True
- )
-
- # Create session 2 (ended just now)
- s2 = CourseLiveSession.objects.create(
- course=self.course,
- subject='Live Class Two',
- started_at=timezone.now() - datetime.timedelta(hours=2),
- is_cancelled=False,
- reminder_sent=True
- )
-
- # The student has not joined either s1 or s2 (no LiveSessionUser entries).
- # Set ended_at to trigger the signal
- s2.ended_at = timezone.now()
- s2.save()
-
- # Check DB notification
- notif = Notification.objects.filter(user=self.student, title="Пропуск живых уроков").first()
- self.assertIsNotNone(notif)
- self.assertIn("Курс 1", notif.message)
- self.assertTrue(mock_send.called)
diff --git a/apps/account/tests/notifications/test_transaction_notifications.py b/apps/account/tests/notifications/test_transaction_notifications.py
deleted file mode 100644
index 4e838a2..0000000
--- a/apps/account/tests/notifications/test_transaction_notifications.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import datetime
-from django.utils import timezone
-from django.test import TestCase
-from unittest.mock import patch
-from django.core.files.uploadedfile import SimpleUploadedFile
-
-from dj_language.models import Language
-from apps.account.models import StudentUser, Notification
-from apps.course.models.course import Course, CourseCategory
-from apps.transaction.models import TransactionParticipant
-
-class TransactionNotificationsFlowTests(TestCase):
- def setUp(self):
- # Create languages
- self.lang_ru, _ = Language.objects.update_or_create(
- code='ru',
- defaults={'name': 'Russian', 'status': True, 'countries': []}
- )
- self.lang_en, _ = Language.objects.update_or_create(
- code='en',
- defaults={'name': 'English', 'status': True, 'countries': []}
- )
-
- # Create category
- self.category = CourseCategory.objects.create(
- name=[{"title": "Category", "language_code": "en"}],
- slug=[{"title": "category", "language_code": "en"}]
- )
-
- # Create student user
- self.student = StudentUser.objects.create(
- email='student_tx@example.com',
- username='student_tx',
- fullname='Tx Student',
- language=self.lang_ru,
- fcm='token_tx_student'
- )
-
- # Create Course
- thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
- self.course = Course.objects.create(
- title=[
- {"title": "Course 1", "language_code": "en"},
- {"title": "Курс 1", "language_code": "ru"}
- ],
- slug=[{"title": "course-1", "language_code": "en"}],
- category=self.category,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=False,
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=0,
- description='Description',
- short_description='Short description',
- status=[{"title": "ongoing", "language_code": "en"}],
- is_free=True,
- )
-
- # Create transaction with PENDING status
- self.tx = TransactionParticipant.objects.create(
- user=self.student,
- course=self.course,
- price=150.00,
- status=TransactionParticipant.TransactionStatus.PENDING
- )
-
- @patch('apps.account.notification_service.send_notification')
- def test_payment_successful_notification(self, mock_send):
- # Update status to SUCCESS
- self.tx.status = TransactionParticipant.TransactionStatus.SUCCESS
- self.tx.save()
-
- # Check DB notification (in Russian)
- notif = Notification.objects.filter(user=self.student, title="Оплата успешна").first()
- self.assertIsNotNone(notif)
- self.assertIn("Курс 1", notif.message)
- self.assertTrue(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_payment_failed_notification(self, mock_send):
- # Update status to FAILED
- self.tx.status = TransactionParticipant.TransactionStatus.FAILED
- self.tx.save()
-
- # Check DB notification (in Russian)
- notif = Notification.objects.filter(user=self.student, title="Ошибка оплаты").first()
- self.assertIsNotNone(notif)
- self.assertIn("Курс 1", notif.message)
- self.assertTrue(mock_send.called)
-
- @patch('apps.account.notification_service.send_notification')
- def test_refund_completed_notification(self, mock_send):
- # Update status to REFUNDED
- self.tx.status = TransactionParticipant.TransactionStatus.REFUNDED
- self.tx.save()
-
- # Check DB notification (in Russian)
- notif = Notification.objects.filter(user=self.student, title="Возврат средств выполнен").first()
- self.assertIsNotNone(notif)
- self.assertIn("Курс 1", notif.message)
- self.assertTrue(mock_send.called)
diff --git a/apps/account/tests/test_multiple_roles.py b/apps/account/tests/test_multiple_roles.py
deleted file mode 100644
index 45993b7..0000000
--- a/apps/account/tests/test_multiple_roles.py
+++ /dev/null
@@ -1,240 +0,0 @@
-"""
-تستهای سیستم نقشهای چندگانه
-"""
-from django.test import TestCase
-from django.contrib.auth.models import Group
-from apps.account.models import User
-from apps.course.models import Course, CourseCategory, Participant
-from apps.transaction.models import TransactionParticipant
-
-
-class MultipleRolesTestCase(TestCase):
- def setUp(self):
- """راهاندازی دادههای تست"""
- # ایجاد گروهها
- self.professor_group = Group.objects.create(name="Professor Group")
- self.student_group = Group.objects.create(name="Student Group")
- self.client_group = Group.objects.create(name="Client Group")
-
- # ایجاد کاربر
- self.user = User.objects.create_user(
- email='test@example.com',
- fullname='Test User',
- password='testpass123'
- )
- # حذف language برای جلوگیری از خطای foreign key
- self.user.language = None
- self.user.save()
-
- # ایجاد دستهبندی دوره
- self.category = CourseCategory.objects.create(
- name='Test Category',
- slug='test-category'
- )
-
- def test_user_can_have_multiple_roles(self):
- """تست اینکه کاربر میتواند چندین نقش داشته باشد"""
- # اضافه کردن نقش professor
- self.user.add_role('professor')
- self.assertTrue(self.user.has_role('professor'))
- self.assertEqual(self.user.primary_role, User.UserType.PROFESSOR)
-
- # اضافه کردن نقش student
- self.user.add_role('student')
- self.assertTrue(self.user.has_role('student'))
- self.assertTrue(self.user.has_role('professor')) # نقش قبلی حفظ شده
-
- # نقش اصلی باید professor باشد (اولویت بالاتر)
- self.assertEqual(self.user.primary_role, User.UserType.PROFESSOR)
-
- # لیست تمام نقشها
- roles = self.user.get_all_roles()
- self.assertIn('professor', roles)
- self.assertIn('student', roles)
-
- def test_remove_role(self):
- """تست حذف نقش"""
- # اضافه کردن دو نقش
- self.user.add_role('professor')
- self.user.add_role('student')
-
- # حذف نقش professor
- self.user.remove_role('professor')
- self.assertFalse(self.user.has_role('professor'))
- self.assertTrue(self.user.has_role('student'))
-
- # نقش اصلی باید student شود
- self.assertEqual(self.user.primary_role, User.UserType.STUDENT)
-
- def test_course_creation_and_enrollment(self):
- """تست ایجاد دوره و ثبتنام در دوره دیگر"""
- # کاربر نقش professor میگیرد
- self.user.add_role('professor')
-
- # ایجاد دوره
- course1 = Course.objects.create(
- title='Test Course 1',
- slug='test-course-1',
- category=self.category,
- professor=self.user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Test description'
- )
-
- # بررسی اینکه کاربر میتواند دوره را مدیریت کند
- self.assertTrue(self.user.can_manage_course(course1))
-
- # کاربر دیگری دوره دیگری میسازد
- other_user = User.objects.create_user(
- email='other@example.com',
- fullname='Other User',
- password='testpass123'
- )
- other_user.language = None
- other_user.save()
- other_user.add_role('professor')
-
- course2 = Course.objects.create(
- title='Test Course 2',
- slug='test-course-2',
- category=self.category,
- professor=other_user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Test description 2'
- )
-
- # کاربر اول در دوره دوم شرکت میکند
- self.user.add_role('student')
- participant = Participant.objects.create(
- student=self.user,
- course=course2
- )
-
- # بررسی نقشها
- self.assertTrue(self.user.has_role('professor')) # هنوز استاد است
- self.assertTrue(self.user.has_role('student')) # و دانشآموز هم هست
-
- # بررسی دسترسیها
- self.assertTrue(self.user.can_manage_course(course1)) # دوره خودش
- self.assertFalse(self.user.can_manage_course(course2)) # دوره دیگری
-
- def test_transaction_preserves_professor_role(self):
- """تست اینکه transaction نقش professor را حفظ میکند"""
- # کاربر استاد میشود
- self.user.add_role('professor')
-
- # ایجاد دوره
- course = Course.objects.create(
- title='Test Course',
- slug='test-course',
- category=self.category,
- professor=self.user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Test description',
- is_free=True
- )
-
- # شبیهسازی transaction (کاربر در دورهای شرکت میکند)
- if not self.user.has_role('student'):
- self.user.add_role('student')
-
- # بررسی اینکه هر دو نقش حفظ شدهاند
- self.assertTrue(self.user.has_role('professor'))
- self.assertTrue(self.user.has_role('student'))
-
- # نقش اصلی باید professor باشد
- self.assertEqual(self.user.primary_role, User.UserType.PROFESSOR)
-
- def test_permissions(self):
- """تست دسترسیها"""
- # کاربر بدون نقش خاص
- self.assertFalse(self.user.can_teach_course())
- self.assertTrue(self.user.can_enroll_course())
-
- # اضافه کردن نقش professor
- self.user.add_role('professor')
- self.assertTrue(self.user.can_teach_course())
- self.assertTrue(self.user.can_enroll_course())
-
- # حذف نقش professor
- self.user.remove_role('professor')
- self.assertFalse(self.user.can_teach_course())
- self.assertTrue(self.user.can_enroll_course())
-
- def test_user_type_based_on_groups_compatibility(self):
- """تست سازگاری با property قدیمی"""
- # اضافه کردن نقش student
- self.user.add_role('student')
- self.user.refresh_from_db() # بروزرسانی از دیتابیس
- self.assertEqual(self.user.user_type_based_on_groups, User.UserType.STUDENT)
-
- # اضافه کردن نقش professor
- self.user.add_role('professor')
- self.user.refresh_from_db() # بروزرسانی از دیتابیس
- # property قدیمی بر اساس اولویت کار میکند - student اول چک میشود
- # پس باید student برگرداند نه professor
- self.assertEqual(self.user.user_type_based_on_groups, User.UserType.STUDENT)
-
- # حذف نقش student
- self.user.remove_role('student')
- self.user.refresh_from_db()
- self.assertEqual(self.user.user_type_based_on_groups, User.UserType.PROFESSOR)
-
- # حذف همه نقشها
- self.user.remove_role('professor')
- self.user.refresh_from_db()
- self.assertEqual(self.user.user_type_based_on_groups, User.UserType.CLIENT)
-
- def test_admin_priority_over_professor(self):
- """تست اولویت admin بر professor"""
- # کاربر هم admin و هم professor است
- self.user.add_role('admin')
- self.user.add_role('professor')
- self.user.is_staff = True
- self.user.save()
-
- # ایجاد دوره
- course = Course.objects.create(
- title='Test Course',
- slug='test-course',
- category=self.category,
- professor=self.user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Test description'
- )
-
- # admin باید دسترسی کامل داشته باشد
- self.assertTrue(self.user.can_manage_course(course))
- self.assertTrue(self.user.can_teach_course())
-
- # حتی اگر دوره متعلق به کس دیگری باشد
- other_user = User.objects.create_user(
- email='other@example.com',
- fullname='Other User',
- password='testpass123'
- )
- other_user.language = None
- other_user.save()
- other_user.add_role('professor')
-
- other_course = Course.objects.create(
- title='Other Course',
- slug='other-course',
- category=self.category,
- professor=other_user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Other description'
- )
-
- # admin باید به دوره دیگران هم دسترسی داشته باشد
- self.assertTrue(self.user.can_manage_course(other_course))
diff --git a/apps/api/views/__init__.py b/apps/api/views/__init__.py
index 40c60bc..5a58caa 100644
--- a/apps/api/views/__init__.py
+++ b/apps/api/views/__init__.py
@@ -4,8 +4,6 @@
from .api_views import HomeView, CountryView, CommentListAPIView, CityListView
from .documentation import CustomAPIDocumentationView
from .swagger_views import CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth
-from .admin_dashboard import AdminDashboardStatsView
-from .professor_dashboard import ProfessorDashboardStatsView
__all__ = [
'HomeView',
'CountryView',
@@ -15,6 +13,4 @@ __all__ = [
'CustomSwaggerView',
'SwaggerTokenAuthView',
'clear_swagger_auth',
- 'AdminDashboardStatsView',
- 'ProfessorDashboardStatsView'
]
diff --git a/apps/api/views/admin_dashboard.py b/apps/api/views/admin_dashboard.py
deleted file mode 100644
index cef7800..0000000
--- a/apps/api/views/admin_dashboard.py
+++ /dev/null
@@ -1,163 +0,0 @@
-from rest_framework.views import APIView
-from rest_framework.response import Response
-from rest_framework.permissions import IsAuthenticated, IsAdminUser
-from django.utils import timezone
-from datetime import timedelta
-from django.db.models import Count, Sum, Q
-from django.db.models.functions import TruncDate
-
-from apps.account.models import User, LoginHistory
-from apps.course.models import Course, Participant, CourseLiveSession
-from apps.course.models.course import extract_text_from_json
-from apps.quiz.models import QuizParticipant
-from apps.blog.models import Blog
-from apps.chat.models import ChatMessage, RoomMessage
-from apps.transaction.models import TransactionParticipant
-from apps.certificate.models import Certificate
-from rest_framework.authentication import TokenAuthentication
-
-class AdminDashboardStatsView(APIView):
- authentication_classes = [TokenAuthentication]
- permission_classes = [IsAuthenticated, IsAdminUser]
-
- def get(self, request):
- now = timezone.now()
- thirty_days_ago = now - timedelta(days=30)
-
- # ---------------------------------------------------------
- # 1. CARDS & BASIC STATS
- # ---------------------------------------------------------
- active_students = User.objects.filter(
- Q(user_type=User.UserType.STUDENT) | Q(user_type=User.UserType.CLIENT),
- is_active=True
- ).count()
- professors = User.objects.filter(user_type=User.UserType.PROFESSOR, is_active=True).count()
- active_courses = Course.objects.filter(status__contains=[{"title": Course.StatusChoices.ONGOING}]).count()
- total_blogs = Blog.objects.count()
- pending_certificates = Certificate.objects.filter(status='pending').count()
- active_live_sessions = CourseLiveSession.objects.filter(ended_at__isnull=True).count()
-
- # Revenue (30d sum of successful transactions)
- revenue_result = TransactionParticipant.objects.filter(
- status=TransactionParticipant.TransactionStatus.SUCCESS,
- created_at__gte=thirty_days_ago
- ).aggregate(total=Sum('price'))
- revenue_30d = float(revenue_result['total'] or 0.0)
-
- # Private vs Group Chat Usage
- chat_types = RoomMessage.objects.values('room_type').annotate(count=Count('id'))
- chat_type_stats = {item['room_type']: item['count'] for item in chat_types}
-
- # ---------------------------------------------------------
- # 2. CHARTS DATA
- # ---------------------------------------------------------
-
- # Payment Methods (Pie Chart)
- payment_methods = TransactionParticipant.objects.values('payment_method').annotate(total=Count('id'))
- payment_method_chart = [
- {"name": item['payment_method'], "value": item['total']}
- for item in payment_methods
- ]
-
- # Transaction Status (Donut Chart)
- transaction_status = TransactionParticipant.objects.values('status').annotate(total=Count('id'))
- transaction_chart = [
- {"name": item['status'], "value": item['total']}
- for item in transaction_status
- ]
-
- # Global Student Distribution (Bar Chart)
- top_countries = LoginHistory.objects.exclude(country__isnull=True).exclude(country="").values('country').annotate(
- count=Count('id')
- ).order_by('-count')[:5]
- country_chart = [{"country": item['country'], "students": item['count']} for item in top_countries]
-
- # New User Registrations Trend (Line Chart)
- registrations = User.objects.filter(date_joined__gte=thirty_days_ago).annotate(
- date=TruncDate('date_joined')
- ).values('date').annotate(count=Count('id')).order_by('date')
- registration_chart = [{"date": item['date'].strftime('%Y-%m-%d'), "users": item['count']} for item in registrations]
-
- # Community Chat Volume Trend (Area Chart)
- chat_volume = ChatMessage.objects.filter(sent_at__gte=thirty_days_ago).annotate(
- date=TruncDate('sent_at')
- ).values('date').annotate(count=Count('id')).order_by('date')
- chat_chart = [{"date": item['date'].strftime('%Y-%m-%d'), "messages": item['count']} for item in chat_volume]
-
- # Course Pipeline (Stacked Bar)
- course_pipeline = {"name": "Courses"}
- for choice in Course.StatusChoices.values:
- course_pipeline[choice] = Course.objects.filter(status__contains=[{"title": choice}]).count()
-
- # Course Efficacy: Completed vs Enrolled (Bar Chart)
- # We take the top 5 courses, check how many users are enrolled, and how many have at least 1 completed lesson
- top_courses_qs = Course.objects.annotate(participant_count=Count('participants')).order_by('-participant_count')[:5]
- course_efficacy = []
- for course in top_courses_qs:
- enrolled = course.participant_count
- completed = Participant.objects.filter(
- course=course,
- student__lesson_completions__course_lesson__course=course
- ).distinct().count()
- title_text = extract_text_from_json(course.title)
- course_efficacy.append({
- "title": title_text[:20] + "..." if len(title_text) > 20 else title_text,
- "enrolled": enrolled,
- "completed": completed
- })
-
- # ---------------------------------------------------------
- # 3. LISTS & LEADERBOARDS
- # ---------------------------------------------------------
-
- # Top 5 Courses (List)
- top_courses_list = [
- {
- "id": c.id,
- "title": extract_text_from_json(c.title),
- "professor": ", ".join(p.fullname for p in c.professors.all()) or "Unknown",
- "participants": c.participant_count
- } for c in top_courses_qs
- ]
-
- # Top 5 Blogs (List)
- top_blogs = Blog.objects.order_by('-views_count')[:5]
- top_blogs_list = [
- {"id": b.id, "title": b._extract_text_from_json(b.title), "views": b.views_count}
- for b in top_blogs
- ]
-
- # Top Performing Students (Leaderboard)
- top_students = QuizParticipant.objects.select_related('user').order_by('-total_score', 'total_timing')[:5]
- leaderboard = [
- {"id": s.user.id, "name": s.user.fullname, "score": s.total_score, "time": s.total_timing}
- for s in top_students
- ]
-
- return Response({
- "cards": {
- "active_students": active_students,
- "professors": professors,
- "active_courses": active_courses,
- "total_blogs": total_blogs,
- "revenue_30d": revenue_30d,
- "pending_certificates": pending_certificates,
- "active_live_sessions": active_live_sessions,
- "group_rooms": chat_type_stats.get('group', 0),
- "private_rooms": chat_type_stats.get('private', 0),
- },
- "charts": {
- "transactions_status": transaction_chart,
- "payment_methods": payment_method_chart,
- "countries": country_chart,
- "registrations": registration_chart,
- "chat_volume": chat_chart,
- "course_pipeline": [course_pipeline],
- "course_efficacy": course_efficacy
- },
- "lists": {
- "top_courses": top_courses_list,
- "top_blogs": top_blogs_list,
- "leaderboard": leaderboard
- }
- })
\ No newline at end of file
diff --git a/apps/api/views/professor_dashboard.py b/apps/api/views/professor_dashboard.py
deleted file mode 100644
index b5126ed..0000000
--- a/apps/api/views/professor_dashboard.py
+++ /dev/null
@@ -1,139 +0,0 @@
-from rest_framework.views import APIView
-from rest_framework.response import Response
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.authentication import TokenAuthentication
-from django.utils import timezone
-from datetime import timedelta
-from django.db.models import Count, Q
-from django.db.models.functions import TruncDate
-
-from apps.account.models import User
-from apps.course.models import Course, Participant
-from apps.course.models.course import extract_text_from_json
-from apps.quiz.models import Quiz, QuizParticipant
-from apps.chat.models import ChatMessage, RoomMessage
-from apps.api.permissions import IsProfessorUser
-
-
-class ProfessorDashboardStatsView(APIView):
- authentication_classes = [TokenAuthentication]
- permission_classes = [IsAuthenticated, IsProfessorUser]
-
- def get(self, request):
- now = timezone.now()
- thirty_days_ago = now - timedelta(days=30)
-
- # ---------------------------------------------------------
- # 1. CARDS & BASIC STATS
- # ---------------------------------------------------------
- # Total courses taught by this professor
- courses_count = Course.objects.filter(professors=request.user).count()
-
- # Total unique active students enrolled in this professor's courses
- students_count = Participant.objects.filter(
- course__professors=request.user,
- is_active=True
- ).values('student').distinct().count()
-
- # Total quizzes created in this professor's courses
- quizzes_count = Quiz.objects.filter(course__professors=request.user).count()
-
- # Chat rooms count (group rooms for professor's courses + private chats with professor)
- chat_rooms_count = RoomMessage.objects.filter(
- Q(course__professors=request.user) |
- (Q(room_type=RoomMessage.RoomTypeChoices.PRIVATE) & (Q(initiator=request.user) | Q(recipient=request.user)))
- ).distinct().count()
-
- # ---------------------------------------------------------
- # 2. CHARTS DATA
- # ---------------------------------------------------------
- # Chat Volume Trend (30 days)
- professor_rooms = RoomMessage.objects.filter(
- Q(course__professors=request.user) |
- (Q(room_type=RoomMessage.RoomTypeChoices.PRIVATE) & (Q(initiator=request.user) | Q(recipient=request.user)))
- )
- chat_volume = ChatMessage.objects.filter(
- room__in=professor_rooms,
- sent_at__gte=thirty_days_ago
- ).annotate(
- date=TruncDate('sent_at')
- ).values('date').annotate(count=Count('id')).order_by('date')
-
- chat_chart = [{"date": item['date'].strftime('%Y-%m-%d'), "messages": item['count']} for item in chat_volume]
-
- # Course Efficacy: Completed vs Enrolled (Bar Chart for Top 5 Courses)
- top_courses_qs = Course.objects.filter(professors=request.user).annotate(
- participant_count=Count('participants')
- ).order_by('-participant_count')[:5]
-
- course_efficacy = []
- for course in top_courses_qs:
- enrolled = course.participant_count
- completed = Participant.objects.filter(
- course=course,
- student__lesson_completions__course_lesson__course=course
- ).distinct().count()
- title_text = extract_text_from_json(course.title)
- course_efficacy.append({
- "title": title_text[:20] + "..." if len(title_text) > 20 else title_text,
- "enrolled": enrolled,
- "completed": completed
- })
-
- # Quiz Participation statistics (all quizzes of the professor)
- quizzes = Quiz.objects.filter(course__professors=request.user)
- quizzes_stats = []
- for quiz in quizzes:
- course = quiz.course
- if not course:
- continue
- total_enrolled = Participant.objects.filter(course=course, is_active=True).count()
- participated = QuizParticipant.objects.filter(quiz=quiz).values('user').distinct().count()
- not_participated = max(0, total_enrolled - participated)
- quizzes_stats.append({
- "id": quiz.id,
- "title": quiz.title,
- "course_title": extract_text_from_json(course.title),
- "participated": participated,
- "not_participated": not_participated,
- })
-
- # ---------------------------------------------------------
- # 3. LISTS & LEADERBOARDS
- # ---------------------------------------------------------
- # Top Courses List
- top_courses_list = [
- {
- "id": c.id,
- "title": extract_text_from_json(c.title),
- "participants": c.participant_count
- } for c in top_courses_qs
- ]
-
- # Leaderboard (Top 5 Performing Students in professor's quizzes)
- top_students = QuizParticipant.objects.filter(
- quiz__course__professors=request.user
- ).select_related('user').order_by('-total_score', 'total_timing')[:5]
-
- leaderboard = [
- {"id": s.user.id, "name": s.user.fullname, "score": s.total_score, "time": s.total_timing}
- for s in top_students
- ]
-
- return Response({
- "cards": {
- "courses_count": courses_count,
- "students_count": students_count,
- "quizzes_count": quizzes_count,
- "chat_rooms_count": chat_rooms_count,
- },
- "charts": {
- "chat_volume": chat_chart,
- "course_efficacy": course_efficacy,
- "quizzes_stats": quizzes_stats
- },
- "lists": {
- "top_courses": top_courses_list,
- "leaderboard": leaderboard
- }
- })
diff --git a/apps/blog/__init__.py b/apps/blog/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/blog/admin.py b/apps/blog/admin.py
deleted file mode 100644
index ed2bdba..0000000
--- a/apps/blog/admin.py
+++ /dev/null
@@ -1,130 +0,0 @@
-from django.contrib import admin
-from django.utils.translation import gettext_lazy as _
-from unfold.decorators import display
-from unfold.admin import ModelAdmin, TabularInline, StackedInline
-from unfold.contrib.forms.widgets import WysiwygWidget
-from unfold.widgets import UnfoldAdminTextareaWidget, UnfoldAdminTextInputWidget, UnfoldAdminExpandableTextareaWidget
-from utils.multilang_json_widget import MultiLanguageJSONWidget
-from django import forms
-from .models import Blog, BlogContent
-from utils.admin import project_admin_site
-
-class BlogContentForm(forms.ModelForm):
- """
- Custom form for BlogContent to use WysiwygWidget for content field
- """
- class Meta:
- model = BlogContent
- fields = '__all__'
- widgets = {
- 'title': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminExpandableTextareaWidget),
-
- }
-class BlogAdminForm(forms.ModelForm):
- class Meta:
- model = Blog
- fields = '__all__'
- widgets = {
- # You can switch between UnfoldAdminTextInputWidget, UnfoldAdminExpandableTextareaWidget,UnfoldAdminTextareaWidget or WysiwygWidget
- 'title': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminExpandableTextareaWidget),
- 'slogan': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminTextareaWidget),
- 'summary': MultiLanguageJSONWidget(input_widget_class=WysiwygWidget),
- 'slug': MultiLanguageJSONWidget(input_widget_class=UnfoldAdminTextInputWidget),
- }
- # ADD THIS METHOD:
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- # Explicitly tell the form these fields are required
- # so the admin template renders the red star
- self.fields['title'].required = True
- self.fields['slogan'].required = True
-
-
-class BlogContentInline(StackedInline):
- """
- Inline admin for BlogContent in Blog admin
- """
- model = BlogContent
- form = BlogContentForm
- extra = 1
- fields = ('title', 'content', 'slug', 'image', 'order')
- ordering = ['order']
-
-
-@admin.register(Blog, site=project_admin_site)
-class BlogAdmin(ModelAdmin):
- """
- Admin interface for Blog model using Django unfold
- """
- form = BlogAdminForm
- list_display = ('title_info', 'slogan_info', 'views_count', 'created_at', 'updated_at')
- list_filter = ('created_at', 'updated_at')
- search_fields = ('title', 'slogan', 'summary')
- # prepopulated_fields = {'slug': ('title',)}
- readonly_fields = ('views_count', 'created_at', 'updated_at')
-
- fieldsets = (
- (_('Basic Information'), {
- 'fields': ('title', 'slug', 'thumbnail', 'slogan')
- }),
- (_('Content'), {
- 'fields': ('summary',)
- }),
- (_('Statistics'), {
- 'fields': ('views_count',),
- 'classes': ('collapse',)
- }),
- (_('Timestamps'), {
- 'fields': ('created_at', 'updated_at'),
- 'classes': ('collapse',)
- }),
- )
-
- inlines = [BlogContentInline]
-
- @display(description=_('Title'))
- def title_info(self, obj):
- return obj._extract_text_from_json(obj.title)
-
- @display(description=_('Slogan'))
- def slogan_info(self, obj):
- return obj._extract_text_from_json(obj.slogan)
-
- def get_queryset(self, request):
- queryset = super().get_queryset(request)
- print(f'--get_queryset-->{queryset}')
- for blog in queryset:
- print(f'-get_queryset-blog-->{blog.title}')
- return queryset.prefetch_related('contents')
-
-
-@admin.register(BlogContent, site=project_admin_site)
-class BlogContentAdmin(ModelAdmin):
- """
- Admin interface for BlogContent model using Django unfold
- """
- form = BlogContentForm
- list_display = ('title_info', 'blog', 'order', 'created_at', 'updated_at')
- list_filter = ('blog', 'created_at', 'updated_at')
- search_fields = ('title', 'content', 'blog__title')
- list_select_related = ('blog',)
-
- fieldsets = (
- (_('Basic Information'), {
- 'fields': ('blog', 'title', 'slug', 'order')
- }),
- (_('Content'), {
- 'fields': ('content', 'image')
- }),
- (_('Timestamps'), {
- 'fields': ('created_at', 'updated_at'),
- 'classes': ('collapse',)
- }),
- )
-
- readonly_fields = ('created_at', 'updated_at')
-
-
- @display(description=_('Title'))
- def title_info(self, obj):
- return Blog._extract_text_from_json(obj.title)
\ No newline at end of file
diff --git a/apps/blog/apps.py b/apps/blog/apps.py
deleted file mode 100644
index 743ab47..0000000
--- a/apps/blog/apps.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from django.apps import AppConfig
-
-
-class BlogConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'apps.blog'
- verbose_name = 'Blog'
diff --git a/apps/blog/management/commands/fix_empty_blog_fields.py b/apps/blog/management/commands/fix_empty_blog_fields.py
deleted file mode 100644
index becdca3..0000000
--- a/apps/blog/management/commands/fix_empty_blog_fields.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from django.core.management.base import BaseCommand
-from apps.blog.models import Blog
-
-class Command(BaseCommand):
- help = 'Populate empty title and slogan fields in Blog records with default JSON structures.'
-
- def handle(self, *args, **kwargs):
- blogs = Blog.objects.all()
- updated_count = 0
-
- for blog in blogs:
- needs_update = False
-
- # Check if title is logically empty (None, [], {}, or "")
- if not blog.title:
- # Setting a default structure based on your model's docstring
- blog.title = [{"title": "Default Blog Title", "language_code": "en"}]
- needs_update = True
-
- # Check if slogan is logically empty
- if not blog.slogan:
- blog.slogan = [{"text": "Default Blog Slogan", "language_code": "en"}]
- needs_update = True
-
- if needs_update:
- # Use update_fields for performance and to prevent overriding other concurrent saves
- blog.save(update_fields=['title', 'slogan'])
- updated_count += 1
-
- self.stdout.write(self.style.SUCCESS(f'Successfully checked all blogs and updated {updated_count} records.'))
\ No newline at end of file
diff --git a/apps/blog/management/commands/seed_blog_data.py b/apps/blog/management/commands/seed_blog_data.py
deleted file mode 100644
index d958a0a..0000000
--- a/apps/blog/management/commands/seed_blog_data.py
+++ /dev/null
@@ -1,367 +0,0 @@
-import os
-import random
-import uuid
-from typing import List, Dict
-
-from django.conf import settings
-from django.core.management.base import BaseCommand
-from django.core.files import File
-
-from apps.blog.models import Blog, BlogContent
-
-
-def build_multilang_list(values: Dict[str, str], value_key: str = "title") -> List[Dict[str, str]]:
- """
- Convert a dict like {'en': '...', 'fa': '...', 'ru': '...'} into the project's
- JSONField list schema: [{'language_code': 'en', 'title': '...'}, ...]
- value_key controls whether we store under 'title' (for titles) or 'text' (for content).
- """
- return [{"language_code": code, value_key: text} for code, text in values.items()]
-
-
-def get_seed_images() -> List[str]:
- """
- Load available image file paths from BASE_DIR/seeds/images/
- """
- base = os.path.join(settings.BASE_DIR, "seeds", "images")
- if not os.path.isdir(base):
- return []
- files = []
- for name in os.listdir(base):
- lower = name.lower()
- if lower.endswith((".jpg", ".jpeg", ".png", ".webp")):
- files.append(os.path.join(base, name))
- return files
-
-
-def pick_image_path(images: List[str]) -> str:
- """
- Randomly pick an image path from the provided list.
- """
- if not images:
- return ""
- return random.choice(images)
-
-
-def generate_topics() -> List[Dict[str, Dict[str, str]]]:
- """
- Build 20 topics based on prophets and imams to satisfy the requested domains.
- Each topic is a mapping for three languages: en, fa, ru.
- """
- prophets = [
- {"en": "Prophet Muhammad", "fa": "حضرت محمد (ص)", "ru": "Пророк Мухаммад"},
- {"en": "Prophet Musa", "fa": "حضرت موسی (ع)", "ru": "Пророк Муса"},
- {"en": "Prophet Isa", "fa": "حضرت عیسی (ع)", "ru": "Пророк Иса"},
- {"en": "Prophet Ibrahim", "fa": "حضرت ابراهیم (ع)", "ru": "Пророк Ибрахим"},
- {"en": "Prophet Nuh", "fa": "حضرت نوح (ع)", "ru": "Пророк Нух"},
- {"en": "Prophet Yusuf", "fa": "حضرت یوسف (ع)", "ru": "Пророк Юсуф"},
- {"en": "Prophet Yaqub", "fa": "حضرت یعقوب (ع)", "ru": "Пророк Якуб"},
- {"en": "Prophet Dawud", "fa": "حضرت داوود (ع)", "ru": "Пророк Давуд"},
- ]
- imams = [
- {"en": "Imam Ali", "fa": "امام علی (ع)", "ru": "Имам Али"},
- {"en": "Imam Hasan", "fa": "امام حسن (ع)", "ru": "Имам Хасан"},
- {"en": "Imam Husayn", "fa": "امام حسین (ع)", "ru": "Имам Хусейн"},
- {"en": "Imam Sajjad", "fa": "امام سجاد (ع)", "ru": "Имам Саджад"},
- {"en": "Imam Baqir", "fa": "امام باقر (ع)", "ru": "Имам Бакир"},
- {"en": "Imam Sadiq", "fa": "امام صادق (ع)", "ru": "Имам Садык"},
- {"en": "Imam Kadhim", "fa": "امام کاظم (ع)", "ru": "Имам Казим"},
- {"en": "Imam Reza", "fa": "امام رضا (ع)", "ru": "Имам Реза"},
- {"en": "Imam Jawad", "fa": "امام جواد (ع)", "ru": "Имам Джавад"},
- {"en": "Imam Hadi", "fa": "امام هادی (ع)", "ru": "Имам Хади"},
- {"en": "Imam Askari", "fa": "امام عسکری (ع)", "ru": "Имам Аскари"},
- {"en": "Imam Mahdi", "fa": "امام مهدی (عج)", "ru": "Имам Махди"},
- ]
- topics = prophets + imams
- return topics[:20]
-
-
-def content_sections(name_en: str, name_fa: str, name_ru: str) -> List[Dict[str, Dict[str, str]]]:
- """
- Build 10 narrative anecdotal content sections per blog, tailored to the blog's subject (prophet/imam),
- with rich multilingual texts (fa, en, ru). Each section is a self-contained story (حکایت/История).
- """
- sections = []
-
- sections.append({
- "title": {
- "en": f"Anecdote: Early Life Kindness of {name_en}",
- "fa": f"حکایت: مهربانی در کودکی {name_fa}",
- "ru": f"История: Доброе сердце в детстве {name_ru}",
- },
- "text": {
- "en": f"As a child, {name_en} was noted for uncommon kindness. One cold morning a neighbor had no bread, "
- f"so {name_en} shared the family portion and said, 'Provision grows when shared.' "
- f"The town remembered this as a lesson that compassion is the seed of community.",
- "fa": f"{name_fa} از همان کودکی به مهربانی شناخته میشد. صبحی سرد، همسایهای نان نداشت؛ "
- f"{name_fa} سهم خانواده را بخشید و گفت: «روزی وقتی تقسیم شود، افزون میگردد.» "
- f"آن رفتار درسی شد برای شهر که شفقت، بذر اجتماع است.",
- "ru": f"С детства {name_ru} отличался редкой добротой. В холодное утро у соседа не было хлеба, "
- f"и {name_ru} поделился семейной долей, сказав: «Истинный удел умножается, когда им делятся». "
- f"Так люди усвоили урок о сострадании как основе общины.",
- },
- })
-
- sections.append({
- "title": {
- "en": f"Anecdote: First Signs of Wisdom of {name_en}",
- "fa": f"حکایت: نشانههای نخستین حکمت {name_fa}",
- "ru": f"История: Первые признаки мудрости {name_ru}",
- },
- "text": {
- "en": f"In youth, a dispute arose over a simple matter. While others raised their voices, "
- f"{name_en} asked both sides to repeat their words slowly. "
- f"By listening with fairness, {name_en} settled the matter gently and taught that calm clarity reveals truth.",
- "fa": f"در جوانی، نزاعی بر سر مسئلهای ساده درگرفت. هنگامی که دیگران صدا بلند کرده بودند، "
- f"{name_fa} از هر دو طرف خواست آرام و دقیق سخن بگویند. "
- f"با گوش سپردن منصفانه، نزاع به نرمی پایان یافت و روشن شد که آرامش، حقیقت را آشکار میکند.",
- "ru": f"В юности возник спор по пустяку. Пока голоса накалялись, "
- f"{name_ru} попросил обе стороны говорить медленно и ясно. "
- f"Выслушав справедливо, {name_ru} примирил спорящих и показал, что спокойная ясность открывает истину.",
- },
- })
-
- sections.append({
- "title": {
- "en": f"Anecdote: Compassion for the Poor by {name_en}",
- "fa": f"حکایت: شفقت بر نیازمندان از سوی {name_fa}",
- "ru": f"История: Сострадание к нуждающимся от {name_ru}",
- },
- "text": {
- "en": f"A traveler arrived hungry and ashamed. {name_en} prepared food with their own hands and invited the traveler "
- f"to sit as an honored guest. People learned that dignity grows where compassion leads.",
- "fa": f"مسافری گرسنه و شرمسار فرا رسید. {name_fa} خود دست به کار شد، طعامی مهیا کرد و مسافر را "
- f"چون مهمانی گرامی نشاند. مردم آموختند که کرامت، در سایهٔ پیشگامیِ شفقت میروید.",
- "ru": f"Пришел путник голодный и смущенный. {name_ru} собственноручно приготовил еду и усадил его как почётного гостя. "
- f"Люди поняли, что достоинство расцветает там, где впереди идет сострадание.",
- },
- })
-
- sections.append({
- "title": {
- "en": f"Anecdote: Patience Under Trial of {name_en}",
- "fa": f"حکایت: صبر در امتحان {name_fa}",
- "ru": f"История: Терпение в испытании {name_ru}",
- },
- "text": {
- "en": f"Hard days came with whispers and blame. {name_en} answered with patience, refusing to return harshness with harshness. "
- f"In time, those who criticized felt softened and sought forgiveness.",
- "fa": f"روزهای دشوار با زمزمهها و سرزنشها همراه شد. {name_fa} با صبر پاسخ گفت و به تندی، تندی نکرد. "
- f"با گذر زمان، دلِ ملامتگران نرم شد و پوزش خواستند.",
- "ru": f"Настали трудные дни с шепотом упреков. {name_ru} отвечал терпением и не платил жесткостью за жесткость. "
- f"Со временем сердца порицавших смягчились, и они попросили прощения.",
- },
- })
-
- sections.append({
- "title": {
- "en": f"Anecdote: Justice in a Dispute by {name_en}",
- "fa": f"حکایت: عدالت در یک نزاع به روایت {name_fa}",
- "ru": f"История: Справедливость в споре у {name_ru}",
- },
- "text": {
- "en": f"Two neighbors quarreled over a wall. {name_en} measured the ground, heard each claim, and decided "
- f"with equity—neither fully winning nor losing. They accepted, seeing justice as balance, not bias.",
- "fa": f"دو همسایه بر سر دیواری به نزاع افتادند. {name_fa} زمین را اندازه گرفت، سخن هر دو را شنید "
- f"و به گونهای حکم کرد که نه این پیروزِ مطلق باشد و نه آن؛ عدالت را توازن دیدند نه جانبداری.",
- "ru": f"Двое соседей спорили из‑за стены. {name_ru} измерил участок, выслушал обе стороны и вынес решение, "
- f"где ни один не выиграл полностью и не проиграл. Так они увидели справедливость как равновесие, а не пристрастие.",
- },
- })
-
- sections.append({
- "title": {
- "en": f"Anecdote: A Miraculous Sign with {name_en}",
- "fa": f"حکایت: نشانهای شگفت با {name_fa}",
- "ru": f"История: Чудесный знак с {name_ru}",
- },
- "text": {
- "en": f"In a moment of fear, a small sign appeared—unexpected help arrived at the right time. "
- f"People said, 'It was a mercy,' and {name_en} reminded them that signs awaken gratitude and responsibility.",
- "fa": f"در لحظهای هراسانگیز، نشانهای پدیدار شد؛ یاریِ ناگهانی در زمانِ درست. "
- f"مردم گفتند: «رحمتی بود»، و {name_fa} یادآور شد که نشانهها سپاس و مسئولیت میآموزند.",
- "ru": f"В миг страха явился маленький знак — помощь пришла вовремя. "
- f"Люди сказали: «Это была милость», а {name_ru} напомнил, что знамения пробуждают благодарность и ответственность.",
- },
- })
-
- sections.append({
- "title": {
- "en": f"Anecdote: Teaching with Gentle Words of {name_en}",
- "fa": f"حکایت: تعلیم با سخن نرم از {name_fa}",
- "ru": f"История: Наставление мягким словом от {name_ru}",
- },
- "text": {
- "en": f"A young student erred while reading. {name_en} corrected without humiliation, "
- f"explaining with care until understanding bloomed. Knowledge, they said, enters where hearts feel safe.",
- "fa": f"شاگردی در خواندن خطا کرد. {name_fa} بیآنکه او را خوار کند، با دلسوزی توضیح داد تا فهم شکوفا شد. "
- f"گفت: دانش، جایی وارد میشود که دلها امن باشند.",
- "ru": f"Юный ученик ошибся в чтении. {name_ru} исправил без унижения и терпеливо объяснил, пока не пришло понимание. "
- f"Знание входит туда, где сердце в безопасности.",
- },
- })
-
- sections.append({
- "title": {
- "en": f"Anecdote: Night Prayer and Humility of {name_en}",
- "fa": f"حکایت: نماز شب و فروتنی {name_fa}",
- "ru": f"История: Ночная молитва и смирение {name_ru}",
- },
- "text": {
- "en": f"In the stillness of the night, {name_en} stood in prayer, whispering gratitude and seeking guidance. "
- f"Those who saw learned that inner strength is born from humble devotion.",
- "fa": f"در سکوت شب، {name_fa} به نماز ایستاد؛ شکر میگفت و راه میجست. "
- f"بینندگان آموختند که قوت درون از بندگی فروتنانه زاده میشود.",
- "ru": f"В тишине ночи {name_ru} стоял в молитве, шепча благодарность и прося наставления. "
- f"Те, кто видел, поняли: внутренняя сила рождается из смиренного поклонения.",
- },
- })
-
- sections.append({
- "title": {
- "en": f"Anecdote: Generosity Without Expectation by {name_en}",
- "fa": f"حکایت: بخشش بیمنت از {name_fa}",
- "ru": f"История: Щедрость без ожиданий от {name_ru}",
- },
- "text": {
- "en": f"A poor family hid their need out of modesty. {name_en} discreetly sent provisions for days, "
- f"asking no thanks. True giving, they taught, seeks no witness but the All‑Seeing.",
- "fa": f"خانوادهای نیاز خود را از شرم پنهان میکردند. {name_fa} بیصدا آذوقهٔ چند روزشان را رساند "
- f"و هیچ سپاسی نخواست؛ آموخت که بخششِ راستین، جز دیدهٔ حق گواهی نمیطلبد.",
- "ru": f"Бедная семья скрывала нужду из скромности. {name_ru} тайно прислал им припасы на несколько дней "
- f"и не просил благодарности. Истинная щедрость не ищет свидетелей, кроме Всевидящего.",
- },
- })
-
- sections.append({
- "title": {
- "en": f"Anecdote: Legacy That Inspires of {name_en}",
- "fa": f"حکایت: میراث الهامبخشِ {name_fa}",
- "ru": f"История: Наследие, которое вдохновляет {name_ru}",
- },
- "text": {
- "en": f"Years later, children repeated the sayings of {name_en} and neighbors kept the customs of mercy, justice, and truth. "
- f"The legacy was not stone or gold, but transformed hearts.",
- "fa": f"سالها بعد، کودکان سخنانِ {name_fa} را بازمیگفتند و همسایگان آیینِ رحمت، عدالت و راستی را نگه میداشتند. "
- f"میراث، سنگ و زر نبود؛ دلهای دگرگونشده بود.",
- "ru": f"Спустя годы дети повторяли изречения {name_ru}, а соседи хранили обычаи милости, справедливости и истины. "
- f"Их наследие было не в камне и золоте, а в преображенных сердцах.",
- },
- })
-
- return sections
-
-
-class Command(BaseCommand):
- help = "Seed 20 blogs with 10 related contents each in fa, en, ru languages. Images are randomly assigned from seeds/images."
-
- def add_arguments(self, parser):
- parser.add_argument("--blogs", type=int, default=20, help="Number of blogs to create")
- parser.add_argument("--contents", type=int, default=10, help="Number of contents per blog")
- parser.add_argument("--commit", action="store_true", help="Persist changes to the database. If omitted, runs in dry-run mode.")
- parser.add_argument("--images-dir", type=str, default="", help="Override images directory (defaults to BASE_DIR/seeds/images)")
-
- def handle(self, *args, **options):
- blogs_count = int(options.get("blogs") or 20)
- contents_count = int(options.get("contents") or 10)
- commit = bool(options.get("commit"))
- images_dir_opt = options.get("images_dir")
-
- # Load image candidates
- images = []
- if images_dir_opt:
- base = images_dir_opt
- if os.path.isdir(base):
- for name in os.listdir(base):
- lower = name.lower()
- if lower.endswith((".jpg", ".jpeg", ".png", ".webp")):
- images.append(os.path.join(base, name))
- else:
- images = get_seed_images()
-
- if not images:
- self.stdout.write(self.style.WARNING("No seed images found under seeds/images/. Thumbnails and content images will be empty."))
-
- topics = generate_topics()
- if blogs_count > len(topics):
- blogs_count = len(topics)
-
- created_blogs = 0
- created_contents = 0
-
- for idx in range(blogs_count):
- topic = topics[idx]
- name_en = topic["en"]
- name_fa = topic["fa"]
- name_ru = topic["ru"]
-
- title_values = {"en": f"Biography: {name_en}", "fa": f"زندگینامه: {name_fa}", "ru": f"Биография: {name_ru}"}
- slogan_values = {"en": f"Stories and lessons from {name_en}", "fa": f"حکایتها و درسها از {name_fa}", "ru": f"Истории и уроки о {name_ru}"}
- summary_values = {
- "en": f"A curated collection of chapters about {name_en}, covering life, teachings, and legacy.",
- "fa": f"مجموعهای منتخب از فصلها درباره {name_fa} شامل زندگی، تعالیم و میراث.",
- "ru": f"Подборка глав о {name_ru}, охватывающих жизнь, учение и наследие.",
- }
-
- blog = Blog(
- title=build_multilang_list(title_values, "title"),
- slogan=build_multilang_list(slogan_values, "title"),
- summary=build_multilang_list(summary_values, "text"),
- )
-
- # Assign a random thumbnail image if available
- thumb_path = pick_image_path(images)
- if thumb_path:
- ext = os.path.splitext(thumb_path)[1].lower()
- fname = f"seed_thumb_{uuid.uuid4().hex}{ext}"
- if commit:
- with open(thumb_path, "rb") as f:
- blog.thumbnail.save(fname, File(f), save=False)
- else:
- # Dry-run: simulate
- blog.thumbnail.name = os.path.join("blog/thumbnails", fname)
-
- self.stdout.write(f"[{'COMMIT' if commit else 'DRY'}] Preparing blog {idx+1}: {name_en}")
-
- contents_payload = content_sections(name_en, name_fa, name_ru)
- # Limit to requested count
- contents_payload = contents_payload[:contents_count]
-
- if commit:
- blog.save()
- created_blogs += 1
-
- # Create related contents
- order = 1
- for section in contents_payload:
- title_list = build_multilang_list(section["title"], "title")
- text_list = build_multilang_list(section["text"], "text")
- content_image_path = pick_image_path(images)
- bc = BlogContent(
- blog=blog,
- title=title_list,
- content=text_list,
- slug=title_list, # allow slug generation from multilingual titles
- order=order,
- )
- order += 1
-
- if content_image_path:
- ext = os.path.splitext(content_image_path)[1].lower()
- fname = f"seed_content_{uuid.uuid4().hex}{ext}"
- if commit:
- with open(content_image_path, "rb") as f:
- bc.image.save(fname, File(f), save=False)
- else:
- bc.image = None # do not assign filesystem in dry-run
-
- if commit:
- bc.save()
- created_contents += 1
-
- self.stdout.write(self.style.SUCCESS(f"Prepared {len(contents_payload)} contents for blog '{name_en}'"))
-
- mode = "COMMIT" if commit else "DRY-RUN"
- self.stdout.write(self.style.SUCCESS(f"{mode} finished. Blogs prepared: {created_blogs}, Contents prepared: {created_contents}"))
- if not commit:
- self.stdout.write(self.style.WARNING("Run again with --commit to persist the changes."))
\ No newline at end of file
diff --git a/apps/blog/migrations/0001_initial.py b/apps/blog/migrations/0001_initial.py
deleted file mode 100644
index d8ba500..0000000
--- a/apps/blog/migrations/0001_initial.py
+++ /dev/null
@@ -1,238 +0,0 @@
-# Generated by Django 4.2.27 on 2026-01-22 10:48
-
-import dj_language.field
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = [
- ("dj_language", "0002_auto_20220120_1344"),
- ]
-
- operations = [
- migrations.CreateModel(
- name="Blog",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "title",
- models.JSONField(
- blank=True, default=list, null=True, verbose_name="title"
- ),
- ),
- (
- "thumbnail",
- models.ImageField(
- help_text="Blog thumbnail image",
- upload_to="blog/thumbnails/%Y/%m/",
- verbose_name="Thumbnail",
- ),
- ),
- (
- "slogan",
- models.JSONField(
- blank=True, default=list, null=True, verbose_name="slogan"
- ),
- ),
- (
- "summary",
- models.JSONField(
- blank=True, default=list, null=True, verbose_name="summary"
- ),
- ),
- (
- "views_count",
- models.PositiveIntegerField(
- default=0,
- help_text="Number of times this blog was viewed",
- verbose_name="Views Count",
- ),
- ),
- (
- "slug",
- models.JSONField(
- blank=True,
- default=list,
- help_text="URL slug for the blog",
- null=True,
- verbose_name="slug",
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- ],
- options={
- "verbose_name": "Blog",
- "verbose_name_plural": "Blogs",
- "ordering": ["-created_at"],
- },
- ),
- migrations.CreateModel(
- name="BlogSeo",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "title",
- models.CharField(
- blank=True,
- help_text="maximum length of page title is 70 characters and minimum length is 30",
- max_length=140,
- null=True,
- verbose_name="seo title",
- ),
- ),
- (
- "keywords",
- models.CharField(
- blank=True,
- help_text="keywords in the content that make it possible for people to find the site via search engines",
- max_length=700,
- null=True,
- ),
- ),
- (
- "description",
- models.CharField(
- blank=True,
- help_text="describes and summarizes the contents of the page for the benefit of users and search engines",
- max_length=170,
- null=True,
- ),
- ),
- (
- "blog",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="seos",
- to="blog.blog",
- verbose_name="blog",
- ),
- ),
- (
- "language",
- dj_language.field.LanguageField(
- default=69,
- limit_choices_to={"status": True},
- null=True,
- on_delete=django.db.models.deletion.PROTECT,
- related_name="+",
- to="dj_language.language",
- verbose_name="language",
- ),
- ),
- ],
- options={
- "verbose_name": "Blog SEO",
- "verbose_name_plural": "Blog SEOs",
- },
- ),
- migrations.CreateModel(
- name="BlogContent",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "title",
- models.JSONField(
- blank=True,
- default=list,
- help_text="Title of this content section",
- null=True,
- verbose_name="Content title",
- ),
- ),
- (
- "content",
- models.JSONField(
- blank=True,
- default=list,
- help_text="The main content text",
- null=True,
- verbose_name="content",
- ),
- ),
- (
- "slug",
- models.JSONField(
- blank=True,
- default=list,
- help_text="URL slug for this content (optional)",
- null=True,
- verbose_name="slug",
- ),
- ),
- (
- "image",
- models.ImageField(
- blank=True,
- help_text="Optional image for this content section",
- null=True,
- upload_to="blog/content_images/%Y/%m/",
- verbose_name="Image",
- ),
- ),
- (
- "order",
- models.PositiveIntegerField(
- default=0,
- help_text="Order of this content within the blog",
- verbose_name="Order",
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- (
- "blog",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="contents",
- to="blog.blog",
- verbose_name="Blog",
- ),
- ),
- ],
- options={
- "verbose_name": "Blog Content",
- "verbose_name_plural": "Blog Contents",
- "ordering": ["order", "created_at"],
- },
- ),
- ]
diff --git a/apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py b/apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py
deleted file mode 100644
index b129cf1..0000000
--- a/apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Generated by Django 5.2.12 on 2026-04-26 11:40
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('blog', '0001_initial'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='blog',
- name='slogan',
- field=models.JSONField(default=list, verbose_name='slogan'),
- ),
- migrations.AlterField(
- model_name='blog',
- name='title',
- field=models.JSONField(default=list, verbose_name='title'),
- ),
- ]
diff --git a/apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py b/apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py
deleted file mode 100644
index 52a48a9..0000000
--- a/apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py
+++ /dev/null
@@ -1,76 +0,0 @@
-# Generated by Django 5.2.12 on 2026-05-03 14:09
-
-import dj_language.field
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('blog', '0002_alter_blog_slogan_alter_blog_title'),
- ('dj_language', '0002_auto_20220120_1344'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='blog',
- name='slogan',
- field=models.JSONField(default=list, verbose_name='Slogan'),
- ),
- migrations.AlterField(
- model_name='blog',
- name='slug',
- field=models.JSONField(blank=True, default=list, help_text='URL slug for the blog', null=True, verbose_name='Slug'),
- ),
- migrations.AlterField(
- model_name='blog',
- name='summary',
- field=models.JSONField(blank=True, default=list, null=True, verbose_name='Summary'),
- ),
- migrations.AlterField(
- model_name='blog',
- name='title',
- field=models.JSONField(default=list, verbose_name='Title'),
- ),
- migrations.AlterField(
- model_name='blogcontent',
- name='content',
- field=models.JSONField(blank=True, default=list, help_text='The main content text', null=True, verbose_name='Content'),
- ),
- migrations.AlterField(
- model_name='blogcontent',
- name='slug',
- field=models.JSONField(blank=True, default=list, help_text='URL slug for this content (optional)', null=True, verbose_name='Slug'),
- ),
- migrations.AlterField(
- model_name='blogcontent',
- name='title',
- field=models.JSONField(blank=True, default=list, help_text='Title of this content section', null=True, verbose_name='Content Title'),
- ),
- migrations.AlterField(
- model_name='blogseo',
- name='blog',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seos', to='blog.blog', verbose_name='Blog'),
- ),
- migrations.AlterField(
- model_name='blogseo',
- name='description',
- field=models.CharField(blank=True, help_text='describes and summarizes the contents of the page for the benefit of users and search engines', max_length=170, null=True, verbose_name='Description'),
- ),
- migrations.AlterField(
- model_name='blogseo',
- name='keywords',
- field=models.CharField(blank=True, help_text='keywords in the content that make it possible for people to find the site via search engines', max_length=700, null=True, verbose_name='Keywords'),
- ),
- migrations.AlterField(
- model_name='blogseo',
- name='language',
- field=dj_language.field.LanguageField(default=69, limit_choices_to={'status': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dj_language.language', verbose_name='Language'),
- ),
- migrations.AlterField(
- model_name='blogseo',
- name='title',
- field=models.CharField(blank=True, help_text='maximum length of page title is 70 characters and minimum length is 30', max_length=140, null=True, verbose_name='SEO Title'),
- ),
- ]
diff --git a/apps/blog/migrations/__init__.py b/apps/blog/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/blog/models.py b/apps/blog/models.py
deleted file mode 100644
index 95bd584..0000000
--- a/apps/blog/models.py
+++ /dev/null
@@ -1,207 +0,0 @@
-from django.db import models
-from django.utils.translation import gettext_lazy as _
-from utils import generate_slug_for_model, generate_language_slugs
-from dj_language.models import Language
-from unfold.contrib.forms.widgets import ArrayWidget
-from dj_language.field import LanguageField
-
-class Blog(models.Model):
- """
- Blog model with title, thumbnail, slogan, summary, views count and timestamps
- """
- title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Title')) # [{"title": "", "language_code": "en"},{"title": "", "language_code": "fa"},...]
-
- thumbnail = models.ImageField(
- upload_to='blog/thumbnails/%Y/%m/',
- verbose_name=_('Thumbnail'),
- help_text=_('Blog thumbnail image')
- )
- slogan = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Slogan'))
-
- summary = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Summary'))
-
- views_count = models.PositiveIntegerField(
- default=0,
- verbose_name=_('Views Count'),
- help_text=_('Number of times this blog was viewed')
- )
-
- slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Slug'), help_text=_('URL slug for the blog'))
-
-
- created_at = models.DateTimeField(
- auto_now_add=True,
- verbose_name=_('Created At')
- )
- updated_at = models.DateTimeField(
- auto_now=True,
- verbose_name=_('Updated At')
- )
-
- class Meta:
- ordering = ['-created_at']
- verbose_name = _('Blog')
- verbose_name_plural = _('Blogs')
-
- def __str__(self):
- text = self._extract_text_from_json(self.title)
- if text:
- return text
- return f"Blog #{self.pk}" if self.pk else "Blog"
-
- @staticmethod
- def _extract_text_from_json(value):
- if not value:
- return ""
-
- # cases: list of dicts, list of strings, dict mapping, plain string
- if isinstance(value, list):
- for item in value:
- if isinstance(item, dict):
- text = item.get('title') or item.get('value') or item.get('text')
- if text:
- return str(text)
- else:
- if item:
- return str(item)
- return ""
- if isinstance(value, dict):
- # Prefer common language codes if present
- for lang in ("fa", "en", "ru"):
- if lang in value and value[lang]:
- v = value[lang]
- if isinstance(v, dict):
- return str(v.get('title') or v.get('value') or v.get('text') or "")
- return str(v)
- # Fallback to first non-empty value
- for v in value.values():
- if isinstance(v, dict):
- txt = v.get('title') or v.get('value') or v.get('text')
- if txt:
- return str(txt)
- elif v:
- return str(v)
- return ""
- if isinstance(value, (str, int, float)):
- return str(value)
- return ""
-
- def increment_view_count(self):
- """Increment the view count by 1"""
- self.views_count += 1
- self.save(update_fields=['views_count'])
- return self.views_count
-
- def get_seo_for_language(self, language_code):
- try:
- seo_field_object = self.seos.filter(language__code=language_code).first()
- if seo_field_object:
- return {
- "title": seo_field_object.title,
- "description": seo_field_object.description,
- }
- return None
- except Exception:
- return None
-
- def get_blog_filed(self, lang, blog_field):
- try:
- from apps.course.models.course import get_localized_field
-
- return get_localized_field(lang, blog_field)
- except Exception as exp:
- print(f'---> Error in get_blog_filed: {exp}')
- return None
-
- def save(self, *args, **kwargs):
- if not self.slug:
- try:
- self.slug = generate_language_slugs(self.title)
- except Exception:
- self.slug = []
- super().save(*args, **kwargs)
-
-
-class BlogContent(models.Model):
- """
- BlogContent model related to Blog with title, content, slug, image, order and timestamps
- """
- blog = models.ForeignKey(
- Blog,
- on_delete=models.CASCADE,
- related_name='contents',
- verbose_name=_('Blog')
- )
- title = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Content Title'), help_text=_('Title of this content section'))
- content = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Content'), help_text=_('The main content text'))
- slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Slug'), help_text=_('URL slug for this content (optional)'))
-
- image = models.ImageField(
- upload_to='blog/content_images/%Y/%m/',
- null=True,
- blank=True,
- verbose_name=_('Image'),
- help_text=_('Optional image for this content section')
- )
- order = models.PositiveIntegerField(
- default=0,
- verbose_name=_('Order'),
- help_text=_('Order of this content within the blog')
- )
-
- created_at = models.DateTimeField(
- auto_now_add=True,
- verbose_name=_('Created At')
- )
- updated_at = models.DateTimeField(
- auto_now=True,
- verbose_name=_('Updated At')
- )
-
- class Meta:
- ordering = ['order', 'created_at']
- verbose_name = _('Blog Content')
- verbose_name_plural = _('Blog Contents')
- # unique_together = ['blog', 'order']
-
- def __str__(self):
- title_text = Blog._extract_text_from_json(self.title)
- if title_text:
- return title_text
- blog_text = Blog._extract_text_from_json(self.blog.title) if self.blog_id else "Blog"
- return f"{blog_text} - Content #{self.pk or ''}".strip()
-
- def save(self, *args, **kwargs):
- try:
- self.slug = generate_language_slugs(self.slug)
- except Exception:
- pass
- super().save(*args, **kwargs)
-
-
-class BlogSeo(models.Model):
- blog = models.ForeignKey(Blog, on_delete=models.CASCADE, related_name='seos', verbose_name=_('Blog'))
- title = models.CharField(
- _('SEO Title'), max_length=140, null=True, blank=True,
- help_text=_('maximum length of page title is 70 characters and minimum length is 30'),
- )
- keywords = models.CharField(
- _('Keywords'),
- max_length=700, null=True, blank=True,
- help_text=_('keywords in the content that make it possible for people to find the site via search engines')
- )
- description = models.CharField(
- _('Description'),
- max_length=170, null=True, blank=True,
- help_text=_('describes and summarizes the contents of the page for the benefit of users and search engines'),
- )
- language = LanguageField(null=True, verbose_name=_('Language'))
-
-
- class Meta:
- verbose_name = _('Blog SEO')
- verbose_name_plural = _('Blog SEOs')
-
- def __str__(self):
- lang = getattr(self.language, 'code', None) if self.language else None
- return f"SEO({lang or '-'}) - {self.title or ''}"
diff --git a/apps/blog/serializers.py b/apps/blog/serializers.py
deleted file mode 100644
index 02ea4ba..0000000
--- a/apps/blog/serializers.py
+++ /dev/null
@@ -1,142 +0,0 @@
-from rest_framework import serializers
-from utils import FileFieldSerializer
-from .models import Blog, BlogContent
-
-
-class BlogContentSerializer(serializers.ModelSerializer):
- """
- Serializer for BlogContent model with all details
- """
- image = FileFieldSerializer(required=False, allow_null=True)
- title = serializers.SerializerMethodField()
- content = serializers.SerializerMethodField()
- slug = serializers.SerializerMethodField()
-
- class Meta:
- model = BlogContent
- fields = [
- 'id',
- 'title',
- 'content',
- 'slug',
- 'image',
- 'order',
- 'created_at',
- 'updated_at'
- ]
- read_only_fields = ['id', 'created_at', 'updated_at']
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_title(self, obj: BlogContent):
- return obj.blog.get_blog_filed(self._lang(), obj.title)
-
- def get_content(self, obj: BlogContent):
- return obj.blog.get_blog_filed(self._lang(), obj.content)
-
- def get_slug(self, obj: BlogContent):
- return obj.blog.get_blog_filed(self._lang(), obj.slug)
-
-
-class BlogListSerializer(serializers.ModelSerializer):
- """
- Serializer for Blog list view with file field for thumbnail
- """
- thumbnail = FileFieldSerializer(required=False)
- title = serializers.SerializerMethodField()
- slogan = serializers.SerializerMethodField()
- summary = serializers.SerializerMethodField()
- slug = serializers.SerializerMethodField()
- seo = serializers.SerializerMethodField()
-
- class Meta:
- model = Blog
- fields = [
- 'id',
- 'title',
- 'thumbnail',
- 'slogan',
- 'summary',
- 'views_count',
- 'slug',
- 'seo',
- 'created_at',
- 'updated_at'
- ]
- read_only_fields = ['id', 'views_count', 'created_at', 'updated_at']
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_title(self, obj: Blog):
- return obj.get_blog_filed(self._lang(), obj.title)
-
- def get_slogan(self, obj: Blog):
- return obj.get_blog_filed(self._lang(), obj.slogan)
-
- def get_summary(self, obj: Blog):
- return obj.get_blog_filed(self._lang(), obj.summary)
-
- def get_slug(self, obj: Blog):
- return obj.get_blog_filed(self._lang(), obj.slug)
-
- def get_seo(self, obj: Blog):
- return obj.get_seo_for_language(self._lang())
-
-
-class BlogDetailSerializer(serializers.ModelSerializer):
- """
- Serializer for Blog detail view with related BlogContent
- """
- thumbnail = FileFieldSerializer(required=False)
- contents = serializers.SerializerMethodField()
- title = serializers.SerializerMethodField()
- slogan = serializers.SerializerMethodField()
- summary = serializers.SerializerMethodField()
- slug = serializers.SerializerMethodField()
- seo = serializers.SerializerMethodField()
-
- class Meta:
- model = Blog
- fields = [
- 'id',
- 'title',
- 'thumbnail',
- 'slogan',
- 'summary',
- 'views_count',
- 'slug',
- 'seo',
- 'created_at',
- 'updated_at',
- 'contents'
- ]
- def get_contents(self, obj: Blog):
- # Pass down context (request) to nested serializer
- ser = BlogContentSerializer(obj.contents.all().order_by('order'), many=True, context=self.context)
- return ser.data
- read_only_fields = ['id', 'views_count', 'created_at', 'updated_at']
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_title(self, obj: Blog):
- return obj.get_blog_filed(self._lang(), obj.title)
-
- def get_slogan(self, obj: Blog):
- return obj.get_blog_filed(self._lang(), obj.slogan)
-
- def get_summary(self, obj: Blog):
- return obj.get_blog_filed(self._lang(), obj.summary)
-
- def get_slug(self, obj: Blog):
- return obj.get_blog_filed(self._lang(), obj.slug)
-
- def get_seo(self, obj: Blog):
- return obj.get_seo_for_language(self._lang())
-
-
diff --git a/apps/blog/serializers_admin.py b/apps/blog/serializers_admin.py
deleted file mode 100644
index ac86240..0000000
--- a/apps/blog/serializers_admin.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from rest_framework import serializers
-from django.core.files.uploadedfile import SimpleUploadedFile
-from .models import Blog, BlogContent, BlogSeo
-from utils import absolute_url
-from utils.image_compression import maybe_compress_uploaded_file
-
-
-# ─── Helper: return full URL for an ImageField ────────────────────────────────
-class AbsoluteImageField(serializers.ImageField):
- """
- Wraps DRF's ImageField to return a full absolute URL on read,
- while accepting a real uploaded file (InMemoryUploadedFile /
- TemporaryUploadedFile) on write – just like a normal ImageField.
- """
- def to_internal_value(self, data):
- uploaded = super().to_internal_value(data)
- compressed_bytes = maybe_compress_uploaded_file(uploaded)
- if compressed_bytes is None:
- return uploaded
-
- return SimpleUploadedFile(
- name=getattr(uploaded, "name", "image"),
- content=compressed_bytes,
- content_type=getattr(uploaded, "content_type", None),
- )
-
- def to_representation(self, value):
- if not value:
- return None
- request = self.context.get("request")
- url = value.url if hasattr(value, "url") else str(value)
- if request:
- return request.build_absolute_uri(url)
- return url
-
-
-# ─── Serializers ──────────────────────────────────────────────────────────────
-
-class AdminBlogSeoSerializer(serializers.ModelSerializer):
- class Meta:
- model = BlogSeo
- fields = ["id", "title", "keywords", "description", "language"]
-
-
-class AdminBlogContentSerializer(serializers.ModelSerializer):
- image = AbsoluteImageField(required=False, allow_null=True)
-
- class Meta:
- model = BlogContent
- fields = [
- "id", "blog", "title", "content", "slug", "image", "order",
- "created_at", "updated_at",
- ]
-
-
-class AdminBlogListSerializer(serializers.ModelSerializer):
- """
- Lightweight serializer for the admin data table.
- Exposes raw JSON fields so the React frontend can manage translations.
- """
- thumbnail = AbsoluteImageField(required=False, allow_null=True)
-
- class Meta:
- model = Blog
- fields = [
- "id", "title", "slogan", "summary", "slug", "thumbnail",
- "views_count", "created_at", "updated_at",
- ]
- read_only_fields = ["id", "views_count", "created_at", "updated_at"]
-
-
-class AdminBlogDetailSerializer(serializers.ModelSerializer):
- """
- Full serializer for the admin detail/edit view.
- Includes nested contents and SEO data.
- """
- thumbnail = AbsoluteImageField(required=False, allow_null=True)
- contents = AdminBlogContentSerializer(many=True, read_only=True)
- seos = AdminBlogSeoSerializer(many=True, read_only=True)
-
- class Meta:
- model = Blog
- fields = [
- "id", "title", "slogan", "summary", "slug", "thumbnail",
- "views_count", "created_at", "updated_at", "contents", "seos",
- ]
- read_only_fields = ["id", "views_count", "created_at", "updated_at"]
diff --git a/apps/blog/tests.py b/apps/blog/tests.py
deleted file mode 100644
index 7ce503c..0000000
--- a/apps/blog/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/apps/blog/urls.py b/apps/blog/urls.py
deleted file mode 100644
index 91778b3..0000000
--- a/apps/blog/urls.py
+++ /dev/null
@@ -1,39 +0,0 @@
-from django.urls import path, re_path , include
-from rest_framework.routers import DefaultRouter, SimpleRouter
-
-from .views import BlogListAPIView, RelatedBlogsAPIView, BlogDetailBySlugAPIView
-
-from .views_admin import AdminBlogViewSet, AdminBlogContentViewSet, AdminBlogSeoViewSet
-
-app_name = 'blog'
-
-# --- Admin Router Setup ---
-admin_router = SimpleRouter()
-admin_router.register(r'blogs', AdminBlogViewSet, basename='admin-blogs')
-admin_router.register(r'contents', AdminBlogContentViewSet, basename='admin-blog-contents')
-admin_router.register(r'seo', AdminBlogSeoViewSet, basename='admin-blog-seo')
-
-# Hide admin viewsets from swagger
-for prefix, viewset, basename in admin_router.registry:
- viewset.swagger_schema = None
-urlpatterns = [
- # Blog list with search and sort_by filters
- path('list/', BlogListAPIView.as_view(), name='blog-list'),
-
- # Related blogs for a specific blog ID
- path('related//', RelatedBlogsAPIView.as_view(), name='related-blogs'),
-
- # Blog detail by slug (using regex to support different languages)
- re_path(r'^detail/(?P[\w\-\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u200C\u200D]+)/$',
- BlogDetailBySlugAPIView.as_view(),
- name='blog-detail'),
-
- path('admin/', include(admin_router.urls)),
-]
-
-
-
-
-
-
-
diff --git a/apps/blog/views.py b/apps/blog/views.py
deleted file mode 100644
index 01b2599..0000000
--- a/apps/blog/views.py
+++ /dev/null
@@ -1,183 +0,0 @@
-from rest_framework.generics import ListAPIView, GenericAPIView
-from rest_framework.permissions import AllowAny
-from rest_framework.response import Response
-from rest_framework import status
-from rest_framework.permissions import IsAuthenticated
-from django.db.models import Q
-from django.shortcuts import get_object_or_404
-from .models import Blog
-from .serializers import BlogListSerializer, BlogDetailSerializer
-import random
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
-from utils.pagination import StandardResultsSetPagination
-
-
-
-class BlogListAPIView(ListAPIView):
- """
- API view to list blogs with search and sort_by filters
- """
- serializer_class = BlogListSerializer
- permission_classes = [AllowAny]
- pagination_class = StandardResultsSetPagination
- @swagger_auto_schema(
- operation_description="List blogs with optional search and sort_by filters",
- tags=["Imam-Javad - Blog"],
- manual_parameters=[
- openapi.Parameter(
- name='search',
- in_=openapi.IN_QUERY,
- description='Search in title, slogan, or summary',
- type=openapi.TYPE_STRING,
- required=False
- ),
- openapi.Parameter(
- name='sort_by',
- in_=openapi.IN_QUERY,
- description="Sorting: 'latest' or 'most_viewed'",
- type=openapi.TYPE_STRING,
- required=False
- ),
- ],
- responses={
- 200: openapi.Response(
- description="List of blogs",
- schema=BlogListSerializer(many=True)
- )
- }
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def get_queryset(self):
- queryset = Blog.objects.all()
-
- # Search filter
- search = self.request.query_params.get('search', None)
- if search:
- queryset = queryset.filter(
- Q(title__icontains=search) |
- Q(slogan__icontains=search) |
- Q(summary__icontains=search)
- )
-
- # Sort by filter
- sort_by = self.request.query_params.get('sort_by', None)
- if sort_by == 'latest':
- queryset = queryset.order_by('-created_at')
- elif sort_by == 'most_viewed':
- queryset = queryset.order_by('-views_count')
- else:
- # Default ordering
- queryset = queryset.order_by('-created_at')
-
- return queryset
-
-
-class RelatedBlogsAPIView(GenericAPIView):
- """
- API view to get 10 random related blogs for a given blog ID
- """
- serializer_class = BlogListSerializer
- permission_classes = [AllowAny]
-
- @swagger_auto_schema(
- operation_description="Get up to 10 random related blogs for the given blog_id",
- tags=["Imam-Javad - Blog"],
- manual_parameters=[
- openapi.Parameter(
- name='blog_id',
- in_=openapi.IN_PATH,
- description='Current blog ID to exclude',
- type=openapi.TYPE_INTEGER,
- required=True
- )
- ],
- responses={
- 200: openapi.Response(
- description="Related blogs",
- schema=BlogListSerializer(many=True)
- )
- }
- )
- def get(self, request, blog_id):
- """
- Get 10 random blogs excluding the current blog
- """
- try:
- # Get the current blog to exclude it from results
- current_blog = get_object_or_404(Blog, id=blog_id)
-
- # Get all blogs except the current one
- all_blogs = list(Blog.objects.exclude(id=blog_id))
-
- # Get random 10 blogs (or less if there are fewer blogs)
- random_count = min(10, len(all_blogs))
- if random_count > 0:
- related_blogs = random.sample(all_blogs, random_count)
- else:
- related_blogs = []
-
- serializer = self.get_serializer(related_blogs, many=True)
- return Response(serializer.data, status=status.HTTP_200_OK)
-
- except Exception as e:
- return Response(
- {'error': 'Blog not found or error occurred'},
- status=status.HTTP_404_NOT_FOUND
- )
-
-
-class BlogDetailBySlugAPIView(GenericAPIView):
- """
- API view to get blog details by slug and increment view count
- """
- serializer_class = BlogDetailSerializer
- permission_classes = [AllowAny]
-
- @swagger_auto_schema(
- operation_description="Get blog details by slug and increment view count",
- tags=["Imam-Javad - Blog"],
- manual_parameters=[
- openapi.Parameter(
- name='slug',
- in_=openapi.IN_PATH,
- description='Blog slug',
- type=openapi.TYPE_STRING,
- required=True
- )
- ],
- responses={
- 200: openapi.Response(
- description="Blog detail",
- schema=BlogDetailSerializer()
- )
- }
- )
- def get(self, request, slug):
- """
- Get blog details by slug and increment view count
- """
- try:
- # Slug is stored as list of objects in JSONField -> filter accordingly
- blog = Blog.objects.filter(slug__contains=[{'title': slug}]).first()
- if not blog:
- return Response({'error': 'Blog not found'}, status=status.HTTP_404_NOT_FOUND)
-
- # Increment view count
- blog.increment_view_count()
-
- # Get related blog contents ordered by order field
- blog_with_contents = Blog.objects.prefetch_related(
- 'contents'
- ).get(id=blog.id)
-
- serializer = self.get_serializer(blog_with_contents, context={'request': request})
- return Response(serializer.data, status=status.HTTP_200_OK)
-
- except Exception as e:
- return Response(
- {'error': 'Blog not found'},
- status=status.HTTP_404_NOT_FOUND
- )
\ No newline at end of file
diff --git a/apps/blog/views_admin.py b/apps/blog/views_admin.py
deleted file mode 100644
index a37ad06..0000000
--- a/apps/blog/views_admin.py
+++ /dev/null
@@ -1,128 +0,0 @@
-from django.db.models import Q
-from rest_framework.viewsets import ModelViewSet
-from rest_framework.permissions import IsAuthenticated
-from apps.account.permissions import IsSuperAdmin
-from rest_framework.authentication import TokenAuthentication
-from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
-from rest_framework.decorators import action
-from rest_framework.response import Response
-from utils.pagination import StandardResultsSetPagination
-
-from .models import Blog, BlogContent, BlogSeo
-from .serializers_admin import (
- AdminBlogListSerializer,
- AdminBlogDetailSerializer,
- AdminBlogContentSerializer,
- AdminBlogSeoSerializer
-)
-
-class AdminBlogViewSet(ModelViewSet):
- """
- Admin CRUD ViewSet for Blog management.
- """
- permission_classes = [IsAuthenticated, IsSuperAdmin]
- authentication_classes = [TokenAuthentication]
- pagination_class = StandardResultsSetPagination
- # Allow multipart so admins can upload thumbnail images directly
- parser_classes = (MultiPartParser, FormParser, JSONParser)
-
- def get_serializer_class(self):
- if self.action == 'list':
- return AdminBlogListSerializer
- return AdminBlogDetailSerializer
-
- def get_queryset(self):
- queryset = Blog.objects.all().prefetch_related('contents', 'seos')
-
- # Custom Search handling for JSON fields
- search_query = self.request.query_params.get('search', None)
- has_search = False
- if search_query:
- has_search = True
- from django.db.models import Case, When, Value, IntegerField
- # Note: Searching inside a JSON array of dicts in Django can be tricky
- # depending on your DB (PostgreSQL vs SQLite).
- # This is a safe fallback using text casting for SQLite/PG compatibility.
- queryset = queryset.filter(
- Q(title__icontains=search_query) |
- Q(slogan__icontains=search_query)
- ).annotate(
- search_relevance=Case(
- When(title__icontains=search_query, then=Value(2)),
- default=Value(1),
- output_field=IntegerField()
- )
- )
-
- created_date = self.request.query_params.get('created', None)
- if created_date:
- queryset = queryset.filter(created_at__date=created_date)
-
- created_after = self.request.query_params.get('created_after', None)
- if created_after:
- queryset = queryset.filter(created_at__date__gte=created_after)
-
- created_before = self.request.query_params.get('created_before', None)
- if created_before:
- queryset = queryset.filter(created_at__date__lte=created_before)
-
- if has_search:
- return queryset.order_by('-search_relevance', '-created_at')
- return queryset.order_by('-created_at')
-
-class AdminBlogContentViewSet(ModelViewSet):
- """
- Admin CRUD ViewSet for managing the content blocks inside a specific blog.
- """
- serializer_class = AdminBlogContentSerializer
- permission_classes = [IsAuthenticated, IsSuperAdmin]
- authentication_classes = [TokenAuthentication]
- parser_classes = (MultiPartParser, FormParser, JSONParser)
-
- def get_queryset(self):
- queryset = BlogContent.objects.all()
-
- # Filter contents by blog_id if provided in the URL query params
- blog_id = self.request.query_params.get('blog_id', None)
- if blog_id:
- queryset = queryset.filter(blog_id=blog_id)
-
- return queryset.order_by('order', 'created_at')
-
- @action(detail=False, methods=['post'])
- def reorder(self, request):
- """
- Reorders content blocks in bulk.
- Format: {"content_ids": [id1, id2, ...]}
- """
- content_ids = request.data.get('content_ids', [])
- if not content_ids:
- return Response({'error': 'content_ids list is required'}, status=400)
-
- from django.db import transaction
- with transaction.atomic():
- # First pass: temporary shift to prevent unique constraint conflicts
- for idx, c_id in enumerate(content_ids):
- BlogContent.objects.filter(id=c_id).update(order=idx + 10000)
- # Second pass: set the correct final order values
- for idx, c_id in enumerate(content_ids):
- BlogContent.objects.filter(id=c_id).update(order=idx + 1)
-
- return Response({'status': 'success'})
-
-class AdminBlogSeoViewSet(ModelViewSet):
- """
- Admin CRUD ViewSet for managing SEO meta tags for a specific blog.
- """
- serializer_class = AdminBlogSeoSerializer
- permission_classes = [IsAuthenticated, IsSuperAdmin]
- authentication_classes = [TokenAuthentication]
-
- def get_queryset(self):
- queryset = BlogSeo.objects.all()
-
- blog_id = self.request.query_params.get('blog_id', None)
- if blog_id:
- queryset = queryset.filter(blog_id=blog_id)
-
- return queryset
\ No newline at end of file
diff --git a/apps/certificate/__init__.py b/apps/certificate/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/certificate/admin.py b/apps/certificate/admin.py
deleted file mode 100644
index 4ac3883..0000000
--- a/apps/certificate/admin.py
+++ /dev/null
@@ -1,81 +0,0 @@
-from django.contrib import admin
-from django.db.models import Q
-from django.utils.translation import gettext_lazy as _
-from django.utils.html import format_html
-
-from unfold.admin import ModelAdmin
-from unfold.decorators import display
-
-from apps.certificate.models import Certificate
-from apps.course.models import Course
-from apps.course.models.course import extract_text_from_json
-
-from utils.admin import project_admin_site
-from apps.course.admin.professor_base import CertificateBaseAdmin
-
-
-def find_matching_course_ids(search_term: str):
- normalized = (search_term or "").strip().lower()
- if not normalized:
- return []
-
- matching_ids = list(
- Course.objects.filter(
- Q(title__icontains=search_term) | Q(slug__icontains=search_term)
- ).values_list("id", flat=True)
- )
-
- if matching_ids:
- return matching_ids
-
- for course in Course.objects.all().only("id", "title", "slug"):
- title_text = extract_text_from_json(course.title).lower()
- slug_text = extract_text_from_json(course.slug).lower()
- if normalized in title_text or normalized in slug_text:
- matching_ids.append(course.id)
-
- return matching_ids
-
-@admin.register(Certificate)
-class CertificateAdmin(CertificateBaseAdmin):
- list_display = ['student', 'course', 'certificate_status', 'created_at']
- list_filter = ['status', 'created_at']
- search_fields = ['id', 'student__username', 'student__email']
- readonly_fields = ['created_at', 'updated_at']
- autocomplete_fields = ['student',]
- fieldsets = (
- (None, {
- 'fields': ('student', 'course', 'status', 'certificate_file')
- }),
- (_('Timestamps'), {
- 'fields': ('created_at', 'updated_at'),
- 'classes': ('collapse',)
- }),
- )
-
- @display(description=_("Status"), ordering="status")
- def certificate_status(self, obj):
- status_classes = {
- 'pending': 'unfold-badge unfold-badge--warning',
- 'approved': 'unfold-badge unfold-badge--success',
- 'rejected': 'unfold-badge unfold-badge--danger',
- 'issued': 'unfold-badge unfold-badge--info',
- }
-
- status_class = status_classes.get(obj.status.lower(), 'unfold-badge')
- return format_html('{}', status_class, obj.get_status_display())
-
- def get_queryset(self, request):
- queryset = super().get_queryset(request)
- return queryset
-
- def get_search_results(self, request, queryset, search_term):
- queryset, use_distinct = super().get_search_results(request, queryset, search_term)
- matching_course_ids = find_matching_course_ids(search_term)
-
- if matching_course_ids:
- queryset = queryset | self.model.objects.filter(course_id__in=matching_course_ids)
- use_distinct = True
-
- return queryset, use_distinct
-project_admin_site.register(Certificate, CertificateAdmin)
diff --git a/apps/certificate/apps.py b/apps/certificate/apps.py
deleted file mode 100644
index 5d62170..0000000
--- a/apps/certificate/apps.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from django.apps import AppConfig
-
-
-class CertificateConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'apps.certificate'
-
- def ready(self):
- import apps.certificate.signals
diff --git a/apps/certificate/migrations/0001_initial.py b/apps/certificate/migrations/0001_initial.py
deleted file mode 100644
index 336915a..0000000
--- a/apps/certificate/migrations/0001_initial.py
+++ /dev/null
@@ -1,69 +0,0 @@
-# Generated by Django 4.2.27 on 2026-01-22 10:48
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = [
- ("course", "0001_initial"),
- ("account", "0001_initial"),
- ]
-
- operations = [
- migrations.CreateModel(
- name="Certificate",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "status",
- models.CharField(
- choices=[
- ("pending", "pending"),
- ("approved", "approved"),
- ("canceled", "canceled"),
- ],
- default="pending",
- max_length=10,
- ),
- ),
- (
- "certificate_file",
- models.FileField(
- blank=True,
- null=True,
- upload_to="certificates/",
- verbose_name="certificate_file",
- ),
- ),
- ("created_at", models.DateTimeField(auto_now_add=True)),
- ("updated_at", models.DateTimeField(auto_now=True)),
- (
- "course",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="course_certificates",
- to="course.course",
- ),
- ),
- (
- "student",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="certificates",
- to="account.studentuser",
- ),
- ),
- ],
- ),
- ]
diff --git a/apps/certificate/migrations/0002_alter_certificate_course_and_more.py b/apps/certificate/migrations/0002_alter_certificate_course_and_more.py
deleted file mode 100644
index ba8c885..0000000
--- a/apps/certificate/migrations/0002_alter_certificate_course_and_more.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# Generated by Django 5.2.12 on 2026-05-04 12:53
-
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('account', '0003_alter_clientuser_options_and_more'),
- ('certificate', '0001_initial'),
- ('course', '0006_alter_course_professor_alter_course_video_file_and_more'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='certificate',
- name='course',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_certificates', to='course.course', verbose_name='Course'),
- ),
- migrations.AlterField(
- model_name='certificate',
- name='created_at',
- field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
- ),
- migrations.AlterField(
- model_name='certificate',
- name='status',
- field=models.CharField(choices=[('pending', 'pending'), ('approved', 'approved'), ('canceled', 'canceled')], default='pending', max_length=10, verbose_name='Status'),
- ),
- migrations.AlterField(
- model_name='certificate',
- name='student',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to='account.studentuser', verbose_name='Student'),
- ),
- ]
diff --git a/apps/certificate/migrations/0003_alter_certificate_student.py b/apps/certificate/migrations/0003_alter_certificate_student.py
deleted file mode 100644
index 7739cd5..0000000
--- a/apps/certificate/migrations/0003_alter_certificate_student.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# Generated by Django 5.2.12 on 2026-06-07 16:49
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('certificate', '0002_alter_certificate_course_and_more'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='certificate',
- name='student',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to=settings.AUTH_USER_MODEL, verbose_name='Student'),
- ),
- ]
diff --git a/apps/certificate/migrations/__init__.py b/apps/certificate/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/certificate/models.py b/apps/certificate/models.py
deleted file mode 100644
index f4a099d..0000000
--- a/apps/certificate/models.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from django.db import models
-
-from django.utils.translation import gettext_lazy as _
-from filer.fields.file import FilerFileField
-from apps.course.models import Course
-from apps.course.models.course import extract_text_from_json
-from apps.account.models import User
-
-
-
-class Certificate(models.Model):
- STATUS_CHOICES = [
- ('pending', _('pending')),
- ('approved', _('approved')),
- ('canceled', _('canceled')),
- ]
-
- student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='certificates', verbose_name=_('Student'))
- course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_certificates', verbose_name=_('Course'))
- status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending', verbose_name=_('Status'))
- certificate_file = models.FileField(upload_to='certificates/', null=True, blank=True, verbose_name=_('certificate_file'))
-
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at'))
- updated_at = models.DateTimeField(auto_now=True)
-
- def __str__(self):
- course_title = extract_text_from_json(self.course.title) if self.course_id else ""
- return f"Certificate {self.student.fullname} - {course_title}"
diff --git a/apps/certificate/serializers.py b/apps/certificate/serializers.py
deleted file mode 100644
index cce380b..0000000
--- a/apps/certificate/serializers.py
+++ /dev/null
@@ -1,73 +0,0 @@
-
-
-from rest_framework import serializers
-from apps.certificate.models import Certificate
-from apps.course.serializers import CourseDetailSerializer
-from django.conf import settings
-
-
-
-class CertificateSerializer(serializers.ModelSerializer):
- course = serializers.SerializerMethodField()
- certificate_file = serializers.SerializerMethodField()
-
- class Meta:
- model = Certificate
- fields = ['id', 'student', 'course', 'status', 'created_at', 'updated_at', 'certificate_file']
- read_only_fields = ['id', 'student', 'status', 'created_at', 'updated_at',]
-
- def get_course(self, obj):
- return CourseDetailSerializer(obj.course, context=self.context).data
-
- def get_certificate_file(self, obj):
- if obj.certificate_file:
- request = self.context.get('request')
- if request is not None:
- return request.build_absolute_uri(obj.certificate_file.url)
- return obj.certificate_file.url
- return None
-
-
-
-
-class CertificateRequestSerializer(serializers.ModelSerializer):
- class Meta:
- model = Certificate
- fields = ['id', 'course']
- read_only_fields = ['id']
-
- def create(self, validated_data):
- user = self.context['request'].user
- course = validated_data['course']
-
- if Certificate.objects.filter(student=user, course=course, status__in=['pending', 'approved']).exists():
- raise serializers.ValidationError({
- "course": "A certificate request for this course is already pending or approved."
- })
- return Certificate.objects.create(student=user, course=course)
-
-
-class AdminCertificateSerializer(serializers.ModelSerializer):
- student_name = serializers.CharField(source='student.fullname', read_only=True)
- student_email = serializers.CharField(source='student.email', read_only=True)
- course_title = serializers.SerializerMethodField()
-
- class Meta:
- model = Certificate
- fields = [
- 'id', 'student', 'student_name', 'student_email',
- 'course', 'course_title', 'status', 'certificate_file',
- 'created_at', 'updated_at'
- ]
-
- def get_course_title(self, obj):
- if not obj.course:
- return None
- request = self.context.get('request')
- lang = getattr(request, 'LANGUAGE_CODE', None) or 'en'
- from apps.course.models.course import get_localized_field
- return get_localized_field(lang, obj.course.title)
-
-
-
-
diff --git a/apps/certificate/signals.py b/apps/certificate/signals.py
deleted file mode 100644
index d4ea86f..0000000
--- a/apps/certificate/signals.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import logging
-from django.db.models.signals import pre_save, post_save
-from django.dispatch import receiver
-from apps.certificate.models import Certificate
-from apps.account.notification_service import create_and_send_notification
-from apps.course.models.course import extract_text_from_json, get_localized_field
-
-logger = logging.getLogger(__name__)
-
-@receiver(pre_save, sender=Certificate)
-def store_certificate_previous_status(sender, instance, **kwargs):
- if instance.pk:
- try:
- old = Certificate.objects.get(pk=instance.pk)
- instance._previous_status = old.status
- except Certificate.DoesNotExist:
- instance._previous_status = None
- else:
- instance._previous_status = None
-
-
-@receiver(post_save, sender=Certificate)
-def notify_certificate_issued(sender, instance, created, **kwargs):
- # Trigger when status becomes approved
- if instance.status == 'approved':
- was_approved = False
- if created:
- was_approved = True
- elif hasattr(instance, '_previous_status') and instance._previous_status != 'approved':
- was_approved = True
-
- if was_approved:
- course_title_en = get_localized_field('en', instance.course.title)
- course_title_fa = get_localized_field('fa', instance.course.title)
- create_and_send_notification(
- user=instance.student,
- title_en="Certificate Issued",
- body_en=f"Your certificate for '{course_title_en}' has been issued.",
- title_fa="گواهی صادر شد",
- body_fa=f"گواهی پایان دوره شما برای «{course_title_fa}» صادر شد.",
- service='imam-javad',
- data={'type': 'certificate_issued', 'certificate_id': instance.id}
- )
diff --git a/apps/certificate/tests.py b/apps/certificate/tests.py
deleted file mode 100644
index 7ce503c..0000000
--- a/apps/certificate/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/apps/certificate/urls.py b/apps/certificate/urls.py
deleted file mode 100644
index 6ae96cd..0000000
--- a/apps/certificate/urls.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from django.urls import path, include
-from rest_framework.routers import SimpleRouter
-from .views import CertificateRequestView, UserCertificatesListView, AdminCertificateViewSet
-
-router = SimpleRouter()
-router.register(r'admin/certificates', AdminCertificateViewSet, basename='admin-certificates')
-
-# Hide admin viewsets from swagger
-for prefix, viewset, basename in router.registry:
- viewset.swagger_schema = None
-urlpatterns = [
- path('', include(router.urls)),
- path('request/', CertificateRequestView.as_view(), name='certificate-request'),
- path('my-certificates/', UserCertificatesListView.as_view(), name='user-certificates'),
-]
\ No newline at end of file
diff --git a/apps/certificate/views.py b/apps/certificate/views.py
deleted file mode 100644
index e8ccbd2..0000000
--- a/apps/certificate/views.py
+++ /dev/null
@@ -1,151 +0,0 @@
-from rest_framework import generics, permissions
-from rest_framework.viewsets import ModelViewSet
-from rest_framework.authentication import TokenAuthentication
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
-from django.db.models import Q
-
-from apps.certificate.models import Certificate
-from apps.certificate.serializers import AdminCertificateSerializer, CertificateRequestSerializer, CertificateSerializer
-from apps.account.permissions import IsSuperAdmin
-from apps.course.models import Course
-from apps.course.models.course import extract_text_from_json
-from utils.pagination import StandardResultsSetPagination
-
-
-def find_matching_course_ids(search_term: str):
- normalized = (search_term or "").strip().lower()
- if not normalized:
- return []
-
- matching_ids = list(
- Course.objects.filter(
- Q(title__icontains=search_term) | Q(slug__icontains=search_term)
- ).values_list("id", flat=True)
- )
-
- if matching_ids:
- return matching_ids
-
- for course in Course.objects.all().only("id", "title", "slug"):
- title_text = extract_text_from_json(course.title).lower()
- slug_text = extract_text_from_json(course.slug).lower()
- if normalized in title_text or normalized in slug_text:
- matching_ids.append(course.id)
-
- return matching_ids
-
-
-
-class CertificateRequestView(generics.CreateAPIView):
- queryset = Certificate.objects.all()
- serializer_class = CertificateRequestSerializer
- permission_classes = [permissions.IsAuthenticated]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- operation_description="Request a certificate for completed course",
- tags=["Imam-Javad - Certificate"],
- responses={
- 201: openapi.Response(
- description="Certificate request created successfully"
- )
- }
- )
- def post(self, request, *args, **kwargs):
- return super().post(request, *args, **kwargs)
-
- def perform_create(self, serializer):
- serializer.save(student=self.request.user)
-
-
-class UserCertificatesListView(generics.ListAPIView):
- serializer_class = CertificateSerializer
- permission_classes = [permissions.IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- pagination_class = StandardResultsSetPagination
-
- @swagger_auto_schema(
- operation_description="Get list of user's certificates",
- tags=["Imam-Javad - Certificate"],
- responses={
- 200: openapi.Response(
- description="List of user certificates",
- schema=CertificateSerializer(many=True)
- )
- }
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def get_queryset(self):
- return Certificate.objects.filter(student=self.request.user).order_by('-created_at')
-
-
-def is_professor(request):
- return getattr(request.user, 'user_type', None) == 'professor'
-
-
-class AdminCertificateViewSet(ModelViewSet):
- queryset = Certificate.objects.all()
- serializer_class = AdminCertificateSerializer
- permission_classes = [permissions.IsAuthenticated, IsSuperAdmin]
- authentication_classes = [TokenAuthentication]
- pagination_class = StandardResultsSetPagination
-
- def get_queryset(self):
- queryset = Certificate.objects.all().select_related('student', 'course')
-
- # Professors can only see certificates for students in their courses
- if is_professor(self.request):
- queryset = queryset.filter(course__professors=self.request.user)
-
- # Search query
- search_query = self.request.query_params.get('search', None)
- has_search = False
- if search_query:
- has_search = True
- matching_course_ids = find_matching_course_ids(search_query)
- from django.db.models import Case, When, Value, IntegerField
- queryset = queryset.filter(
- Q(student__fullname__icontains=search_query) |
- Q(student__email__icontains=search_query) |
- Q(course_id__in=matching_course_ids)
- ).annotate(
- search_relevance=Case(
- When(student__fullname__icontains=search_query, then=Value(3)),
- When(student__email__icontains=search_query, then=Value(2)),
- When(course_id__in=matching_course_ids, then=Value(1)),
- default=Value(0),
- output_field=IntegerField()
- )
- )
-
- # Filters
- status_param = self.request.query_params.get('status', None)
- if status_param:
- queryset = queryset.filter(status=status_param)
-
- course_id = self.request.query_params.get('course', None)
- if course_id:
- queryset = queryset.filter(course_id=course_id)
-
- student_id = self.request.query_params.get('student', None)
- if student_id:
- queryset = queryset.filter(student_id=student_id)
-
- issued_date = self.request.query_params.get('issued_date', None)
- if issued_date:
- queryset = queryset.filter(created_at__date=issued_date)
-
- issued_after = self.request.query_params.get('issued_after', None)
- if issued_after:
- queryset = queryset.filter(created_at__date__gte=issued_after)
-
- issued_before = self.request.query_params.get('issued_before', None)
- if issued_before:
- queryset = queryset.filter(created_at__date__lte=issued_before)
-
- if has_search:
- return queryset.order_by('-search_relevance', '-created_at')
- return queryset.order_by('-created_at')
diff --git a/apps/chat/__init__.py b/apps/chat/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/chat/admin.py b/apps/chat/admin.py
deleted file mode 100644
index d7552f4..0000000
--- a/apps/chat/admin.py
+++ /dev/null
@@ -1,410 +0,0 @@
-from django.contrib import admin
-from django.utils.html import format_html
-from django.utils.translation import gettext_lazy as _
-from django.db.models import Count
-from django import forms
-from unfold.admin import ModelAdmin, TabularInline
-from unfold.contrib.filters.admin import RangeNumericFilter, RangeDateTimeFilter
-from django.shortcuts import redirect
-from django.urls import reverse
-from unfold.decorators import action, display
-from django.contrib import messages
-
-from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus
-from utils.admin import project_admin_site, admin_url_generator
-from django.contrib.auth import get_user_model
-from django.db.models import Count, Q
-User = get_user_model()
-
-
-# --- HELPER FUNCTION: GET ALLOWED USERS FOR A ROOM ---
-def get_allowed_users_for_room(room):
- """
- Returns a queryset of active users allowed in a specific room.
- Private: Initiator + Recipient
- Group: Initiator + Recipient + Course Professor + Active Course Participants
- """
- allowed_ids = set()
-
- if room.initiator_id:
- allowed_ids.add(room.initiator_id)
- if room.recipient_id:
- allowed_ids.add(room.recipient_id)
-
- if room.room_type == 'group' and room.course_id:
- # Add all Professors
- professor_ids = room.course.professors.values_list('id', flat=True)
- allowed_ids.update(professor_ids)
-
- # Add Active Participants
- from apps.course.models import Participant
- participant_ids = Participant.objects.filter(
- course_id=room.course_id,
- is_active=True
- ).values_list('student_id', flat=True)
- allowed_ids.update(participant_ids)
-
- return User.objects.filter(is_active=True, id__in=allowed_ids)
-
-
-def get_rooms_queryset_for_user(user, queryset):
- """
- Absolute Privacy Rule:
- - NO ONE sees a PRIVATE chat unless they are the initiator or recipient.
- - Admins/Consultants see ALL GROUP chats.
- - Others see only their enrolled GROUP chats.
- """
- # شرط پایه: چتهایی که خود شخص در آنها حضور مستقیم دارد (خصوصی یا گروهی فرقی ندارد)
- personal_q = Q(initiator=user) | Q(recipient=user)
-
- if user.is_superuser or user.has_role('admin') or user.has_role('super_admin') or user.has_role('consultant'):
- # ادمینها و مشاورین: چتهای خودشان + تمام چتهای گروهی
- return queryset.filter(
- personal_q |
- Q(room_type=RoomMessage.RoomTypeChoices.GROUP)
- ).distinct()
-
- # اساتید و دانشجویان: چتهای خودشان + چتهای گروهی که در آن عضو هستند
- return queryset.filter(
- personal_q |
- Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__professors=user) |
- Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__participants__student=user, course__participants__is_active=True)
- ).distinct()
-
-# --- WIDTH ENFORCEMENT FOR SELECT2 DROPDOWNS IN INLINES ---
-class MinWidthInlineForm(forms.ModelForm):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- target_dropdown_fields = ['sender', 'user']
-
- for field_name, field in self.fields.items():
- if field_name in target_dropdown_fields and hasattr(field.widget, 'attrs'):
- existing_class = field.widget.attrs.get('class', '')
- field.widget.attrs['class'] = f"{existing_class} min-w-[250px] w-full"
-
- existing_style = field.widget.attrs.get('style', '')
- field.widget.attrs['style'] = f"{existing_style} min-width: 250px; width: 250px;"
-
-
-class ChatMessageInline(TabularInline):
- model = ChatMessage
- form = MinWidthInlineForm
- extra = 1 # 🔔 Allows you to add 1 new message from the room tab
- tab = True
-
- # 🔔 Using real database fields so you can actually input new messages
- fields = ('sender', 'content', 'content_type', 'file_attachment', 'sent_at', 'is_deleted')
- readonly_fields = ('sent_at',)
-
- can_delete = False
- show_change_link = True
- verbose_name = _("Recent Message")
- verbose_name_plural = _("Recent Messages (Latest 50)")
-
- def get_queryset(self, request):
- qs = super().get_queryset(request)
- object_id = request.resolver_match.kwargs.get('object_id')
-
- if object_id:
- latest_ids = list(qs.filter(room_id=object_id).order_by('-sent_at').values_list('id', flat=True)[:50])
- return qs.filter(id__in=latest_ids).order_by('-sent_at')
-
- return qs.none()
-
- # 🔔 FILTER THE SENDER DROPDOWN IN THE TAB
- def formfield_for_foreignkey(self, db_field, request, **kwargs):
- if db_field.name == "sender":
- room_id = request.resolver_match.kwargs.get('object_id')
- if room_id:
- try:
- room = RoomMessage.objects.get(pk=room_id)
- kwargs["queryset"] = get_allowed_users_for_room(room)
- except RoomMessage.DoesNotExist:
- kwargs["queryset"] = User.objects.filter(is_active=True)
- else:
- kwargs["queryset"] = User.objects.filter(is_active=True)
- return super().formfield_for_foreignkey(db_field, request, **kwargs)
-
-
-class MessageReadStatusAdmin(ModelAdmin):
- list_display = (
- 'user', 'message', 'is_read_status', 'read_at',
- )
- list_filter = (
- ('read_at', RangeDateTimeFilter),
- 'is_read',
- )
- search_fields = ('user__username', 'user__email', 'message__content')
- readonly_fields = ('read_at',)
-
- @display(description=_("Read Status"))
- def is_read_status(self, obj):
- if obj.is_read:
- return format_html('{}', _("Read"))
- return format_html('{}', _("Unread"))
-
-
-class RoomMessageAdmin(ModelAdmin):
- list_display = (
- 'name', 'room_type_badge', 'course', 'initiator',
- 'messages_count', 'is_locked'
- )
- list_filter = (
- 'room_type',
- ('created_at', RangeDateTimeFilter),
- ('updated_at', RangeDateTimeFilter),
- 'course','is_locked'
- )
- search_fields = ('name', 'description', 'course__title', 'initiator__username', 'recipient__username')
- ordering = ('-created_at',)
- readonly_fields = ('created_at', 'updated_at', 'messages_count', 'course', 'initiator', 'recipient')
- inlines = []
-
- actions_detail = []
-
- fieldsets = (
- (_("Room Information"), {
- 'fields': ('name', 'description', 'room_type', 'messages_count','is_locked'),
- 'classes': ('grid-col-2',),
- }),
- (_("Relations"), {
- 'fields': ('course', 'initiator', 'recipient'),
- 'classes': ('grid-col-2',),
- }),
- (_("Timestamps"), {
- 'fields': ('created_at', 'updated_at'),
- 'classes': ('grid-col-2',),
- }),
- )
-
- def formfield_for_foreignkey(self, db_field, request, **kwargs):
- if db_field.name in ["initiator", "recipient"]:
- kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
-
- return super().formfield_for_foreignkey(db_field, request, **kwargs)
-
- @display(description=_("Messages Count"))
- def messages_count(self, obj):
- count = obj.messages.count()
- return format_html(
- ''
- '{}', count
- )
-
- @display(description=_("Room Type"))
- def room_type_badge(self, obj):
- if obj.room_type == 'group':
- return format_html('{}', _("Group"))
- return format_html('{}', _("Private"))
-
- def get_queryset(self, request):
- queryset = super().get_queryset(request)
- queryset = queryset.annotate(
- total_messages=Count('messages')
- )
- return get_rooms_queryset_for_user(request.user, queryset)
-
- @action(
- description=_("Manage All Messages"),
- icon="chat",
- )
- def manage_all_messages(self, request, object_id):
- """Redirect to the pre-filtered Chat Message changelist for this room."""
- room = self.get_object(request, object_id)
- if not room:
- messages.error(request, _("Room not found"))
- return redirect(admin_url_generator(request, "chat_roommessage_changelist"))
-
- base_url = admin_url_generator(request, "chat_chatmessage_changelist")
- url = f"{base_url}?room__id__exact={object_id}"
- return redirect(url)
-
-
-class MessageReadStatusInline(TabularInline):
- model = MessageReadStatus
- form = MinWidthInlineForm
- extra = 0
- fields = ('user', 'is_read', 'read_at')
- readonly_fields = ('read_at',)
- can_delete = False
- show_change_link = True
- classes = ['collapse']
- verbose_name = _("Read Status")
- verbose_name_plural = _("Read Statuses")
-
- # 🔔 FILTER THE USER DROPDOWN IN THE READ STATUS TAB
- def formfield_for_foreignkey(self, db_field, request, **kwargs):
- if db_field.name == "user":
- message_id = request.resolver_match.kwargs.get('object_id')
- if message_id:
- try:
- msg = ChatMessage.objects.get(pk=message_id)
- kwargs["queryset"] = get_allowed_users_for_room(msg.room)
- except ChatMessage.DoesNotExist:
- kwargs["queryset"] = User.objects.filter(is_active=True)
- else:
- kwargs["queryset"] = User.objects.filter(is_active=True)
- return super().formfield_for_foreignkey(db_field, request, **kwargs)
-
-
-class ChatMessageAdmin(ModelAdmin):
- list_display = (
- 'id', 'room', 'sender', 'content_type_badge', 'content_preview',
- 'content_size_display', 'has_attachment', 'sent_at', 'is_deleted_status'
- )
- list_filter = (
- 'room',
- 'content_type',
- 'is_deleted',
- ('sent_at', RangeDateTimeFilter),
- ('updated_at', RangeDateTimeFilter),
- ('content_size', RangeNumericFilter)
- )
- search_fields = ('room__name', 'sender__username', 'content')
- ordering = ('-sent_at',)
- readonly_fields = ('sent_at', 'updated_at', 'content_size', 'attachment_preview')
- inlines = [MessageReadStatusInline]
-
- fieldsets = (
- (_("Message Information"), {
- 'fields': ('room', 'sender', 'content', 'content_type'),
- 'classes': ('grid-col-2',),
- }),
- (_("Attachments"), {
- 'fields': ('file_attachment', 'image_attachment', 'attachment_preview'),
- 'classes': ('grid-col-2',),
- }),
- (_("Additional Info"), {
- 'fields': ('content_size',),
- 'classes': ('grid-col-1',),
- }),
- (_("Status"), {
- 'fields': ('is_deleted', 'deleted_at'),
- 'classes': ('grid-col-2',),
- }),
- (_("Timestamps"), {
- 'fields': ('sent_at', 'updated_at'),
- 'classes': ('grid-col-2',),
- }),
- )
- actions_list = ["back_to_chat_rooms"]
-
- def get_queryset(self, request):
- queryset = super().get_queryset(request)
- user = request.user
-
- personal_q = Q(room__initiator=user) | Q(room__recipient=user)
-
- if user.is_superuser or user.has_role('admin') or user.has_role('super_admin') or user.has_role('consultant'):
- # ادمینها و مشاورین: پیامهای خودشان + تمام پیامهای چتهای گروهی
- return queryset.filter(
- personal_q |
- Q(room__room_type=RoomMessage.RoomTypeChoices.GROUP)
- ).distinct()
-
- # اساتید و دانشجویان
- return queryset.filter(
- personal_q |
- Q(room__room_type=RoomMessage.RoomTypeChoices.GROUP, room__course__professors=user) |
- Q(room__room_type=RoomMessage.RoomTypeChoices.GROUP, room__course__participants__student=user, room__course__participants__is_active=True)
- ).distinct()
-
- # 🔔 FILTER THE SENDER DROPDOWN IN THE CHAT MESSAGE FORM
- def formfield_for_foreignkey(self, db_field, request, **kwargs):
- if db_field.name == "sender":
- message_id = request.resolver_match.kwargs.get('object_id')
- if message_id:
- try:
- msg = ChatMessage.objects.get(pk=message_id)
- kwargs["queryset"] = get_allowed_users_for_room(msg.room)
- except ChatMessage.DoesNotExist:
- kwargs["queryset"] = User.objects.filter(is_active=True)
- else:
- kwargs["queryset"] = User.objects.filter(is_active=True)
- return super().formfield_for_foreignkey(db_field, request, **kwargs)
-
- @action(
- description=_("Back to Chat Rooms"),
- icon="arrow_back",
- )
- def back_to_chat_rooms(self, request):
- url = reverse('admin:chat_roommessage_changelist')
- return redirect(url)
-
- @display(description=_("Content Preview"))
- def content_preview(self, obj):
- if obj.content_type == 'text':
- preview = obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
- return preview
- return _("%(type)s content") % {'type': obj.get_content_type_display()}
-
- @display(description=_("Type"))
- def content_type_badge(self, obj):
- badges = {
- 'text': ('bg-green-500', _('Text')),
- 'file': ('bg-green-500', _('File')),
- 'audio': ('bg-green-500', _('Audio')),
- 'image': ('bg-green-500', _('Image')),
- }
- bg_color, label = badges.get(obj.content_type, ('bg-gray-500', obj.content_type))
- return format_html(
- '{}',
- bg_color, label
- )
-
- @display(description=_("Status"))
- def is_deleted_status(self, obj):
- if obj.is_deleted:
- return format_html('{}', _("Deleted"))
- return format_html('{}', _("Active"))
-
- @display(description=_("Size"))
- def content_size_display(self, obj):
- if obj.content_size:
- if obj.content_size > 1024:
- size_kb = obj.content_size / 1024
- return f"{size_kb:.1f} KB"
- return _("{} bytes").format(obj.content_size)
- return "-"
-
- @display(description=_("Attachment"))
- def has_attachment(self, obj):
- if obj.image_attachment:
- return format_html('{}', _("📷 Image"))
- elif obj.file_attachment:
- return format_html('{}', _("📎 File"))
- elif obj.content and obj.content_type != 'text':
- return format_html('{}', _("🔗 Legacy"))
- return "-"
-
- @display(description=_("Attachment Preview"))
- def attachment_preview(self, obj):
- if obj.image_attachment:
- return format_html(
- '{}:'
- '

'
- '
{} ',
- _("Image"),
- obj.image_attachment.url,
- obj.image_attachment.url,
- _("Open in new tab")
- )
- elif obj.file_attachment:
- return format_html(
- '',
- _("File"),
- obj.file_attachment.url,
- _("📥 Download File")
- )
- elif obj.content and obj.content_type != 'text':
- return format_html(
- '{}:
{}
',
- _("Legacy URL"),
- obj.content
- )
- return "-"
-
-project_admin_site.register(RoomMessage, RoomMessageAdmin)
-# project_admin_site.register(ChatMessage, ChatMessageAdmin)
-project_admin_site.register(MessageReadStatus, MessageReadStatusAdmin)
\ No newline at end of file
diff --git a/apps/chat/admin_views.py b/apps/chat/admin_views.py
deleted file mode 100644
index 2f0b636..0000000
--- a/apps/chat/admin_views.py
+++ /dev/null
@@ -1,285 +0,0 @@
-import json
-import requests
-import threading # 🟢 برای اجرای بکگراند
-from django.conf import settings
-from django.http import JsonResponse
-from django.shortcuts import render, get_object_or_404
-from django.views.decorators.http import require_http_methods
-from django.db.models import Q, Count
-from django.utils.translation import gettext_lazy as _
-
-from apps.chat.models import RoomMessage, ChatMessage, AdminRoomSeen
-from utils.admin import project_admin_site
-
-
-def send_webhook_to_fastapi(room_id, message_data):
- fastapi_url = f"{getattr(settings, 'FASTAPI_SERVER_URL', 'http://127.0.0.1:8000')}/api/internal/webhook/chat-message/"
- headers = {
- "Authorization": getattr(settings, 'INTERNAL_WEBHOOK_SECRET', 'super-secret-key-12345'),
- "Content-Type": "application/json"
- }
- payload = {
- "room_id": room_id,
- "message": message_data
- }
- try:
- # با تایماوت 2 ثانیه میفرستیم که اگر سرور گیر بود، پروسه متوقف نشود
- requests.post(fastapi_url, json=payload, headers=headers, timeout=2.0)
- except Exception as e:
- print(f"⚠️ Webhook to FastAPI failed: {e}")
-
-def live_chat_dashboard_view(request):
- """رندر کردن اسکلت اصلی صفحه چت"""
- context = {
- **project_admin_site.each_context(request),
- "title": _("Live Support Chat"),
- }
- return render(request, "admin/chat/live_chat.html", context)
-
-def api_get_rooms(request):
- """گرفتن لیست رومها بر اساس دسترسی و حریم خصوصی مطلق"""
- user = request.user
-
- queryset = RoomMessage.objects.annotate(
- admin_unread_count=Count(
- 'messages',
- filter=Q(messages__is_read=False) & ~Q(messages__sender=user) & Q(messages__is_deleted=False)
- )
- ).order_by('-updated_at')
-
- personal_q = Q(initiator=user) | Q(recipient=user)
-
- if user.is_superuser or user.has_role('admin') or user.has_role('super_admin') or user.has_role('consultant'):
- rooms = queryset.filter(
- personal_q |
- Q(room_type=RoomMessage.RoomTypeChoices.GROUP)
- ).distinct()[:50]
- else:
- rooms = queryset.filter(
- personal_q |
- Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__professors=user) |
- Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__participants__student=user, course__participants__is_active=True)
- ).distinct()[:50]
-
- data = []
- for r in rooms:
- title = r.name or _("Room #{}").format(r.id)
- if r.room_type == 'private' and title and title.startswith("Chat with "):
- title = _("Chat with {}").format(title[10:])
- elif r.room_type == 'group' and r.course:
- title = r.course.title
-
- unread_count = getattr(r, 'admin_unread_count', 0)
-
- data.append({
- "id": r.id,
- "title": title,
- "type": r.room_type,
- "unread": unread_count,
- "time": r.updated_at.strftime("%H:%M") if r.updated_at else ""
- })
-
- return JsonResponse({"rooms": data})
-
-def api_get_messages(request, room_id):
- """گرفتن پیامهای یک روم خاص (فقط پیامهای جدیدتر از last_id)"""
- last_id = int(request.GET.get('last_id', 0))
- room = get_object_or_404(RoomMessage, id=room_id)
-
- messages = ChatMessage.objects.filter(
- room=room,
- id__gt=last_id, # جادوی پولینگ: فقط آیدیهای بزرگتر از آخرین پیامی که داریم رو بگیر
- is_deleted=False
- ).order_by('id')
-
- # 🟢 مارک کردن پیامها به عنوان خوانده شده (سین کردن) برای تمام انواع چت
- if messages.exists():
- # پیامهای طرف مقابل را سین کن
- unread_msgs = messages.filter(is_read=False).exclude(sender=request.user)
- if unread_msgs.exists():
- unread_msgs.update(is_read=True)
-
- if room.room_type == RoomMessage.RoomTypeChoices.GROUP:
- # همگامسازی با فیلد استاتیک برای جلوگیری از مشکلات احتمالی در FastAPI
- if room.unread_messages_count > 0:
- room.unread_messages_count = 0
- room.save(update_fields=['unread_messages_count'])
-
- # ثبت وضعیت دیده شده برای ادمین در جدول AdminRoomSeen
- is_admin = (
- request.user.is_superuser or
- getattr(request.user, 'user_type', '') in ['admin', 'super_admin'] or
- (hasattr(request.user, 'has_role') and (request.user.has_role('admin') or request.user.has_role('super_admin')))
- )
- if is_admin:
- latest_msg = messages.last() # پیامها بر اساس id صعودی مرتب شدهاند
- if latest_msg:
- AdminRoomSeen.objects.update_or_create(
- admin=request.user,
- room=room,
- defaults={'last_seen_message': latest_msg}
- )
-
- data = []
- for m in messages:
- avatar = m.sender.avatar.url if getattr(m.sender, 'avatar', None) else None
- sender_name = m.sender.fullname or m.sender.email or _("Unknown")
-
- data.append({
- "id": m.id,
- "sender_id": m.sender_id,
- "sender_name": sender_name,
- "avatar": avatar,
- "content": m.content,
- "type": m.content_type,
- "file_url": m.file_url, # از پراپرتی مدلت استفاده کردیم
- "sent_at": m.sent_at.strftime("%H:%M"),
- "is_me": m.sender_id == request.user.id
- })
-
- return JsonResponse({"messages": data})
-
-@require_http_methods(["POST"])
-def api_send_message(request, room_id):
- """ارسال پیام جدید از طریق ایجکس (AJAX)"""
- try:
- body = json.loads(request.body)
- content = body.get('content', '').strip()
-
- if not content:
- return JsonResponse({"error": "Empty content"}, status=400)
-
- room = get_object_or_404(RoomMessage, id=room_id)
-
- # ذخیره در دیتابیس
- msg = ChatMessage.objects.create(
- room=room,
- sender=request.user,
- content=content,
- content_type='text'
- )
-
- # آپدیت کردن زمان روم
- room.updated_at = msg.sent_at
- room.save(update_fields=['updated_at'])
-
- # 🟢 ساخت ساختار JSON پیام دقیقاً شبیه به چیزی که FastAPI به فرانت میفرستد
- avatar_url = request.user.avatar.url if getattr(request.user, 'avatar', None) else None
- sender_name = request.user.fullname or request.user.email or _("Support")
-
- message_data = {
- "id": msg.id,
- "room_id": room_id,
- "sender": {
- "id": request.user.id,
- "email": request.user.email,
- "fullname": sender_name,
- "user_type": getattr(request.user, 'user_type', 'admin'),
- "avatar": avatar_url
- },
- "content": msg.content,
- "content_type": msg.content_type,
- "content_size": msg.content_size,
- "sent_at": msg.sent_at.isoformat(),
- "metadata": msg.message_metadata,
- "updated_at": None,
- "is_deleted": False
- }
-
- # 🟢 شلیک به سمت FastAPI در یک Thread جداگانه
- threading.Thread(target=send_webhook_to_fastapi, args=(room_id, message_data)).start()
-
- return JsonResponse({"success": True, "message_id": msg.id})
- except Exception as e:
- return JsonResponse({"error": str(e)}, status=500)
-
-def api_get_users(request):
- """
- برگرداندن لیست کاربران فعال که ایمیل دارند (برای شروع چت جدید)
- """
- from django.contrib.auth import get_user_model
- User = get_user_model()
-
- search_term = request.GET.get('q', '').strip()
-
- # کاربرانی که فعال هستند، خود شخصِ فعلی نیستند و حتماً ایمیل دارند
- users_qs = User.objects.filter(
- is_active=True,
- email__isnull=False
- ).exclude(id=request.user.id)
-
- if search_term:
- users_qs = users_qs.filter(
- Q(email__icontains=search_term) |
- Q(fullname__icontains=search_term)
- )
-
- users_qs = users_qs.order_by('-date_joined')[:50] # محدود کردن به 50 نفر اول
-
- data = []
- from django.utils.translation import gettext as _gt
- for u in users_qs:
- role = getattr(u, 'user_type', 'Member') or 'Member'
- translated_role = _gt(str(role).capitalize())
-
- name = u.fullname or u.email
- data.append({
- "id": u.id,
- "name": name,
- "role": translated_role
- })
-
- return JsonResponse({"users": data})
-
-@require_http_methods(["POST"])
-def api_create_or_get_room(request):
- """
- اگر بین ادمین فعلی و کاربرِ انتخابی چت وجود داشت، آیدیاش را برمیگرداند
- اگر وجود نداشت، یک Private Room جدید میسازد
- """
- try:
- body = json.loads(request.body)
- recipient_id = body.get('user_id')
-
- if not recipient_id:
- return JsonResponse({"error": "No user ID provided"}, status=400)
-
- from django.contrib.auth import get_user_model
- User = get_user_model()
- recipient = get_object_or_404(User, id=recipient_id)
-
- # بررسی میکنیم آیا قبلا رومی بین این دو نفر ساخته شده؟
- existing_room = RoomMessage.objects.filter(
- room_type='private'
- ).filter(
- Q(initiator=request.user, recipient=recipient) |
- Q(initiator=recipient, recipient=request.user)
- ).first()
-
- if existing_room:
- # روم از قبل وجود دارد
- title = existing_room.name or recipient.fullname or recipient.email
- if title and title.startswith("Chat with "):
- title = _("Chat with {}").format(title[10:])
- return JsonResponse({
- "success": True,
- "room_id": existing_room.id,
- "title": title
- })
-
- # روم وجود ندارد، میسازیم
- new_room = RoomMessage.objects.create(
- name=f"Chat with {recipient.fullname or recipient.email}",
- room_type='private',
- initiator=request.user,
- recipient=recipient
- )
-
- return JsonResponse({
- "success": True,
- "room_id": new_room.id,
- "title": _("Chat with {}").format(recipient.fullname or recipient.email)
- })
-
- except Exception as e:
- return JsonResponse({"error": str(e)}, status=500)
\ No newline at end of file
diff --git a/apps/chat/apps.py b/apps/chat/apps.py
deleted file mode 100644
index 08a568e..0000000
--- a/apps/chat/apps.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from django.apps import AppConfig
-
-
-class ChatConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'apps.chat'
-
- def ready(self):
- import apps.chat.signals
diff --git a/apps/chat/management/__init__.py b/apps/chat/management/__init__.py
deleted file mode 100644
index 8b13789..0000000
--- a/apps/chat/management/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/chat/management/commands/README.md b/apps/chat/management/commands/README.md
deleted file mode 100644
index c70c27f..0000000
--- a/apps/chat/management/commands/README.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# Chat Management Commands
-
-## clear_chat_data
-
-این management command برای پاک کردن دادههای چت طراحی شده است و دو حالت کاری دارد:
-
-### حالت پیشفرض (محافظت از رومهای کورس)
-در این حالت:
-- همه پیامها (ChatMessage) حذف میشوند
-- همه وضعیتهای خواندن پیام (MessageReadStatus) حذف میشوند
-- رومهایی که مربوط به کورس نیستند (course=null) حذف میشوند
-- رومهایی که مربوط به کورس هستند حفظ میشوند اما پیامهایشان حذف میشود
-- تعداد پیامهای خوانده نشده رومهای کورس صفر میشود
-
-### حالت حذف کامل
-در این حالت همه دادههای چت شامل رومهای کورس نیز حذف میشوند.
-
-## استفاده
-
-### حالت پیشفرض (محافظت از رومهای کورس)
-```bash
-# با تأیید کاربر
-python manage.py clear_chat_data
-
-# بدون تأیید کاربر
-python manage.py clear_chat_data --force
-```
-
-### حذف کامل همه دادهها
-```bash
-# با تأیید کاربر
-python manage.py clear_chat_data --all-rooms
-
-# بدون تأیید کاربر
-python manage.py clear_chat_data --all-rooms --force
-```
-
-## پارامترها
-
-- `--force`: اجرای دستور بدون درخواست تأیید از کاربر
-- `--all-rooms`: حذف همه رومها شامل رومهای مربوط به کورس
-
-## نکات مهم
-
-1. **ایمنی**: دستور در یک transaction اجرا میشود تا در صورت خطا، تغییرات rollback شوند
-2. **گزارشدهی**: دستور تعداد رکوردهای حذف شده را نمایش میدهد
-3. **محافظت از دادههای کورس**: در حالت پیشفرض، رومهای مربوط به کورس حفظ میشوند
-4. **بازنشانی شمارنده**: تعداد پیامهای خوانده نشده رومهای کورس به صفر تنظیم میشود
-
-## مثال خروجی
-
-```
-Found:
- - 150 messages
- - 75 read statuses
- - 10 total rooms (3 course rooms, 7 non-course rooms)
-✓ Deleted 75 MessageReadStatus records
-✓ Deleted 150 ChatMessage records
-✓ Deleted 7 non-course RoomMessage records
-✓ Reset unread_messages_count for 3 course rooms
-Chat data clearing completed successfully!
-```
diff --git a/apps/chat/management/commands/__init__.py b/apps/chat/management/commands/__init__.py
deleted file mode 100644
index 8b13789..0000000
--- a/apps/chat/management/commands/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/chat/management/commands/clear_chat_data.py b/apps/chat/management/commands/clear_chat_data.py
deleted file mode 100644
index 2546076..0000000
--- a/apps/chat/management/commands/clear_chat_data.py
+++ /dev/null
@@ -1,79 +0,0 @@
-from django.core.management.base import BaseCommand
-from django.db import transaction
-from django.utils.translation import gettext_lazy as _
-
-from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus
-
-
-class Command(BaseCommand):
- help = 'Clear chat data: all rooms, messages and read statuses, but preserve course-related rooms'
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--force',
- action='store_true',
- dest='force',
- help=_('Force deletion without confirmation'),
- )
- parser.add_argument(
- '--all-rooms',
- action='store_true',
- dest='all_rooms',
- help=_('Delete ALL rooms including course-related rooms'),
- )
-
- def handle(self, *args, **options):
- force = options['force']
- all_rooms = options['all_rooms']
-
- if not force:
- if all_rooms:
- confirm = input(_('This will delete ALL chat data including course rooms. Are you sure? (yes/no): '))
- else:
- confirm = input(_('This will delete all messages and read statuses, and non-course rooms. Course rooms will be preserved but their messages will be deleted. Are you sure? (yes/no): '))
-
- if confirm.lower() != 'yes':
- self.stdout.write(self.style.WARNING(_('Operation cancelled.')))
- return
-
- try:
- with transaction.atomic():
- # Count existing data
- total_messages = ChatMessage.objects.count()
- total_read_statuses = MessageReadStatus.objects.count()
- total_rooms = RoomMessage.objects.count()
- course_rooms = RoomMessage.objects.filter(course__isnull=False).count()
- non_course_rooms = RoomMessage.objects.filter(course__isnull=True).count()
-
- self.stdout.write(self.style.WARNING(f'Found:'))
- self.stdout.write(f' - {total_messages} messages')
- self.stdout.write(f' - {total_read_statuses} read statuses')
- self.stdout.write(f' - {total_rooms} total rooms ({course_rooms} course rooms, {non_course_rooms} non-course rooms)')
-
- # Step 1: Delete all MessageReadStatus records
- deleted_read_statuses = MessageReadStatus.objects.all().delete()[0]
- self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_read_statuses} MessageReadStatus records'))
-
- # Step 2: Delete all ChatMessage records
- deleted_messages = ChatMessage.objects.all().delete()[0]
- self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_messages} ChatMessage records'))
-
- # Step 3: Handle rooms based on options
- if all_rooms:
- # Delete ALL rooms
- deleted_rooms = RoomMessage.objects.all().delete()[0]
- self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_rooms} RoomMessage records (including course rooms)'))
- else:
- # Delete only non-course rooms (rooms without course relationship)
- deleted_non_course_rooms = RoomMessage.objects.filter(course__isnull=True).delete()[0]
- self.stdout.write(self.style.SUCCESS(f'✓ Deleted {deleted_non_course_rooms} non-course RoomMessage records'))
-
- # Reset unread_messages_count for course rooms
- course_rooms_updated = RoomMessage.objects.filter(course__isnull=False).update(unread_messages_count=0)
- self.stdout.write(self.style.SUCCESS(f'✓ Reset unread_messages_count for {course_rooms_updated} course rooms'))
-
- self.stdout.write(self.style.SUCCESS(_('Chat data clearing completed successfully!')))
-
- except Exception as e:
- self.stdout.write(self.style.ERROR(f'Error occurred: {str(e)}'))
- raise
diff --git a/apps/chat/migrations/0001_initial.py b/apps/chat/migrations/0001_initial.py
deleted file mode 100644
index cc82c51..0000000
--- a/apps/chat/migrations/0001_initial.py
+++ /dev/null
@@ -1,221 +0,0 @@
-# Generated by Django 4.2.27 on 2026-01-22 10:48
-
-import apps.chat.models
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ("course", "0001_initial"),
- ]
-
- operations = [
- migrations.CreateModel(
- name="RoomMessage",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- ("name", models.CharField(max_length=255, verbose_name="Room Name")),
- (
- "description",
- models.TextField(blank=True, null=True, verbose_name="Description"),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- (
- "room_type",
- models.CharField(
- choices=[("group", "Group"), ("private", "Private")],
- default="group",
- max_length=10,
- verbose_name="Room Type",
- ),
- ),
- ("unread_messages_count", models.IntegerField(default=0)),
- (
- "course",
- models.ForeignKey(
- blank=True,
- null=True,
- on_delete=django.db.models.deletion.CASCADE,
- related_name="room_messages",
- to="course.course",
- verbose_name="Course",
- ),
- ),
- (
- "initiator",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="initiated_rooms",
- to=settings.AUTH_USER_MODEL,
- verbose_name="Initiator",
- ),
- ),
- (
- "recipient",
- models.ForeignKey(
- blank=True,
- null=True,
- on_delete=django.db.models.deletion.CASCADE,
- related_name="messages_received",
- to=settings.AUTH_USER_MODEL,
- verbose_name="Recipient",
- ),
- ),
- ],
- ),
- migrations.CreateModel(
- name="ChatMessage",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- ("content", models.TextField(verbose_name="Message Content")),
- (
- "content_type",
- models.CharField(
- choices=[
- ("text", "Text"),
- ("file", "File"),
- ("audio", "Audio"),
- ("image", "Image"),
- ],
- default="text",
- max_length=10,
- verbose_name="Chat Type",
- ),
- ),
- (
- "content_size",
- models.PositiveIntegerField(
- blank=True, null=True, verbose_name="Content Size (bytes)"
- ),
- ),
- (
- "file_attachment",
- models.FileField(
- blank=True,
- help_text="For file and audio messages",
- max_length=500,
- null=True,
- upload_to=apps.chat.models.chat_upload_path,
- verbose_name="File Attachment",
- ),
- ),
- (
- "image_attachment",
- models.ImageField(
- blank=True,
- help_text="For image messages",
- max_length=500,
- null=True,
- upload_to=apps.chat.models.chat_upload_path,
- verbose_name="Image Attachment",
- ),
- ),
- ("is_read", models.BooleanField(default=False, verbose_name="Is Read")),
- ("message_metadata", models.JSONField(blank=True, null=True)),
- (
- "sent_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Sent At"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- (
- "deleted_at",
- models.DateTimeField(
- blank=True, null=True, verbose_name="Deleted At"
- ),
- ),
- (
- "is_deleted",
- models.BooleanField(default=False, verbose_name="Is deleted"),
- ),
- (
- "room",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="messages",
- to="chat.roommessage",
- verbose_name="Room",
- ),
- ),
- (
- "sender",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="messages_sent",
- to=settings.AUTH_USER_MODEL,
- verbose_name="Sender",
- ),
- ),
- ],
- ),
- migrations.CreateModel(
- name="MessageReadStatus",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- ("is_read", models.BooleanField(default=False, verbose_name="Is Read")),
- (
- "read_at",
- models.DateTimeField(blank=True, null=True, verbose_name="Read At"),
- ),
- (
- "message",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="read_statuses",
- to="chat.chatmessage",
- verbose_name="Message",
- ),
- ),
- (
- "user",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="read_statuses",
- to=settings.AUTH_USER_MODEL,
- verbose_name="User",
- ),
- ),
- ],
- options={
- "unique_together": {("user", "message")},
- },
- ),
- ]
diff --git a/apps/chat/migrations/0002_roommessage_is_locked.py b/apps/chat/migrations/0002_roommessage_is_locked.py
deleted file mode 100644
index ccec89c..0000000
--- a/apps/chat/migrations/0002_roommessage_is_locked.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Generated by Django 5.2.12 on 2026-04-26 14:28
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('chat', '0001_initial'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='roommessage',
- name='is_locked',
- field=models.BooleanField(default=False, help_text='If True, only the professor and admins can send new messages.', verbose_name='Is Locked'),
- ),
- ]
diff --git a/apps/chat/migrations/0003_alter_chatmessage_options_and_more.py b/apps/chat/migrations/0003_alter_chatmessage_options_and_more.py
deleted file mode 100644
index 7b6bc0e..0000000
--- a/apps/chat/migrations/0003_alter_chatmessage_options_and_more.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# Generated by Django 5.2.12 on 2026-05-03 14:09
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('chat', '0002_roommessage_is_locked'),
- ]
-
- operations = [
- migrations.AlterModelOptions(
- name='chatmessage',
- options={'verbose_name': 'Chat Message', 'verbose_name_plural': 'Chat Messages'},
- ),
- migrations.AlterModelOptions(
- name='messagereadstatus',
- options={'verbose_name': 'Message Read Status', 'verbose_name_plural': 'Message Read Statuses'},
- ),
- migrations.AlterModelOptions(
- name='roommessage',
- options={'verbose_name': 'Room Message', 'verbose_name_plural': 'Room Messages'},
- ),
- migrations.AlterField(
- model_name='chatmessage',
- name='message_metadata',
- field=models.JSONField(blank=True, null=True, verbose_name='Message Metadata'),
- ),
- migrations.AlterField(
- model_name='roommessage',
- name='unread_messages_count',
- field=models.IntegerField(default=0, verbose_name='Unread Messages Count'),
- ),
- ]
diff --git a/apps/chat/migrations/0004_adminroomseen.py b/apps/chat/migrations/0004_adminroomseen.py
deleted file mode 100644
index 2638eee..0000000
--- a/apps/chat/migrations/0004_adminroomseen.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# Generated by Django 5.2.12 on 2026-06-06 02:02
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('chat', '0003_alter_chatmessage_options_and_more'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.CreateModel(
- name='AdminRoomSeen',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('admin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admin_room_seens', to=settings.AUTH_USER_MODEL, verbose_name='Admin User')),
- ('last_seen_message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='admin_seens_last', to='chat.chatmessage', verbose_name='Last Seen Message')),
- ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admin_seens', to='chat.roommessage', verbose_name='Room')),
- ],
- options={
- 'verbose_name': 'Admin Room Seen',
- 'verbose_name_plural': 'Admin Room Seens',
- 'unique_together': {('admin', 'room')},
- },
- ),
- ]
diff --git a/apps/chat/migrations/__init__.py b/apps/chat/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/chat/models.py b/apps/chat/models.py
deleted file mode 100644
index f8c68e5..0000000
--- a/apps/chat/models.py
+++ /dev/null
@@ -1,216 +0,0 @@
-from django.db import models
-from django.utils import timezone
-from django.utils.translation import gettext_lazy as _
-
-from apps.account.models import User
-from apps.course.models import Course
-
-
-def chat_upload_path(instance, filename):
- """
- Generate upload path for chat attachments
- Format: chat/room_{room_id}/YYYY/MM/DD/filename
- """
- date = timezone.now()
- return f'chat/room_{instance.room_id}/{date.year}/{date.month:02d}/{date.day:02d}/{filename}'
-
-
-class RoomMessage(models.Model):
- class RoomTypeChoices(models.TextChoices):
- GROUP = 'group', _('Group')
- PRIVATE = 'private', _('Private')
-
- name = models.CharField(
- max_length=255,
- verbose_name=_("Room Name")
- )
- description = models.TextField(
- verbose_name=_("Description"),
- blank=True,
- null=True
- )
- course = models.ForeignKey(Course, on_delete=models.CASCADE, null=True, blank=True, related_name="room_messages", verbose_name=_("Course"))
- initiator = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name="initiated_rooms",
- verbose_name=_("Initiator")
- )
- recipient = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name="messages_received",
- verbose_name=_("Recipient"),
- null=True,
- blank=True
- )
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
- updated_at = models.DateTimeField(
- auto_now=True,
- verbose_name=_("Updated At")
- )
-
- is_locked = models.BooleanField(
- default=False,
- verbose_name=_("Is Locked"),
- help_text=_("If True, only the professor and admins can send new messages.")
- )
- room_type = models.CharField(
- max_length=10,
- choices=RoomTypeChoices.choices,
- default=RoomTypeChoices.GROUP,
- verbose_name=_("Room Type")
- )
- unread_messages_count = models.IntegerField(default=0, verbose_name=_("Unread Messages Count"))
-
- def __str__(self):
- if self.room_type == self.RoomTypeChoices.GROUP:
- return f"Group Room: {self.course.title if self.course else 'N/A'}"
- return f"Private Room with {self.recipient}"
-
- class Meta:
- verbose_name = _("Room Message")
- verbose_name_plural = _("Room Messages")
-
-
-class ChatMessage(models.Model):
- class ChatTypeChoices(models.TextChoices):
- TEXT = 'text', _('Text')
- FILE = 'file', _('File')
- AUDIO = 'audio', _('Audio')
- IMAGE = 'image', _('Image')
-
- room = models.ForeignKey(
- RoomMessage,
- on_delete=models.CASCADE,
- related_name="messages",
- verbose_name=_("Room"),
- )
- sender = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name="messages_sent",
- verbose_name=_("Sender")
- )
- content = models.TextField(verbose_name=_("Message Content"))
- content_type = models.CharField(
- max_length=10,
- choices=ChatTypeChoices.choices,
- default=ChatTypeChoices.TEXT,
- verbose_name=_("Chat Type")
- )
- content_size = models.PositiveIntegerField(
- verbose_name=_("Content Size (bytes)"),
- blank=True,
- null=True
- )
- file_attachment = models.FileField(
- upload_to=chat_upload_path,
- blank=True,
- null=True,
- max_length=500,
- verbose_name=_("File Attachment"),
- help_text=_("For file and audio messages")
- )
- image_attachment = models.ImageField(
- upload_to=chat_upload_path,
- blank=True,
- null=True,
- max_length=500,
- verbose_name=_("Image Attachment"),
- help_text=_("For image messages")
- )
- is_read = models.BooleanField(default=False, verbose_name=_("Is Read"))
- message_metadata = models.JSONField(blank=True, null=True, verbose_name=_("Message Metadata"))
- sent_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Sent At"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
- deleted_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Deleted At"))
- is_deleted = models.BooleanField(default=False, verbose_name=_("Is deleted"))
-
- @property
- def file_url(self):
- """
- Get file URL - works for both old and new messages
- For backward compatibility with messages using content field
- """
- if self.image_attachment:
- return self.image_attachment.url
- elif self.file_attachment:
- return self.file_attachment.url
- elif self.content and self.content_type != 'text':
- # Legacy messages with URL in content field
- return self.content
- return None
-
- def delete(self, *args, **kwargs):
- """Override delete to remove uploaded files"""
- if self.file_attachment:
- self.file_attachment.delete(save=False)
- if self.image_attachment:
- self.image_attachment.delete(save=False)
- super().delete(*args, **kwargs)
-
- def __str__(self):
- return f"Message from {self.sender} in {self.room}"
-
- class Meta:
- verbose_name = _("Chat Message")
- verbose_name_plural = _("Chat Messages")
-
-
-class MessageReadStatus(models.Model):
- user = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name="read_statuses",
- verbose_name=_("User")
- )
- message = models.ForeignKey(
- ChatMessage,
- on_delete=models.CASCADE,
- related_name="read_statuses",
- verbose_name=_("Message")
- )
- is_read = models.BooleanField(default=False, verbose_name=_("Is Read"))
- read_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Read At"))
-
- class Meta:
- unique_together = ("user", "message") # جلوگیری از ثبت تکراری
- verbose_name = _("Message Read Status")
- verbose_name_plural = _("Message Read Statuses")
-
- def __str__(self):
- return f"User {self.user.fullname} read Message {self.message.id}: {self.is_read}"
-
-
-class AdminRoomSeen(models.Model):
- admin = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name="admin_room_seens",
- verbose_name=_("Admin User")
- )
- room = models.ForeignKey(
- RoomMessage,
- on_delete=models.CASCADE,
- related_name="admin_seens",
- verbose_name=_("Room")
- )
- last_seen_message = models.ForeignKey(
- ChatMessage,
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name="admin_seens_last",
- verbose_name=_("Last Seen Message")
- )
-
- class Meta:
- unique_together = ("admin", "room")
- verbose_name = _("Admin Room Seen")
- verbose_name_plural = _("Admin Room Seens")
-
- def __str__(self):
- return f"Admin {self.admin.fullname} seen in Room {self.room.id}"
-
-
\ No newline at end of file
diff --git a/apps/chat/signals.py b/apps/chat/signals.py
deleted file mode 100644
index 8dd489a..0000000
--- a/apps/chat/signals.py
+++ /dev/null
@@ -1,100 +0,0 @@
-import logging
-from django.db.models.signals import post_save
-from django.dispatch import receiver
-from apps.chat.models import ChatMessage, RoomMessage
-from apps.account.models import User
-from apps.account.notification_service import create_and_send_notification
-from apps.course.models.course import get_localized_field
-from apps.course.models.participant import Participant
-
-logger = logging.getLogger(__name__)
-
-def get_message_preview(instance, lang='fa'):
- if instance.content_type == 'text':
- preview = instance.content or ''
- if len(preview) > 50:
- return preview[:50] + '...'
- return preview
- elif instance.content_type == 'image':
- return 'تصویر' if lang == 'fa' else 'Image'
- elif instance.content_type == 'audio':
- return 'صدا' if lang == 'fa' else 'Audio'
- else:
- return 'فایل ضمیمه' if lang == 'fa' else 'Attachment'
-
-@receiver(post_save, sender=ChatMessage)
-def notify_on_new_chat_message(sender, instance, created, **kwargs):
- if not created:
- return
-
- sender_user = instance.sender
- room = instance.room
-
- # 1. Check if the message is in a PRIVATE room
- if room.room_type == RoomMessage.RoomTypeChoices.PRIVATE:
- # Determine the recipient user (the other person in the private room)
- recipient_user = None
- if room.initiator == sender_user:
- recipient_user = room.recipient
- else:
- recipient_user = room.initiator
-
- if not recipient_user:
- return
-
- # Case A: Teacher Replied in a Private Chat
- if sender_user.user_type == User.UserType.PROFESSOR:
- if recipient_user.user_type in [User.UserType.STUDENT, User.UserType.CLIENT]:
- preview_en = get_message_preview(instance, 'en')
- preview_fa = get_message_preview(instance, 'fa')
-
- create_and_send_notification(
- user=recipient_user,
- title_en="New Message from Teacher",
- body_en=f"Teacher {sender_user.fullname or sender_user.username} replied: {preview_en}",
- title_fa="پیام جدید از طرف استاد",
- body_fa=f"استاد {sender_user.fullname or sender_user.username} پاسخ داد: {preview_fa}",
- service='imam-javad',
- data={'type': 'teacher_reply_private', 'message_id': instance.id, 'room_id': room.id}
- )
-
- # Case B: Support Replied
- elif sender_user.user_type in [User.UserType.ADMIN, User.UserType.SUPER_ADMIN, User.UserType.CONSULTANT]:
- if recipient_user.user_type in [User.UserType.STUDENT, User.UserType.CLIENT]:
- preview_en = get_message_preview(instance, 'en')
- preview_fa = get_message_preview(instance, 'fa')
-
- create_and_send_notification(
- user=recipient_user,
- title_en="New Message from Support",
- body_en=f"Support agent {sender_user.fullname or sender_user.username} replied to your message: {preview_en}",
- title_fa="پاسخ پشتیبانی",
- body_fa=f"پشتیبان {sender_user.fullname or sender_user.username} به پیام شما پاسخ داد: {preview_fa}",
- service='imam-javad',
- data={'type': 'support_reply', 'message_id': instance.id, 'room_id': room.id}
- )
-
- # 2. Check if the message is in a GROUP/COURSE room
- elif room.room_type == RoomMessage.RoomTypeChoices.GROUP and room.course:
- # Case A: Teacher Replied in a Course Chat
- if sender_user.user_type == User.UserType.PROFESSOR:
- course = room.course
- course_title_en = get_localized_field('en', course.title)
- course_title_fa = get_localized_field('fa', course.title)
-
- preview_en = get_message_preview(instance, 'en')
- preview_fa = get_message_preview(instance, 'fa')
-
- # Notify all active enrolled students of the course
- participants = Participant.objects.filter(course=course, is_active=True).select_related('student')
- for p in participants:
- if p.student != sender_user: # Exclude sender
- create_and_send_notification(
- user=p.student,
- title_en="Teacher Post in Course Chat",
- body_en=f"Teacher {sender_user.fullname or sender_user.username} posted in '{course_title_en}': {preview_en}",
- title_fa="پیام استاد در گفتگوی دوره",
- body_fa=f"استاد {sender_user.fullname or sender_user.username} در گفتگوی دوره «{course_title_fa}» پیام جدیدی فرستاد: {preview_fa}",
- service='imam-javad',
- data={'type': 'teacher_reply_course', 'message_id': instance.id, 'room_id': room.id}
- )
diff --git a/apps/chat/tests.py b/apps/chat/tests.py
deleted file mode 100644
index 7ce503c..0000000
--- a/apps/chat/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/apps/chat/urls.py b/apps/chat/urls.py
deleted file mode 100644
index f2d68fb..0000000
--- a/apps/chat/urls.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from django.urls import path
-from .views import ChatMessageNotifyWebhookView
-
-urlpatterns = [
- path('notify-message/', ChatMessageNotifyWebhookView.as_view(), name='chat-notify-message'),
-]
diff --git a/apps/chat/views.py b/apps/chat/views.py
deleted file mode 100644
index 36318f5..0000000
--- a/apps/chat/views.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from rest_framework.views import APIView
-from rest_framework.response import Response
-from rest_framework.permissions import AllowAny
-from django.conf import settings
-from django.shortcuts import get_object_or_404
-from apps.chat.models import ChatMessage
-from apps.chat.signals import notify_on_new_chat_message
-
-class ChatMessageNotifyWebhookView(APIView):
- permission_classes = [AllowAny]
-
- def post(self, request, *args, **kwargs):
- token = request.headers.get('X-Internal-Secret') or request.query_params.get('secret')
- if token != getattr(settings, 'INTERNAL_WEBHOOK_SECRET', None):
- return Response({'error': 'Unauthorized'}, status=403)
-
- message_id = request.data.get('message_id')
- if not message_id:
- return Response({'error': 'message_id is required'}, status=400)
-
- message = get_object_or_404(ChatMessage, id=message_id)
-
- # Trigger the notification logic
- notify_on_new_chat_message(sender=ChatMessage, instance=message, created=True)
-
- return Response({'status': 'success'})
diff --git a/apps/course/__init__.py b/apps/course/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/course/access.py b/apps/course/access.py
deleted file mode 100644
index 41cfaa0..0000000
--- a/apps/course/access.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from apps.course.models import Participant
-
-
-def user_has_course_access(user, course):
- if not user or not getattr(user, 'is_authenticated', False):
- return False
-
- if user.is_staff or user.is_superuser:
- return True
-
- if course.professors.filter(id=user.id).exists():
- return True
-
- return Participant.objects.filter(
- student_id=user.id,
- course=course,
- is_active=True,
- ).exists()
diff --git a/apps/course/admin/__init__.py b/apps/course/admin/__init__.py
deleted file mode 100644
index bff68d7..0000000
--- a/apps/course/admin/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .course import *
-from .lesson import *
-from .participant import *
-from .live_session import *
diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py
deleted file mode 100644
index b03ce53..0000000
--- a/apps/course/admin/course.py
+++ /dev/null
@@ -1,603 +0,0 @@
-from utils.admin import admin_url_generator
-import os
-import hashlib
-
-from django.contrib import admin
-from django.contrib import messages
-from django import forms
-from django.utils.translation import gettext_lazy as _
-from django.db import models
-from django.utils.html import format_html
-from django.shortcuts import redirect, render
-from django.urls import reverse_lazy, reverse
-
-from unfold.admin import ModelAdmin, TabularInline
-from unfold.decorators import action, display
-from unfold.sections import TableSection
-from unfold.contrib.filters.admin import (
- ChoicesDropdownFilter,
- MultipleRelatedDropdownFilter,
- RangeDateFilter,
- RangeNumericFilter,
- TextFilter,
-)
-from unfold.widgets import UnfoldAdminSelectWidget
-
-from .professor_base import DirectCourseAdmin, CourseRelatedAdmin, AttachmentGlossaryBaseAdmin
-from utils.admin import project_admin_site, dovoodi_admin_site
-from utils.json_editor_field import JsonEditorWidget
-from apps.course.models import Course, Glossary, Attachment, CourseCategory, Participant, CourseGlossary, CourseAttachment
-from apps.course.models.lesson import Lesson, CourseLesson , CourseChapter
-from apps.account.models import StudentUser, User
-from apps.quiz.models import Quiz
-from utils.schema import get_weekly_timing_schema, get_course_feature_schema
-
-
-
-class CourseTableSection(TableSection):
- verbose_name = _("Course Categories")
- related_name = "courses"
- height = 380
- fields = [
- "title",
- "status",
- "edit_link"
- ]
-
- def edit_link(self, instance):
- return format_html(
- ''
- 'visibility'
- '',
- instance.id
- )
- edit_link.short_description = _("Edit")
-
-
-class CourseCategoryAdmin(ModelAdmin):
- list_display = ('name', 'slug', 'icon', 'course_count')
- search_fields = ('name',)
- list_sections = [CourseTableSection]
- fieldsets = (
- (None, {
- 'fields': ('name', 'slug', 'icon')
- }),
- )
-
- @display(description=_("Courses"))
- def course_count(self, obj):
- count = obj.courses.all().count()
- return format_html('{}', count)
-
-
-class CourseForm(forms.ModelForm):
- class Meta:
- model = Course
- fields = '__all__'
- exclude = ('slug',)
- widgets = {
- 'timing': JsonEditorWidget(attrs={
- 'schema': get_weekly_timing_schema(),
- 'title': _('Course Weekly Schedule'),
- }),
- 'features': JsonEditorWidget(attrs={
- 'schema': get_course_feature_schema(),
- 'title': _('Course Features'),
- }),
- }
- help_texts = {
- 'status': _('If set to inactive, the course will not be displayed.'),
- }
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.fields['short_description'].required = True
- if 'thumbnail' in self.fields:
- self.fields['thumbnail'].required = True
-
- def clean(self):
- cleaned_data = super().clean()
- thumbnail = cleaned_data.get('thumbnail')
- has_existing_thumbnail = bool(getattr(self.instance, 'thumbnail', None))
-
- if thumbnail is False:
- self.add_error('thumbnail', _('This field is required and cannot be cleared.'))
- return cleaned_data
-
- if (thumbnail is None or thumbnail == '') and not has_existing_thumbnail:
- self.add_error('thumbnail', _('This field is required.'))
- return cleaned_data
-
-# --- WIDTH ENFORCEMENT & PLACEHOLDER TEXT FOR DROPDOWNS ---
-class MinWidthInlineForm(forms.ModelForm):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- target_dropdown_fields = ['lesson', 'attachment', 'glossary', 'student']
-
- for field_name, field in self.fields.items():
- if field_name in target_dropdown_fields:
- if hasattr(field.widget, 'attrs'):
- existing_class = field.widget.attrs.get('class', '')
- field.widget.attrs['class'] = f"{existing_class} min-w-[250px] w-full"
-
- existing_style = field.widget.attrs.get('style', '')
- field.widget.attrs['style'] = f"{existing_style} min-width: 250px; width: 250px;"
-
- # 🔔 Add the custom placeholder text
- if hasattr(field, 'empty_label'):
- field.empty_label = _("Select a value")
-
-class CourseQuizInline(TabularInline):
- model = Quiz
- form = MinWidthInlineForm
- # فیلدهایی که میخواهید در لیست تب نمایش داده شوند
- fields = ('title', 'lesson', 'status')
- readonly_fields = ('title', 'lesson', 'status')
- extra = 0
- tab = True
- tab_id = "quizzes_tab"
-
- # 🎯 این خط جادویی است! یک لینک برای رفتن به صفحه دیتیل کوییز اضافه میکند
- show_change_link = True
-
- verbose_name = _("Quiz")
- verbose_name_plural = _("Quizzes")
-
- # ما فقط میخواهیم این تب نمایشی باشد، پس دسترسی اضافه/تغییر/حذف را در این تب میبندیم
- def has_add_permission(self, request, obj):
- return False
- def has_change_permission(self, request, obj=None):
- return False
- def has_delete_permission(self, request, obj=None):
- return False
-
-class CourseAttachmentInline(TabularInline):
- model = CourseAttachment
- form = MinWidthInlineForm
- extra = 1 # Show 1 empty dropdown by default
- fields = ('attachment',)
- tab = True
- # Removed autocomplete_fields to restore Unfold UI
- verbose_name = _("Course Attachment")
- verbose_name_plural = _("Course Attachments")
-
-
-class CourseGlossaryInline(TabularInline):
- model = CourseGlossary
- form = MinWidthInlineForm
- fields = ('glossary',)
- extra = 1 # Show 1 empty dropdown by default
- tab = True
- tab_id = "glossaries_tab"
- show_change_link = True
- # Removed autocomplete_fields to restore Unfold UI
-
-class CourseChapterInline(TabularInline):
- model = CourseChapter
- form = MinWidthInlineForm
- fields = ('title', 'is_active', 'priority')
- extra = 1
- tab = True
- tab_id = "chapters_tab"
- show_change_link = True
- ordering_field = "priority"
- verbose_name = _("Chapter")
- verbose_name_plural = _("Chapters")
-
-class CourseLessonInline(TabularInline):
- model = CourseLesson
- form = MinWidthInlineForm
- # Remove 'course' if it was there, add 'chapter'
- fields = ('chapter', 'lesson', 'title', 'is_active', 'priority')
- extra = 0 # Set to 0 so the page doesn't get massive
- tab = True
- tab_id = "lessons_tab"
- show_change_link = True
- ordering_field = "priority"
-
- def get_queryset(self, request):
- qs = super().get_queryset(request)
- object_id = request.resolver_match.kwargs.get('object_id')
- if object_id:
- # Only show lessons belonging to chapters in this course
- return qs.filter(chapter__course_id=object_id).order_by('chapter__priority', 'priority')
- return qs.none()
-
- def formfield_for_foreignkey(self, db_field, request, **kwargs):
- if db_field.name == "chapter":
- object_id = request.resolver_match.kwargs.get('object_id')
- if object_id:
- # Limit the chapter dropdown to only chapters belonging to THIS course
- kwargs["queryset"] = CourseChapter.objects.filter(course_id=object_id)
- return super().formfield_for_foreignkey(db_field, request, **kwargs)
- # Removed autocomplete_fields to restore Unfold UI
-
-
-class ParticipantInline(TabularInline):
- model = Participant
- form = MinWidthInlineForm
- fields = ('student', 'joined_date',)
- readonly_fields = ('joined_date', 'student')
- extra = 0 # Remains 0 because users are added via action buttons
- tab = True
- tab_id = "participants_tab"
- verbose_name = _("Recent Participant")
- verbose_name_plural = _("Recent Participants (Latest 10)")
- show_change_link = True
-
- def get_queryset(self, request):
- qs = super().get_queryset(request)
- object_id = request.resolver_match.kwargs.get('object_id')
-
- if object_id:
- latest_ids = list(qs.filter(course_id=object_id).order_by('-joined_date').values_list('id', flat=True)[:10])
- return qs.filter(id__in=latest_ids).order_by('-joined_date')
-
- return qs.none()
-
- def has_add_permission(self, request, obj):
- return False
- def has_change_permission(self, request, obj=None):
- return False
- def has_delete_permission(self, request, obj=None):
- return False
-
-
-class AddStudentForm(forms.Form):
- student = forms.ModelChoiceField(
- queryset=User.objects.filter(is_active=True , email__isnull=False),
- label=_("Select Student"),
- widget=UnfoldAdminSelectWidget,
- required=True
- )
-
-
-class CourseAdmin(DirectCourseAdmin):
- form = CourseForm
- inlines = [CourseChapterInline, CourseLessonInline, CourseAttachmentInline, CourseGlossaryInline, CourseQuizInline, ParticipantInline]
-
- list_display = ('display_header', 'category', 'display_professor', 'status', 'display_price', 'is_online')
- list_filter = [
- ('status', ChoicesDropdownFilter),
- ('level', ChoicesDropdownFilter),
- 'is_online',
- 'is_free',
- ('category', MultipleRelatedDropdownFilter),
- ('price', RangeNumericFilter),
- ]
- save_as = True
- warn_unsaved_form = True
- search_fields = ('id','title', 'description')
- exclude = ('slug', )
- readonly_fields = ('final_price', 'final_price_rub')
- autocomplete_fields = ('category', 'professors',)
- list_filter_submit = True
- change_form_show_cancel_button = True
-
- radio_fields = {
- "video_type": admin.HORIZONTAL,
- }
-
- conditional_fields = {
- 'price': "is_free == false",
- 'price_rub': "is_free == false",
- 'discount_percentage': "is_free == false",
- 'final_price': "is_free == false",
- 'final_price_rub': "is_free == false",
- 'online_link': "is_online",
- 'video_file': "video_type == 'video_file'",
- 'video_link': "video_type == 'youtube_link'",
- }
-
- fieldsets = (
- (None, {
- 'fields': ('title', 'category', 'professors', 'thumbnail', 'description', 'short_description')
- }),
- (_('Settings & Status'), {
- 'fields': (
- ('status', 'level'),
- ('duration', 'lessons_count'),
- ('is_group_chat_locked', 'is_professor_chat_locked'),
- ('is_online', 'online_link')
- ),
- 'classes': ['tab'],
- }),
- (_('Media'), {
- 'fields': (
- ('video_type', 'video_file', 'video_link'),
- ),
- 'classes': ['tab'],
- }),
- (_('Pricing'), {
- 'fields': (
- ('is_free', 'price', 'price_rub'),
- ('discount_percentage', 'final_price', 'final_price_rub')
- ),
- 'classes': ['tab'],
- }),
- (_('Advanced Configuration'), {
- 'fields': ('timing', 'features'),
- 'classes': ['tab'],
- }),
- )
-
-
- @display(description=_("Course"), header=True)
- def display_header(self, instance):
- from django.templatetags.static import static
- thumbnail_path = instance.thumbnail.url if instance.thumbnail else None
- return [
- instance.title,
- None, None,
- {
- "path": thumbnail_path,
- "height": 40,
- "width": 60,
- "squared": True,
- "borderless": True,
- },
- ]
-
- @display(description=_("Professor"))
- def display_professor(self, instance):
- return ", ".join([p.fullname for p in instance.professors.all()])
-
- @display(description=_("Price"))
- def display_price(self, instance):
- if instance.is_free:
- return format_html('{}', _("Free"))
-
- if instance.discount_percentage > 0:
- return format_html(
- 'USD: ${} -> ${}'
- 'RUB: ₽{} -> ₽{}',
- instance.price,
- instance.final_price,
- instance.price_rub,
- instance.final_price_rub,
- )
- return format_html(
- 'USD: ${}RUB: ₽{}',
- instance.final_price,
- instance.final_price_rub,
- )
-
-
- actions_row = ["add_student_to_course"]
- actions_detail = ['add_student_to_course', 'manage_all_students']
-
- def has_is_course_professor_permission(self, request, object_id=None):
- try:
- if request.user.is_staff:
- return True
- course = self.get_object(request, object_id)
- return course and request.user.can_manage_course(course)
- except Exception as e:
- return False
-
- @action(
- description=_("Add Student"),
- icon="person_add",
- permissions=["is_course_professor"],
- )
- def add_student_to_course(self, request, object_id):
- course = self.get_object(request, object_id)
- if not course:
- messages.error(request, _("Course not found"))
- return redirect(admin_url_generator(request, "course_course_changelist"))
-
- if request.method == 'POST':
- form = AddStudentForm(request.POST)
- if form.is_valid():
- student = form.cleaned_data['student']
- if Participant.objects.filter(student=student, course=course).exists():
- messages.warning(request, _("Student {} is already enrolled in this course").format(student.fullname))
- else:
- if not student.has_role('student'):
- student.add_role('student')
- Participant.objects.create(student=student, course=course)
- messages.success(request, _("Student {} has been successfully added to {}").format(student.fullname, course.title))
- return redirect(admin_url_generator(request, "course_course_changelist"))
- else:
- form = AddStudentForm()
-
- return render(
- request,
- "course/add_student_form.html",
- {
- "form": form,
- "object": object,
- "title": _("Add Student to {}").format(course.title),
- **self.admin_site.each_context(request),
- },
- )
-
- @action(
- description=_("Manage All Students"),
- icon="groups",
- permissions=["is_course_professor"],
- )
- def manage_all_students(self, request, object_id):
- course = self.get_object(request, object_id)
- if not course:
- messages.error(request, _("Course not found"))
- return redirect(admin_url_generator(request, "course_course_changelist"))
-
- base_url = admin_url_generator(request, "course_participant_changelist")
- url = f"{base_url}?course__id__exact={object_id}"
- return redirect(url)
-
-class GlossaryAdmin(AttachmentGlossaryBaseAdmin):
- list_display = ('title', 'description')
- search_fields = ('title', 'description')
- ordering = ('-id',)
-
- def is_used_in_professor_courses(self, user, obj):
- return obj.courseglossary_set.filter(course__professors=user).exists()
-
- def filter_by_professor_usage(self, user, queryset):
- return queryset.filter(courseglossary__course__professors=user).distinct()
-
-
-class CourseGlossaryAdmin(CourseRelatedAdmin):
- list_display = ('course', 'glossary_title', 'glossary_description')
- list_filter = ('course',)
- search_fields = ('glossary__title', 'glossary__description', 'course__title')
- ordering = ('-id',)
- autocomplete_fields = ('course', 'glossary')
-
- # 🔔 REMOVES FROM "ALL APPLICATIONS" MODAL
- def has_module_permission(self, request):
- return False
-
- @admin.display(description=_("Title"))
- def glossary_title(self, obj):
- return obj.glossary.title
-
- @admin.display(description=_("Description"))
- def glossary_description(self, obj):
- return obj.glossary.description
-
-
-class AttachmentAdminForm(forms.ModelForm):
- class Meta:
- model = Attachment
- fields = '__all__'
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- if 'file' in self.data or 'file' in self.files:
- file = self.files.get('file')
- if file:
- file.name = self._shorten_file_name(file.name)
-
- def _shorten_file_name(self, file_name):
- max_length = 100
- if len(file_name) > max_length:
- base_name, ext = os.path.splitext(file_name)
- allowed_length = max_length - len(ext)
- base_length = int(allowed_length * 0.8)
- hash_length = allowed_length - base_length
- base_part = base_name[:base_length]
- hash_part = hashlib.sha256(base_name.encode('utf-8')).hexdigest()[:hash_length]
- return f"{base_part}{hash_part}{ext}"
- return file_name
-
-
-class AttachmentAdmin(AttachmentGlossaryBaseAdmin):
- form = AttachmentAdminForm
- list_display = ('title', 'file', 'file_size')
- search_fields = ('title', 'file')
-
- def save_model(self, request, obj, form, change):
- if obj.file:
- obj.file_size = obj.file.size
- super().save_model(request, obj, form, change)
-
- def is_used_in_professor_courses(self, user, obj):
- return obj.courseattachment_set.filter(course__professors=user).exists()
-
- def filter_by_professor_usage(self, user, queryset):
- return queryset.filter(courseattachment__course__professors=user).distinct()
-
-
-class CourseAttachmentAdmin(CourseRelatedAdmin):
- list_display = ('course', 'attachment_title', 'attachment_file', 'attachment_file_size')
- list_filter = ('course',)
- search_fields = ('attachment__title', 'course__title')
- autocomplete_fields = ('course', 'attachment')
-
- # 🔔 REMOVES FROM "ALL APPLICATIONS" MODAL
- def has_module_permission(self, request):
- return False
-
- @admin.display(description=_("Title"))
- def attachment_title(self, obj):
- return obj.attachment.title
-
- @admin.display(description=_("File"))
- def attachment_file(self, obj):
- return obj.attachment.file
-
- @admin.display(description=_("File Size"))
- def attachment_file_size(self, obj):
- return obj.attachment.file_size
-
-
-class ParticipantAdmin(ModelAdmin):
- list_display = ('student_name', 'course_title', 'joined_date',)
- list_filter = (
- ('course', MultipleRelatedDropdownFilter),
- )
- search_fields = ('student__email', 'student__fullname', 'course__title')
- readonly_fields = ('joined_date',)
- autocomplete_fields = ('student', 'course')
-
- fieldsets = (
- (None, {
- 'fields': ('student', 'course', 'is_active')
- }),
- (_('Enrollment Details'), {
- 'fields': ('joined_date', 'unread_messages_count')
- }),
- )
-
- @display(description=_("Student"), header=True)
- def student_name(self, instance):
- from django.templatetags.static import static
-
- avatar_path = instance.student.avatar.url if getattr(instance.student, 'avatar', None) else static("images/reading(1).png")
-
- return [
- instance.student.fullname,
- None,
- None,
- {
- "path": avatar_path,
- "height": 30,
- "width": 36,
- "borderless": True,
- },
- ]
-
- @display(description=_("Course"))
- def course_title(self, obj):
- if obj.course:
- return obj.course.title
- return "-"
-
-
-# =========================================================
-# REGISTRATIONS
-# =========================================================
-from django.contrib import admin as django_admin
-
-try:
- django_admin.site.register(Course, CourseAdmin)
- django_admin.site.register(CourseCategory, CourseCategoryAdmin)
- django_admin.site.register(Glossary, GlossaryAdmin)
- django_admin.site.register(Attachment, AttachmentAdmin)
-except django_admin.sites.AlreadyRegistered:
- pass
-
-project_admin_site.register(Course, CourseAdmin)
-project_admin_site.register(CourseCategory, CourseCategoryAdmin)
-project_admin_site.register(Glossary, GlossaryAdmin)
-project_admin_site.register(CourseGlossary, CourseGlossaryAdmin)
-project_admin_site.register(Attachment, AttachmentAdmin)
-project_admin_site.register(CourseAttachment, CourseAttachmentAdmin)
-project_admin_site.register(Participant, ParticipantAdmin)
-
-class HiddenCourseAdmin(ModelAdmin):
- search_fields = ('title', 'id')
-
- def has_module_permission(self, request):
- return False
- def has_add_permission(self, request):
- return False
- def has_change_permission(self, request, obj=None):
- return False
- def has_delete_permission(self, request, obj=None):
- return False
-
-dovoodi_admin_site.register(Course, HiddenCourseAdmin)
diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py
deleted file mode 100644
index 5683499..0000000
--- a/apps/course/admin/lesson.py
+++ /dev/null
@@ -1,120 +0,0 @@
-import os
-from django.contrib import admin
-from django import forms
-from django.utils.translation import gettext_lazy as _
-from django.utils.html import format_html
-
-from unfold.admin import ModelAdmin
-from unfold.decorators import display
-from unfold.contrib.filters.admin import (
- ChoicesDropdownFilter,
- MultipleRelatedDropdownFilter,
-)
-from unfold.widgets import UnfoldAdminRadioSelectWidget
-
-from utils.admin import project_admin_site
-from .professor_base import CourseRelatedAdmin
-from apps.course.models.lesson import Lesson, CourseLesson, LessonCompletion, CourseChapter
-
-
-class LessonForm(forms.ModelForm):
- class Meta:
- model = Lesson
- fields = '__all__'
-
-
-class CourseLessonForm(forms.ModelForm):
- class Meta:
- model = CourseLesson
- fields = '__all__'
-
-
-class LessonAdmin(ModelAdmin):
- form = LessonForm
- list_display = ('title', 'display_duration', 'content_type')
- search_fields = ('title',)
- ordering = ('title',)
- list_filter_submit = True
-
- fieldsets = (
- (None, {
- 'fields': (('title', 'duration'),)
- }),
- (_('Content'), {
- 'fields': ('content_type', 'content_file', 'video_link'),
- }),
- )
-
- @display(description=_("Duration"))
- def display_duration(self, obj):
- return format_html('{} {}', obj.duration, _("min"))
-
-
-class CourseChapterAdmin(CourseRelatedAdmin):
- list_display = ('title', 'course', 'priority', 'is_active')
- list_filter = (
- ('course', MultipleRelatedDropdownFilter),
- 'is_active',
- )
- search_fields = ('title', 'course__title')
- ordering = ('course', 'priority')
- autocomplete_fields = ('course',)
-
- def has_module_permission(self, request):
- return False
-
-
-
-class CourseLessonAdmin(CourseRelatedAdmin):
- form = CourseLessonForm
- # Change 'course' to 'chapter' in list_display and ordering
- list_display = ('title', 'chapter', 'display_duration', 'is_active', 'priority')
- list_filter = (
- ('chapter__course', MultipleRelatedDropdownFilter),
- ('chapter', MultipleRelatedDropdownFilter),
- 'is_active',
- )
- search_fields = ('title', 'chapter__title', 'chapter__course__title')
- ordering = ('chapter__course', 'chapter', 'priority')
- # Change 'course' to 'chapter' in autocomplete_fields
- autocomplete_fields = ('chapter', 'lesson')
- list_filter_submit = True
-
- def has_module_permission(self, request):
- return False
-
- fieldsets = (
- (None, {
- # Removed 'course', added 'chapter'
- 'fields': ('chapter', 'lesson', 'title', ('priority', 'is_active'))
- }),
- )
-
- @display(description=_("Duration"))
- def display_duration(self, obj):
- return format_html('{} {}', obj.lesson.duration, _("min"))
-
- def get_queryset(self, request):
- qs = super().get_queryset(request)
- return qs.order_by('chapter__course', 'chapter', 'priority')
-
-
-class LessonCompletionAdmin(ModelAdmin):
- list_display = ('student', 'course_lesson', 'completed_at')
- search_fields = ('student__fullname', 'student__email', 'course_lesson__title', 'course_lesson__course__title')
- list_filter = ('course_lesson__course', 'completed_at')
- ordering = ('-completed_at',)
-
- def get_readonly_fields(self, request, obj=None):
- if obj:
- return ['student', 'course_lesson', 'completed_at']
- return []
-
-
-from django.contrib import admin as django_admin
-django_admin.site.register(Lesson, LessonAdmin)
-
-project_admin_site.register(Lesson, LessonAdmin)
-project_admin_site.register(CourseChapter, CourseChapterAdmin)
-project_admin_site.register(CourseLesson, CourseLessonAdmin)
-project_admin_site.register(LessonCompletion, LessonCompletionAdmin)
\ No newline at end of file
diff --git a/apps/course/admin/live_session.py b/apps/course/admin/live_session.py
deleted file mode 100644
index 9c571fd..0000000
--- a/apps/course/admin/live_session.py
+++ /dev/null
@@ -1,177 +0,0 @@
-from django.contrib import admin
-from django import forms
-from django.utils.translation import gettext_lazy as _
-
-from unfold.admin import ModelAdmin, StackedInline
-from unfold.contrib.filters.admin import (
- ChoicesDropdownFilter,
- MultipleRelatedDropdownFilter,
- RangeDateFilter,
-)
-
-from utils.admin import project_admin_site
-from apps.course.models import (
- CourseLiveSession,
- LiveSessionRecording,
- LiveSessionUser,
- USER_ROLE_CHOICES,
- RECORDING_TYPE_CHOICES,
-)
-from django.contrib.auth import get_user_model
-
-User = get_user_model()
-
-
-# 🔔 CUSTOM FILTER: Only show active, non-guest users in the right sidebar filter
-class ActiveUserDropdownFilter(MultipleRelatedDropdownFilter):
- def field_choices(self, field, request, model_admin):
- return field.get_choices(
- include_blank=False,
- limit_choices_to={'is_active': True, 'email__isnull': False}
- )
-
-
-# --- WIDTH ENFORCEMENT & PLACEHOLDER TEXT FOR DROPDOWNS ---
-class MinWidthInlineForm(forms.ModelForm):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- # Target the dropdown fields to ensure they don't collapse
- target_dropdown_fields = ['user', 'role', 'recording_type']
-
- for field_name, field in self.fields.items():
- if field_name in target_dropdown_fields:
- if hasattr(field.widget, 'attrs'):
- existing_class = field.widget.attrs.get('class', '')
- field.widget.attrs['class'] = f"{existing_class} min-w-[250px] w-full"
-
- existing_style = field.widget.attrs.get('style', '')
- field.widget.attrs['style'] = f"{existing_style} min-width: 250px; width: 250px;"
-
- # 🔔 Add the custom placeholder text
- if hasattr(field, 'empty_label'):
- field.empty_label = _("Select a value")
-
-
-class LiveSessionUserInline(StackedInline):
- model = LiveSessionUser
- form = MinWidthInlineForm
- extra = 1
- tab = True
-
- fieldsets = (
- (None, {
- 'fields': (('user', 'role', 'is_online'),) # Grouped horizontally
- }),
- (_("Timing"), {
- 'fields': (('entered_at', 'exited_at'),) # Grouped horizontally
- }),
- )
-
- show_change_link = True
- verbose_name = _("Session User")
- verbose_name_plural = _("Session Users")
-
- # 🔔 FILTER THE USER DROPDOWN IN THE FORM ITSELF
- def formfield_for_foreignkey(self, db_field, request, **kwargs):
- if db_field.name == "user":
- kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
- return super().formfield_for_foreignkey(db_field, request, **kwargs)
-
-
-class LiveSessionRecordingInline(StackedInline):
- model = LiveSessionRecording
- form = MinWidthInlineForm
- extra = 1
- tab = True
-
- fieldsets = (
- (None, {
- 'fields': (('title', 'recording_type', 'is_active'),) # Grouped horizontally
- }),
- (_("Media"), {
- 'fields': ('file',)
- }),
- )
-
- show_change_link = True
- verbose_name = _("Session Recording")
- verbose_name_plural = _("Session Recordings")
-
-
-class CourseLiveSessionAdmin(ModelAdmin):
- list_display = ("subject", "course", "started_at", "ended_at", "created_at")
- list_filter = (
- ("course", MultipleRelatedDropdownFilter),
- ("started_at", RangeDateFilter),
- )
- search_fields = ("subject", "course__title")
- ordering = ("-started_at",)
- autocomplete_fields = ("course",)
- readonly_fields = ("created_at", "updated_at")
-
- # Add the custom tabs here
- inlines = [LiveSessionUserInline, LiveSessionRecordingInline]
-
- fieldsets = (
- (None, {"fields": ("course", "subject", "started_at", "ended_at")}),
- (_("Timestamps"), {"fields": ("created_at", "updated_at")}),
- )
-
-
-class LiveSessionUserAdmin(ModelAdmin):
- list_display = ("user", "session", "role", "is_online", "entered_at", "exited_at")
- list_filter = (
- ("session", MultipleRelatedDropdownFilter),
- ("user", ActiveUserDropdownFilter), # 🔔 APPLIED CUSTOM FILTER HERE!
- ("role", ChoicesDropdownFilter),
- ("entered_at", RangeDateFilter),
- ("is_online", admin.BooleanFieldListFilter),
- )
- search_fields = (
- "user__email",
- "user__fullname",
- "session__subject",
- )
- autocomplete_fields = ("user", "session")
- readonly_fields = ("created_at", "updated_at")
- fieldsets = (
- (None, {"fields": ("session", "user", "role")}),
- (_("Session Timing"), {"fields": ("entered_at", "exited_at", "is_online")}),
- (_("Timestamps"), {"fields": ("created_at", "updated_at")}),
- )
-
- def get_role_choices(self, request):
- return USER_ROLE_CHOICES
-
- # FILTER THE STANDALONE ADMIN FORM AS WELL JUST IN CASE
- def formfield_for_foreignkey(self, db_field, request, **kwargs):
- if db_field.name == "user":
- kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
- return super().formfield_for_foreignkey(db_field, request, **kwargs)
-
-
-class LiveSessionRecordingAdmin(ModelAdmin):
- list_display = ("title", "session", "recording_type", "is_active", "created_at")
- list_filter = (
- ("session", MultipleRelatedDropdownFilter),
- ("recording_type", ChoicesDropdownFilter),
- ("created_at", RangeDateFilter),
- ("is_active", admin.BooleanFieldListFilter),
- )
- search_fields = ("title", "session__subject", "session__course__title")
- autocomplete_fields = ("session",)
- readonly_fields = ("created_at", "updated_at")
- fieldsets = (
- (None, {"fields": ("session", "title", "recording_type")}),
- (_("Files"), {"fields": ("file", "file_time", "thumbnail")}),
- (_("Status"), {"fields": ("is_active",)}),
- (_("Timestamps"), {"fields": ("created_at", "updated_at")}),
- )
-
- def get_recording_type_choices(self, request):
- return RECORDING_TYPE_CHOICES
-
-
-project_admin_site.register(CourseLiveSession, CourseLiveSessionAdmin)
-project_admin_site.register(LiveSessionUser, LiveSessionUserAdmin)
-project_admin_site.register(LiveSessionRecording, LiveSessionRecordingAdmin)
\ No newline at end of file
diff --git a/apps/course/admin/participant.py b/apps/course/admin/participant.py
deleted file mode 100644
index 50a0aae..0000000
--- a/apps/course/admin/participant.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from django.contrib import admin
-
-from apps.course.models import Participant
-from apps.account.models import StudentUser, User
-
-
-
-@admin.register(Participant)
-class ParticipantAdmin(admin.ModelAdmin):
- list_display = ('student', 'course', 'joined_date', 'unread_messages_count')
- search_fields = ('student__fullname', 'student__email', 'course__title')
- list_filter = ('course', 'joined_date')
- ordering = ('-joined_date',)
- autocomplete_fields = ['student'] # جستجوی پویا برای فیلد دانشآموز
-
- def get_readonly_fields(self, request, obj=None):
- """
- Make fields readonly if the object already exists.
- """
- if obj:
- return ['student', 'course', 'joined_date']
- return []
-
-
- def get_form(self, request, obj=None, **kwargs):
- form = super().get_form(request, obj, **kwargs)
- if obj is None: # Adding a new participant
- # محدود کردن انتخاب دانشآموزان به کاربرانی که از نوع StudentUser هستند
- # form.base_fields['student'].queryset = StudentUser.objects.filter(user_type=User.UserType.STUDENT)
- form.base_fields['student'].widget.can_add_related = True # فعال کردن دکمه اضافه کردن
-
- return form
-
\ No newline at end of file
diff --git a/apps/course/admin/professor_base.py b/apps/course/admin/professor_base.py
deleted file mode 100644
index 6152600..0000000
--- a/apps/course/admin/professor_base.py
+++ /dev/null
@@ -1,181 +0,0 @@
-"""
-Base admin classes برای استادان
-"""
-from django.contrib import admin
-from django.contrib.admin import ModelAdmin
-from django.utils.translation import gettext_lazy as _
-from unfold.admin import ModelAdmin as UnfoldModelAdmin
-
-
-class ProfessorBaseAdmin(UnfoldModelAdmin):
- """Base admin class برای استادان"""
-
- def has_module_permission(self, request):
- """آیا کاربر میتواند این ماژول را ببیند؟"""
- # چک کردن احراز هویت
- if not request.user.is_authenticated:
- return False
-
- # اولویت اول: staff یا admin
- if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
- return True
- # اولویت دوم: professor
- return request.user.has_role('professor')
-
- def has_view_permission(self, request, obj=None):
- """آیا میتواند مشاهده کند؟"""
- # چک کردن احراز هویت
- if not request.user.is_authenticated:
- return False
-
- # اولویت اول: staff یا admin - دسترسی کامل
- if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
- return True
- # اولویت دوم: professor - دسترسی محدود
- if request.user.has_role('professor'):
- if obj is None:
- return True
- return self.can_access_object(request.user, obj)
- return False
-
- def has_add_permission(self, request):
- """آیا میتواند اضافه کند؟"""
- # چک کردن احراز هویت
- if not request.user.is_authenticated:
- return False
-
- # اولویت اول: staff یا admin - دسترسی کامل
- if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
- return True
- # اولویت دوم: professor
- return request.user.has_role('professor')
-
- def has_change_permission(self, request, obj=None):
- """آیا میتواند تغییر دهد؟"""
- # چک کردن احراز هویت
- if not request.user.is_authenticated:
- return False
-
- # اولویت اول: staff یا admin - دسترسی کامل
- if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
- return True
- # اولویت دوم: professor - دسترسی محدود
- if request.user.has_role('professor'):
- if obj is None:
- return True
- return self.can_access_object(request.user, obj)
- return False
-
- def has_delete_permission(self, request, obj=None):
- """آیا میتواند حذف کند؟"""
- # چک کردن احراز هویت
- if not request.user.is_authenticated:
- return False
-
- # اولویت اول: staff یا admin - دسترسی کامل
- if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
- return True
- # اولویت دوم: professor - دسترسی محدود
- if request.user.has_role('professor'):
- if obj is None:
- return True
- return self.can_access_object(request.user, obj)
- return False
-
- def can_access_object(self, user, obj):
- """آیا کاربر میتواند به این object دسترسی داشته باشد؟"""
- # این method باید در subclass ها override شود
- return True
-
- def get_queryset(self, request):
- """فیلتر کردن queryset بر اساس دسترسی کاربر"""
- qs = super().get_queryset(request)
-
- # چک کردن احراز هویت
- if not request.user.is_authenticated:
- return qs.none()
-
- # اولویت اول: staff یا admin - دسترسی کامل
- if request.user.is_staff or request.user.has_role('admin') or request.user.has_role('super_admin'):
- return qs
- # اولویت دوم: professor - دسترسی محدود
- if request.user.has_role('professor'):
- return self.filter_queryset_for_professor(request, qs)
- return qs.none()
-
- def filter_queryset_for_professor(self, request, queryset):
- """فیلتر کردن queryset برای استاد"""
- # این method باید در subclass ها override شود
- return queryset
-
-
-class CourseRelatedAdmin(ProfessorBaseAdmin):
- """Base admin برای مدلهایی که به Course مرتبط هستند"""
-
- def can_access_object(self, user, obj):
- """چک کردن دسترسی بر اساس Course"""
- course = self.get_course_from_object(obj)
- if course:
- return user.can_manage_course(course)
- return False
-
- def filter_queryset_for_professor(self, request, queryset):
- """فیلتر کردن بر اساس دورههای استاد"""
- return queryset.filter(course__professors=request.user)
-
- def get_course_from_object(self, obj):
- """دریافت Course از object"""
- # این method باید در subclass ها override شود
- if hasattr(obj, 'course'):
- return obj.course
- return None
-
-
-class DirectCourseAdmin(ProfessorBaseAdmin):
- """Admin برای خود مدل Course"""
-
- def can_access_object(self, user, obj):
- """چک کردن دسترسی به Course"""
- return user.can_manage_course(obj)
-
- def filter_queryset_for_professor(self, request, queryset):
- """فقط دورههای خود استاد"""
- return queryset.filter(professors=request.user)
-
-
-class AttachmentGlossaryBaseAdmin(ProfessorBaseAdmin):
- """Base admin برای Attachment و Glossary"""
-
- def can_access_object(self, user, obj):
- """چک کردن دسترسی - فقط اگر در دورههای استاد استفاده شده"""
- # چک کنیم که آیا این attachment/glossary در دورههای استاد استفاده شده
- return self.is_used_in_professor_courses(user, obj)
-
- def filter_queryset_for_professor(self, request, queryset):
- """فیلتر کردن بر اساس استفاده در دورههای استاد"""
- return self.filter_by_professor_usage(request.user, queryset)
-
- def is_used_in_professor_courses(self, user, obj):
- """آیا در دورههای استاد استفاده شده؟"""
- # باید در subclass ها پیادهسازی شود
- return True
-
- def filter_by_professor_usage(self, user, queryset):
- """فیلتر کردن بر اساس استفاده در دورههای استاد"""
- # باید در subclass ها پیادهسازی شود
- return queryset
-
-
-class CertificateBaseAdmin(ProfessorBaseAdmin):
- """Base admin برای Certificate"""
-
- def can_access_object(self, user, obj):
- """چک کردن دسترسی به Certificate"""
- # فقط certificate های دانشآموزان دورههای خودش
- if hasattr(obj, 'course') and obj.course:
- return user.can_manage_course(obj.course)
- return False
-
- def filter_queryset_for_professor(self, request, queryset):
- """فقط certificate های دانشآموزان دورههای استاد"""
- return queryset.filter(course__professors=request.user)
diff --git a/apps/course/apps.py b/apps/course/apps.py
deleted file mode 100644
index b56f637..0000000
--- a/apps/course/apps.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from django.apps import AppConfig
-
-
-class CourseConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'apps.course'
-
- def ready(self):
- import apps.course.signals
\ No newline at end of file
diff --git a/apps/course/data/category.json b/apps/course/data/category.json
deleted file mode 100644
index 092e449..0000000
--- a/apps/course/data/category.json
+++ /dev/null
@@ -1,42 +0,0 @@
-[
- {
- "id": 8,
- "name": "Комплексный годовой курс",
- "slug": "kompleksnyi-godovoi-kurs"
- },
- {
- "id": 7,
- "name": "Исламская философия",
- "slug": "islamskaia-filosofiia"
- },
- {
- "id": 6,
- "name": "Арабский диалог",
- "slug": "arabskii-dialog"
- },
- {
- "id": 5,
- "name": "грамматике арабского языка",
- "slug": "grammatike-arabskogo-iazyka"
- },
- {
- "id": 4,
- "name": "Персидский язык",
- "slug": "persidskii-iazyk"
- },
- {
- "id": 3,
- "name": "исламской философии",
- "slug": "islamskoi-filosofii"
- },
- {
- "id": 2,
- "name": "Толкование корана",
- "slug": "tolkovanie-korana"
- },
- {
- "id": 1,
- "name": "Таджвид Корана",
- "slug": "tadzhvid-korana"
- }
-]
\ No newline at end of file
diff --git a/apps/course/doc.py b/apps/course/doc.py
deleted file mode 100644
index dc3acaa..0000000
--- a/apps/course/doc.py
+++ /dev/null
@@ -1,480 +0,0 @@
-def doc_course_participants():
- return """
-# 🐈 Scenario
-🛠️ لیست شرکتکنندگان دوره
-
----
-
-## 🚀 درخواست API
-
-### URL:
-```
-GET /api/courses//participants/
-```
-
-
-## 📊 پاسخها
-
-| کد وضعیت | توضیحات |
-|---------------|-----------------------------------------------------------|
-| `200` | موفقیتآمیز - لیستی از شرکتکنندگان دوره بازگردانده شد. |
-| `404` | دوره یافت نشد. |
-| `500` | مشکل موقتی در سرور. |
-
----
-
-
-### پاسخ موفق:
-```json
-[
- {
- "id": 1,
- "fullname": "Ali Rezaei",
- "avatar": "https://example.com/avatars/ali_rezaei.jpg",
- "email": "ali@example.com",
- "phone_number": "+98 912 345 6789",
- "info": "Experienced Python Developer",
- "skill": "Python, Django, REST API"
- }
-]
-```
-"""
-
-
-def doc_courses_lesson():
- return """
-# 🐈 Scenario
-🛠️ لیست درسهای دوره
-
-این API برای دریافت لیست درسهای یک دوره خاص استفاده میشود. این لیست شامل اطلاعاتی مانند عنوان، اولویت، مدت زمان، نوع محتوا، لینک ویدئو، و وضعیت تکمیل هر درس میباشد.
-
-(مقدار is_complated مشخص میکند آیا کاربر این درس را گذرانده است
-ممکن است درس دارای کوعیز باشد که باید در زیر آ» مانند طرح نمایش داده شود
-)
-
-دارای ابجکت کوعیز که لیستی از کوعیز های مربوط به یک درس را نمایش میدهد
-بایستی مانند طرح در زیر درس قرار داده شود
-و دارای مقدار permission
-است که مشخص میکند ایا این کاربر کوعیز را از قبل شرکت کرده است
----
-```
-
-## 📄 توضیحات مقادیر پاسخ
-
-| کلید | نوع داده | توضیحات |
-|-------------------------|------------|----------------------------------------------------------|
-| `id` | Integer | شناسه یکتای درس. |
-| `title` | String | عنوان درس. |
-| `priority` | Integer | اولویت نمایش درس در لیست دروس. |
-| `is_active` | Boolean | آیا درس فعال است یا خیر. |
-| `duration` | Integer | مدت زمان درس به دقیقه. |
-| `content_type` | String | نوع محتوا (لینک یا فایل). |
-| `content_file` | String | فایل مرتبط با درس (در صورت وجود). |
-| `video_link` | String | لینک ویدئو برای درس (در صورت آنلاین بودن). |
-| `is_complated` | Boolean | آیا کاربر این درس را تکمیل کرده است یا خیر. |
-| `quiz` | Object | اطلاعات مرتبط با کوییز درس (در صورت وجود). |
-
-
-### پاسخ موفق:
-```json
-[
- {
- "id": 1,
- "title": "Introduction to Variables",
- "duration": 30,
- "content_type": "link",
- "content_file": null,
- "video_link": "https://example.com/videos/variables_intro.mp4",
- "is_complated": true,
- "quizs": [
- {
- "id": 1,
- "title": "Тестовые курсы",
- "description": "урок 1-2",
- "permission": true,
- "each_question_timing": 30
- }
- ]
- },
- {
- "id": 1,
- "title": "Introduction to Variables",
- "duration": 30,
- "content_type": "link",
- "content_file": null,
- "video_link": "https://example.com/videos/variables_intro.mp4",
- "is_complated": true,
- "quizs": null
- }
-
-]
-```
-"""
-
-
-def doc_courses_lesson_v2():
- return """
-# Scenario
-Nested chapter-based lesson list for a course.
-
-This endpoint returns active chapters for a course, and inside each chapter returns active lessons in priority order.
-
-Important behavior:
-- Pagination is applied on chapters, not individual lessons.
-- `lessons` is a nested array under each chapter.
-- `permission` indicates whether the current user can access lesson content.
-- `is_complated` indicates whether the authenticated user has completed the lesson.
-- `quizs` is either `null` or a list of quizzes related to that lesson.
-
-Request:
-`GET /api/course/v2//lessons/`
-
-Success example:
-```json
-{
- "count": 2,
- "next": null,
- "previous": null,
- "results": [
- {
- "id": 14,
- "title": "فصل 1",
- "priority": 1,
- "is_active": true,
- "lessons": [
- {
- "id": 52,
- "title": "Greetings",
- "priority": 1,
- "is_active": true,
- "permission": true,
- "duration": 16,
- "content_type": "youtube_link",
- "content_file": null,
- "video_link": "https://youtu.be/t5Qo7W5a8qQ?si=WKPxmb4G9F0Va_6e",
- "is_complated": false,
- "quizs": null
- }
- ]
- }
- ]
-}
-```
-"""
-
-
-def doc_courses_my_courses():
- return """
-# 🐈 Scenario
-🛠️ دورههای من
-
-این API برای دریافت لیست دورههایی است که کاربر در آنها شرکت کرده است. این شامل دورههایی است که به اتمام رسیدهاند یا هنوز در حال تکمیل هستند.
-
-(برای دوره های تکمیل نشده
-?completed=false
-دوره های تکمیل شده
-?completed=true
-)
-(برای همه دوره های کاربر بدون هیچ مقداری بفرستید)
-(در صفحه هوم هم میتوانید دوره هایی که کاربر شرکت کرده است و هنوز تکمیل نشده است را نمایش دهید)
-
-
-
----
-
-## 🚀 درخواست API
-
-### پارامترهای فیلتر
-| کلید | نوع داده | توضیحات |
-|---------------|-----------|----------------------------------------------------------|
-| `completed` | Boolean | اگر `true` باشد، فقط دورههایی که تکمیل شدهاند را بازمیگرداند. |
-
-
-### درخواست کامل:
-```
-GET /api/my-courses/?completed=true
-```
-"""
-
-
-
-
-def doc_course_detail():
- return """
-# 🐈 Scenario
-🛠️ جزئیات دوره
-
----
-
-## 💡 نکات مهم:
-1. **اطلاعات دسترسی (`access`)**:
- - این مقدار نشان میدهد که آیا کاربر به این دوره دسترسی دارد یا خیر.
- در واقع آیا دانش آموز این دوره است و به درس های این دوره دسترسی دارد
-
-2. **ویدئو دوره**:
- - دورهها میتوانند شامل لینک ویدئو یا فایل ویدئویی باشند که توسط `video_type` مشخص میشود.
-3. **تعداد درسهای تکمیلشده**:
- - `lessons_complated_count` نشان میدهد که چند درس توسط کاربر تکمیل شده است.
- (برای به دست آوردن درصد درس های تکمیل شده دانش اموز تعداد کل درس های دوره را بر اساس درس های تکمیل شده دوره توسط دانش آموز محاسبه کنید)
-4. **اطلاعات استاد (`professor`)**:
- - اطلاعات استاد شامل نام، تصویر و مهارتها برای آشنایی بیشتر با مربی دوره فراهم شده است.
-5. برای دیدن درس ها و فایل ها و گلاسوری api
-های جدا در نظر گرفته شده است.
-
----
-
----
-## 📄 توضیحات مقادیر پاسخ
-
-| کلید | نوع داده | توضیحات |
-|-------------------------|------------|----------------------------------------------------------|
-| `id` | Integer | شناسه یکتای دوره. |
-| `title` | String | عنوان دوره. |
-| `slug` | String | شناسه یکتای دوره که برای URLها استفاده میشود. |
-| `category` | Object | اطلاعات دستهبندی دوره شامل نام و شناسه. |
-| `access` | Boolean | آیا کاربر به این دوره دسترسی دارد یا خیر. |
-| `participant_count` | Integer | تعداد شرکتکنندگان در این دوره. |
-| `professor` | Object | اطلاعات استاد شامل نام، تصویر، و مهارتها. |
-| `thumbnail` | String | لینک تصویر کوچک دوره. به صورت ابجکت است |
-| `video_type` | String | نوع ویدئو (لینک یا فایل). |
-| `video_file` | String | لینک فایل ویدئویی در صورت وجود. |
-| `video_link` | String | لینک ویدئو در صورت آنلاین بودن محتوا. |
-| `is_online` | Boolean | آیا دوره به صورت آنلاین برگزار میشود یا خیر. |
-| `level` | String | سطح دوره (beginner, mid, advanced). |
-| `duration` | Integer | مدت زمان دوره به ساعت. |
-| `lessons_count` | Integer | تعداد درسهای موجود در این دوره. |
-| `lessons_complated_count`| Integer | تعداد درسهایی که کاربر تکمیل کرده است. که ممکن است مقدار خالی هم باشد |
-| `short_description` | String | توضیح کوتاه در مورد دوره. |
-| `status` | String | وضعیت دوره (upcoming, registering, ongoing, finished). |
-| `is_free` | Boolean | آیا دوره رایگان است یا خیر. |
-| `price` | Decimal | قیمت اصلی دوره در صورت غیر رایگان بودن. |
-| `discount_percentage` | Decimal | درصد تخفیف برای دوره. |
-| `final_price` | Decimal | قیمت نهایی دوره پس از اعمال تخفیف. |
-| `timing` | String | زمانبندی برگزاری دوره (مثلاً ساعتها و روزهای برگزاری).'enum': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], |
-| `features` | String | ویژگیهای برجسته دوره. |
-
----
-## 📄 نمونه پاسخ موفقیتآمیز
-
-```json
-{
- "id": 1,
- "title": "Тажвид м",
- "slug": "tazhvid-m",
- "category": {
- "name": "Таджвид Корана",
- "slug": "tadzhvid-korana",
- "course_count": 25
- },
- "access": true,
- "participant_count": 120,
- "professor": {
- "id": 2,
- "fullname": "rezaa",
- "avatar": "http://localhost:8000/media/users/avatars/2024/11/test3.jpeg",
- "email": "root@admin.com",
- "phone_number": "+98 901 203 1023",
- "info": "good",
- "skill": null
- },
- "thumbnail": {},
- "video_type": "video_link",
- "video_file": null,
- "video_link": "https:222",
- "is_online": true,
- "level": "beginner",
- "duration": 55,
- "lessons_count": 2,
- "lessons_complated_count": 0,
- "short_description": "Таджвид Корана2",
- "status": "upcoming",
- "is_free": true,
- "price": "0.00",
- "discount_percentage": 0,
- "final_price": "0.00",
- "timing": [
- {
- "day": "Monday",
- "time": "02:00"
- },
- {
- "day": "Friday",
- "time": "10:00"
- }
- ],
- "features": [
- {
- "title": "good"
- },
- {
- "title": "regood"
- }
- ]
-}
-```
-
-"""
-
-
-
-
-
-
-def doc_course_list():
- return """
-# 🐈 Scenario
-🛠️ لیست دورهها
-
-این API برای لیست کردن دورهها به همراه اطلاعاتی مانند تعداد شرکتکنندگان، دستهبندی، تصویر کوچک، سطح، مدت زمان و دیگر جزئیات مرتبط استفاده میشود.
-
-
-## 📄 توضیحات مقادیر پاسخ
-
-| کلید | نوع داده | توضیحات |
-|---------------------|------------|----------------------------------------------------------|
-| `id` | Integer | شناسه یکتای دوره. |
-| `title` | String | عنوان دوره. |
-| `slug` | String | شناسه یکتای دوره که برای URLها استفاده میشود. |
-| `participant_count` | Integer | تعداد شرکتکنندگانی که در این دوره حضور دارند. |
-| `category` | Object | اطلاعات دستهبندی دوره شامل نام و شناسه و اسلاک |
-| `thumbnail` | String | لینک تصویر کوچک دوره. |
-| `is_online` | Boolean | آیا دوره به صورت آنلاین برگزار میشود یا خیر. |
-| `level` | String | سطح دوره (beginner, mid, advanced). |
-| `duration` | Integer | مدت زمان دوره به ساعت. |
-| `lessons_count` | Integer | تعداد درسهای موجود در این دوره. |
-| `short_description` | String | توضیح کوتاه در مورد دوره. |
-| `status` | String | وضعیت دوره (upcoming, registering, ongoing, finished). |
-| `is_free` | Boolean | آیا دوره رایگان است یا خیر. |
-| `price` | Decimal | قیمت اصلی دوره در صورت غیر رایگان بودن. |
-| `discount_percentage`| Decimal | درصد تخفیف برای دوره. |
-| `final_price` | Decimal | قیمت نهایی دوره پس از اعمال تخفیف. |
-
----
-
-
-### پارامترهای فیلتر و جستجو
-| کلید | نوع داده | توضیحات |
-|---------------|-----------|----------------------------------------------------------|
-| `title` | String | عنوان دوره برای جستجو در لیست دورهها. |
-| `category_slug` | String | اسلاگ دستهبندی دوره برای فیلتر کردن دورهها براساس دستهبندی. |
-| `status` | String | وضعیت دوره برای فیلتر کردن براساس وضعیت (upcoming, registering, ongoing, finished) |
-| `is_free` | Boolean | برای فیلتر کردن دورههای رایگان یا غیررایگان. |
-| `is_online` | Boolean | برای فیلتر کردن دورههای آنلاین یا آفلاین. |
-
----
-
-
-
-## 📊 پاسخها
-
-| کد وضعیت | توضیحات |
-|---------------|-----------------------------------------------------------|
-| `200` | موفقیتآمیز - لیستی از دورهها بازگردانده شد. |
-| `500` | مشکل موقتی در سرور. |
-
----
-
-## 📄 نمونه پاسخ موفقیتآمیز
-
-```json
-[
- {
- "id": 1,
- "title": "Introduction to Python",
- "slug": "introduction-to-python",
- "participant_count": 120,
- "category": {
- "name": "Programming",
- "slug": "programming"
- },
- "thumbnail": {},
- "is_online": true,
- "level": "beginner",
- "duration": 180,
- "lessons_count": 12,
- "short_description": "Learn the basics of Python programming.",
- "status": "upcoming",
- "is_free": false,
- "price": 100.0,
- "discount_percentage": 20.0,
- "final_price": 80.0
- },
-
-]
-```
-
-"""
-
-
-def doc_course_category():
- return """
-# 🐈 Scenario
-🛠️ لیست دستهبندیهای دورهها
-
-این API برای لیست کردن دستهبندیهای دورهها به همراه تعداد دورههای مرتبط با هر دستهبندی استفاده میشود.
-
----
-
-## 🚀 درخواست API
-
----
-
-## 📄 توضیحات مقادیر پاسخ
-
-| کلید | نوع داده | توضیحات |
-|---------------|-----------|----------------------------------------------------------|
-| `name` | String | نام دستهبندی دوره. |
-| `slug` | String | شناسه یکتای دستهبندی که برای URLها استفاده میشود. |
-| `course_count`| Integer | تعداد دورههایی که در این دستهبندی قرار دارند. |
-
----
-
-## 📊 پاسخها
-
-| کد وضعیت | توضیحات |
-|---------------|-----------------------------------------------------------|
-| `200` | موفقیتآمیز - لیستی از دستهبندیهای دورهها بازگردانده شد. |
-| `500` | مشکل موقتی در سرور. |
-
----
-
-## 📄 نمونه پاسخ موفقیتآمیز
-
-```json
-[
- {
- "name": "Programming",
- "slug": "programming",
- "course_count": 12
- },
- {
- "name": "Data Science",
- "slug": "data-science",
- "course_count": 8
- }
-]
-```
-
-## 📄 نمونه درخواست:
-
-### درخواست کامل:
-```
-GET /api/course-categories/
-```
-
-### پاسخ موفق:
-```json
-[
- {
- "name": "Web Development",
- "slug": "web-development",
- "course_count": 15
- },
- {
- "name": "Artificial Intelligence",
- "slug": "ai",
- "course_count": 10
- }
-]
-```
-"""
diff --git a/apps/course/management/__init__.py b/apps/course/management/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/course/management/commands/__init__.py b/apps/course/management/commands/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/course/management/commands/assign_professors.py b/apps/course/management/commands/assign_professors.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/course/management/commands/auto_close_professorless_live_sessions.py b/apps/course/management/commands/auto_close_professorless_live_sessions.py
deleted file mode 100644
index 4e054d8..0000000
--- a/apps/course/management/commands/auto_close_professorless_live_sessions.py
+++ /dev/null
@@ -1,127 +0,0 @@
-import logging
-
-from django.core.management.base import BaseCommand
-from django.db import transaction
-from django.utils import timezone
-
-from apps.course.models import CourseLiveSession, LiveSessionUser
-from apps.course.services import PlugNMeetClient, PlugNMeetError
-
-logger = logging.getLogger(__name__)
-
-
-class Command(BaseCommand):
- help = (
- "Auto-close live sessions when no moderator has been present for the configured timeout "
- "while other participants are still online."
- )
-
- def handle(self, *args, **options):
- now = timezone.now()
- sessions = CourseLiveSession.objects.filter(
- ended_at__isnull=True,
- auto_close_after_moderator_exit_at__isnull=False,
- auto_close_after_moderator_exit_at__lte=now,
- ).select_related('course')
-
- processed = 0
- closed = 0
- skipped = 0
-
- for session in sessions:
- processed += 1
- outcome = self._process_session(session, now)
- if outcome == 'closed':
- closed += 1
- else:
- skipped += 1
-
- self.stdout.write(
- self.style.SUCCESS(
- f"Processed {processed} session(s); closed {closed}; skipped {skipped}."
- )
- )
-
- def _process_session(self, session: CourseLiveSession, now):
- has_moderator_online = LiveSessionUser.objects.filter(
- session=session,
- role='moderator',
- is_online=True,
- ).exists()
- if has_moderator_online:
- self._clear_pending_auto_close(session)
- logger.info(
- "[Auto Close Professorless] Moderator returned - clearing pending close for session_id=%s",
- session.id,
- )
- return 'skipped'
-
- has_any_online_users = LiveSessionUser.objects.filter(
- session=session,
- is_online=True,
- ).exists()
- if not has_any_online_users:
- self._clear_pending_auto_close(session)
- logger.info(
- "[Auto Close Professorless] No online users remain - deferring to empty timeout for session_id=%s",
- session.id,
- )
- return 'skipped'
-
- try:
- client = PlugNMeetClient()
- client.end_room(session.room_id)
- logger.info(
- "[Auto Close Professorless] Ended room via PlugNMeet - session_id=%s room_id=%s",
- session.id,
- session.room_id,
- )
- except PlugNMeetError as exc:
- logger.warning(
- "[Auto Close Professorless] PlugNMeet end_room failed - session_id=%s room_id=%s error=%s",
- session.id,
- session.room_id,
- str(exc),
- )
- except Exception as exc:
- logger.warning(
- "[Auto Close Professorless] Unexpected end_room error - session_id=%s room_id=%s error=%s",
- session.id,
- session.room_id,
- str(exc),
- )
-
- self._close_session_locally(session, now)
- return 'closed'
-
- @staticmethod
- def _close_session_locally(session: CourseLiveSession, now):
- with transaction.atomic():
- updated = CourseLiveSession.objects.filter(
- pk=session.pk,
- ended_at__isnull=True,
- ).update(
- ended_at=now,
- last_moderator_left_at=None,
- auto_close_after_moderator_exit_at=None,
- updated_at=now,
- )
- if not updated:
- return
-
- LiveSessionUser.objects.filter(
- session=session,
- is_online=True,
- ).update(
- is_online=False,
- exited_at=now,
- updated_at=now,
- )
-
- @staticmethod
- def _clear_pending_auto_close(session: CourseLiveSession):
- CourseLiveSession.objects.filter(pk=session.pk).update(
- last_moderator_left_at=None,
- auto_close_after_moderator_exit_at=None,
- updated_at=timezone.now(),
- )
diff --git a/apps/course/management/commands/clear_course_data.py b/apps/course/management/commands/clear_course_data.py
deleted file mode 100644
index 118cb9c..0000000
--- a/apps/course/management/commands/clear_course_data.py
+++ /dev/null
@@ -1,134 +0,0 @@
-from django.core.management.base import BaseCommand
-from django.db import transaction, connection
-from django.db.models import ProtectedError
-from django.utils.translation import gettext_lazy as _
-
-from apps.course.models import (
- Course, CourseCategory,
- Lesson, CourseLesson, LessonCompletion,
- Attachment, CourseAttachment,
- Glossary, CourseGlossary,
- Participant
-)
-
-
-class Command(BaseCommand):
- help = _('Clear all course-related data from the database')
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--force',
- action='store_true',
- dest='force',
- help=_('Force deletion without confirmation'),
- )
- parser.add_argument(
- '--model',
- type=str,
- dest='model',
- help=_('Specify a single model to clear (e.g., "Course", "Lesson", etc.)'),
- )
- parser.add_argument(
- '--legacy-only',
- action='store_true',
- dest='legacy_only',
- help=_('Clear only legacy models (before migration to new structure)'),
- )
-
- def table_exists(self, table_name):
- """Check if a table exists in the database."""
- with connection.cursor() as cursor:
- cursor.execute(
- """
- SELECT EXISTS (
- SELECT FROM information_schema.tables
- WHERE table_schema = 'public'
- AND table_name = %s
- );
- """,
- [table_name]
- )
- return cursor.fetchone()[0]
-
- def handle(self, *args, **options):
- force = options['force']
- specific_model = options.get('model')
- legacy_only = options.get('legacy_only')
-
- if not force and not specific_model:
- confirm = input(_('This will delete ALL course-related data. Are you sure? (yes/no): '))
- if confirm.lower() != 'yes':
- self.stdout.write(self.style.WARNING(_('Operation cancelled.')))
- return
-
- # Define all models
- all_models = {
- 'Course': (Course, 'course_course'),
- 'CourseCategory': (CourseCategory, 'course_coursecategory'),
- 'Lesson': (Lesson, 'course_lesson'),
- 'CourseLesson': (CourseLesson, 'course_courselesson'),
- 'LessonCompletion': (LessonCompletion, 'course_lessoncompletion'),
- 'Attachment': (Attachment, 'course_attachment'),
- 'CourseAttachment': (CourseAttachment, 'course_courseattachment'),
- 'Glossary': (Glossary, 'course_glossary'),
- 'CourseGlossary': (CourseGlossary, 'course_courseglossary'),
- 'Participant': (Participant, 'course_participant'),
- }
-
- # Legacy models (before migration)
- legacy_models = {
- 'Course': (Course, 'course_course'),
- 'CourseCategory': (CourseCategory, 'course_coursecategory'),
- 'Lesson': (Lesson, 'course_lesson'),
- 'LessonCompletion': (LessonCompletion, 'course_lessoncompletion'),
- 'Attachment': (Attachment, 'course_attachment'),
- 'Glossary': (Glossary, 'course_glossary'),
- 'Participant': (Participant, 'course_participant'),
- }
-
- models_to_use = legacy_models if legacy_only else all_models
-
- if specific_model:
- # Clear only the specified model
- if specific_model not in models_to_use:
- self.stdout.write(self.style.ERROR(_(f'Unknown model: {specific_model}')))
- self.stdout.write(self.style.WARNING(_(f'Available models: {", ".join(models_to_use.keys())}')))
- return
-
- model_info = models_to_use[specific_model]
- models_to_clear = [(specific_model, model_info[0], model_info[1])]
- else:
- # Clear all models in the correct order to avoid foreign key constraints
- models_to_clear = []
-
- # Order matters for foreign key constraints
- model_order = [
- 'LessonCompletion', 'CourseLesson', 'Lesson',
- 'CourseAttachment', 'Attachment',
- 'CourseGlossary', 'Glossary',
- 'Participant', 'Course', 'CourseCategory'
- ]
-
- for model_name in model_order:
- if model_name in models_to_use:
- model_info = models_to_use[model_name]
- models_to_clear.append((model_name, model_info[0], model_info[1]))
-
- # Process each model
- for model_name, model_class, table_name in models_to_clear:
- # Check if the table exists
- if not self.table_exists(table_name):
- self.stdout.write(self.style.WARNING(_(f'Table {table_name} does not exist, skipping {model_name}')))
- continue
-
- try:
- count = model_class.objects.count()
- model_class.objects.all().delete()
- self.stdout.write(self.style.SUCCESS(_(f'Deleted {count} {model_name} records')))
- except ProtectedError as e:
- self.stdout.write(self.style.ERROR(_(f'Could not delete {model_name} records due to protected foreign keys')))
- self.stdout.write(self.style.ERROR(str(e)))
- except Exception as e:
- self.stdout.write(self.style.ERROR(_(f'Error deleting {model_name} records: {str(e)}')))
-
- self.stdout.write(self.style.SUCCESS(_('Course data clearing completed')))
\ No newline at end of file
diff --git a/apps/course/management/commands/ensure_course_rooms.py b/apps/course/management/commands/ensure_course_rooms.py
deleted file mode 100644
index ea1e50b..0000000
--- a/apps/course/management/commands/ensure_course_rooms.py
+++ /dev/null
@@ -1,63 +0,0 @@
-from django.core.management.base import BaseCommand
-from django.db import transaction
-from django.db.models import Exists, OuterRef
-from django.utils.translation import gettext_lazy as _
-
-from apps.chat.models import RoomMessage
-from apps.course.models import Course
-
-
-class Command(BaseCommand):
- help = _('Ensure every course has a group chat room')
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--dry-run',
- action='store_true',
- dest='dry_run',
- help=_('Show courses missing group rooms without creating them'),
- )
-
- def handle(self, *args, **options):
- dry_run = options['dry_run']
-
- group_room_exists = RoomMessage.objects.filter(
- course=OuterRef('pk'),
- room_type=RoomMessage.RoomTypeChoices.GROUP,
- )
-
- courses_without_group_room = Course.objects.prefetch_related('professors').annotate(
- has_group_room=Exists(group_room_exists)
- ).filter(
- has_group_room=False
- )
-
- missing_count = courses_without_group_room.count()
-
- if missing_count == 0:
- self.stdout.write(self.style.SUCCESS(_('All courses already have a group room.')))
- return
-
- self.stdout.write(self.style.WARNING(_(f'Found {missing_count} course(s) without a group room.')))
-
- for course in courses_without_group_room:
- self.stdout.write(f'- Course #{course.id}: {course.title}')
-
- if dry_run:
- self.stdout.write(self.style.WARNING(_('Dry run completed. No rooms were created.')))
- return
-
- created_count = 0
- with transaction.atomic():
- for course in courses_without_group_room:
- RoomMessage.objects.create(
- name=f"{course.title} - Group",
- description=f"Group chat for course: {course.title}",
- initiator=course.professors.first(),
- course=course,
- room_type=RoomMessage.RoomTypeChoices.GROUP,
- is_locked=course.is_group_chat_locked,
- )
- created_count += 1
-
- self.stdout.write(self.style.SUCCESS(_(f'Created {created_count} missing group room(s).')))
diff --git a/apps/course/management/commands/fill_english_translations.py b/apps/course/management/commands/fill_english_translations.py
deleted file mode 100644
index d946680..0000000
--- a/apps/course/management/commands/fill_english_translations.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from django.core.management.base import BaseCommand
-from django.apps import apps
-from django.db import models
-
-class Command(BaseCommand):
- help = "Populate missing 'en' translations with other available language data for JSONFields"
-
- def handle(self, *args, **options):
- target_app_labels = ['course', 'quiz', 'blog', 'hadis', 'library']
- updated_records = 0
-
- for app_label in target_app_labels:
- try:
- app_config = apps.get_app_config(app_label)
- except LookupError:
- continue
-
- for model in app_config.get_models():
- json_fields = [f for f in model._meta.get_fields() if isinstance(f, models.JSONField)]
- if not json_fields:
- continue
-
- for instance in model.objects.all():
- changed = False
- for field in json_fields:
- val = getattr(instance, field.name)
-
- if isinstance(val, list):
- has_en = False
- fallback_text = None
-
- valid = True
- for item in val:
- if not isinstance(item, dict) or 'language_code' not in item or 'title' not in item:
- valid = False
- break
-
- title_val = item.get('title')
- if isinstance(title_val, str):
- title_str = title_val.strip()
- elif title_val is not None:
- title_str = str(title_val).strip()
- else:
- title_str = ''
-
- if item.get('language_code') == 'en' and title_str:
- has_en = True
- elif title_str:
- if not fallback_text:
- fallback_text = title_val
-
- if valid and not has_en and fallback_text:
- val.append({
- "language_code": "en",
- "title": fallback_text
- })
- setattr(instance, field.name, val)
- changed = True
-
- if changed:
- instance.save()
- updated_records += 1
- self.stdout.write(f"Updated {model.__name__} ID {instance.pk}")
-
- self.stdout.write(self.style.SUCCESS(f"Finished updating {updated_records} records."))
diff --git a/apps/course/management/commands/migrate_lessons_to_chapters.py b/apps/course/management/commands/migrate_lessons_to_chapters.py
deleted file mode 100644
index df7d340..0000000
--- a/apps/course/management/commands/migrate_lessons_to_chapters.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from django.core.management.base import BaseCommand
-from django.db import transaction
-from apps.course.models import Course, CourseChapter
-
-class Command(BaseCommand):
- help = 'Migrates existing CourseLessons to be nested inside default CourseChapters (V2 Architecture).'
-
- def handle(self, *args, **options):
- self.stdout.write(self.style.WARNING("Starting data migration for Course Chapters..."))
-
- courses = Course.objects.all()
- courses_updated = 0
- lessons_migrated = 0
-
- # We wrap the whole loop in an atomic transaction.
- # If anything crashes halfway through, the database rolls back to its original state!
- with transaction.atomic():
- for course in courses:
- # Check if course has lessons but no chapters yet
- if course.lessons.exists() and not course.chapters.exists():
- self.stdout.write(f"Migrating course: {course.title}")
-
- # Create a default 'General' chapter
- default_chapter = CourseChapter.objects.create(
- course=course,
- title="General",
- priority=1
- )
-
- # Attach all existing lessons to this new chapter
- for index, course_lesson in enumerate(course.lessons.all().order_by('priority')):
- course_lesson.chapter = default_chapter
- course_lesson.priority = index + 1
- course_lesson.save()
- lessons_migrated += 1
-
- courses_updated += 1
-
- self.stdout.write(
- self.style.SUCCESS(
- f"🎉 Successfully migrated {lessons_migrated} lessons across {courses_updated} courses!"
- )
- )
\ No newline at end of file
diff --git a/apps/course/management/commands/redistribute_lessons_to_chapters.py b/apps/course/management/commands/redistribute_lessons_to_chapters.py
deleted file mode 100644
index b41176a..0000000
--- a/apps/course/management/commands/redistribute_lessons_to_chapters.py
+++ /dev/null
@@ -1,117 +0,0 @@
-import random
-import sys
-from django.core.management.base import BaseCommand
-from django.db import transaction
-from apps.course.models import Course, CourseChapter, CourseLesson
-
-class Command(BaseCommand):
- help = 'Redistributes course lessons into multiple chapters randomly (maintaining order) for courses with >= 2 lessons.'
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--dry-run',
- action='store_true',
- help='Simulates the redistribution without modifying the database.',
- )
- parser.add_argument(
- '--max-chapters',
- type=int,
- default=5,
- help='Maximum number of chapters to create for a course (default: 5).',
- )
-
- def handle(self, *args, **options):
- # Reconfigure stdout to UTF-8 to prevent UnicodeEncodeError on Windows terminals
- try:
- sys.stdout.reconfigure(encoding='utf-8')
- except AttributeError:
- pass
-
- dry_run = options['dry_run']
- max_chapters = options['max_chapters']
-
- if dry_run:
- self.stdout.write(self.style.WARNING("⚠️ DRY RUN MODE: No database changes will be saved."))
-
- self.stdout.write(self.style.WARNING("Starting lesson redistribution..."))
-
- courses = Course.objects.all()
- courses_updated = 0
- total_chapters_created = 0
- total_lessons_redistributed = 0
-
- # Wrap everything in an atomic transaction to ensure safety
- with transaction.atomic():
- for course in courses:
- lessons = list(course.lessons.all().order_by('priority'))
- n = len(lessons)
-
- if n < 2:
- self.stdout.write(self.style.NOTICE(
- f"Skipping course: '{course.title}' (ID: {course.id}) - Only has {n} lesson(s)."
- ))
- continue
-
- # Number of chapters C must be between 2 and min(N, max_chapters)
- c = random.randint(2, min(n, max_chapters))
-
- self.stdout.write(self.style.MIGRATE_HEADING(
- f"\nReorganizing Course: '{course.title}' (ID: {course.id})"
- f"\n Total lessons: {n} | Splitting into {c} chapters..."
- ))
-
- # Partition the ordered lessons list into C slices
- # We randomly pick C-1 unique split boundaries between 1 and n-1
- split_points = sorted(random.sample(range(1, n), c - 1))
-
- partitioned_lessons = []
- last_idx = 0
- for sp in split_points:
- partitioned_lessons.append(lessons[last_idx:sp])
- last_idx = sp
- partitioned_lessons.append(lessons[last_idx:])
-
- if not dry_run:
- # 1. Unlink lessons from chapters first to prevent CASCADE deletion.
- # Using .update() bypasses the save() method and avoids AttributeErrors
- # caused by _adjust_priorities when chapter is None.
- course.lessons.all().update(chapter=None)
-
- # 2. Delete all existing chapters for this course
- course.chapters.all().delete()
-
- # 3. Create the new chapters and assign the sliced lessons
- for i, chapter_lessons in enumerate(partitioned_lessons):
- chapter_title = f"فصل {i+1}"
- priority = i + 1
-
- self.stdout.write(f" 📂 Chapter '{chapter_title}' (priority {priority}) contains:")
- for idx, lesson in enumerate(chapter_lessons):
- self.stdout.write(f" - [{idx + 1}] {lesson.title} (Original ID: {lesson.id})")
-
- if not dry_run:
- # Create the new chapter
- chapter = CourseChapter.objects.create(
- course=course,
- title=chapter_title,
- priority=priority
- )
-
- # Assign and save each lesson within this chapter
- for idx, lesson in enumerate(chapter_lessons):
- lesson.chapter = chapter
- lesson.priority = idx + 1
- lesson.save()
-
- total_chapters_created += 1
- total_lessons_redistributed += len(chapter_lessons)
-
- courses_updated += 1
-
- if dry_run:
- self.stdout.write(self.style.WARNING("\n⚠️ Dry run complete. No database changes were made."))
- else:
- self.stdout.write(self.style.SUCCESS(
- f"\n🎉 Successfully redistributed {total_lessons_redistributed} lessons "
- f"across {total_chapters_created} chapters in {courses_updated} courses!"
- ))
diff --git a/apps/course/management/commands/set_default_category_icons.py b/apps/course/management/commands/set_default_category_icons.py
deleted file mode 100644
index a0d52b8..0000000
--- a/apps/course/management/commands/set_default_category_icons.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from django.core.management.base import BaseCommand
-from apps.course.models import CourseCategory
-
-class Command(BaseCommand):
- help = 'Sets the default icon (/static/images/Frame.svg) for all CourseCategory instances with blank or null icons.'
-
- def handle(self, *args, **options):
- categories = CourseCategory.objects.filter(icon__isnull=True) | CourseCategory.objects.filter(icon='')
-
- count = 0
- for cat in categories:
- cat.icon = "/static/images/Frame.svg"
- cat.save()
- count += 1
-
- self.stdout.write(self.style.SUCCESS(f'Successfully updated {count} CourseCategory icons to "/static/images/Frame.svg".'))
diff --git a/apps/course/management/commands/smoke_test_online_class_webhooks.py b/apps/course/management/commands/smoke_test_online_class_webhooks.py
deleted file mode 100644
index aa1ff50..0000000
--- a/apps/course/management/commands/smoke_test_online_class_webhooks.py
+++ /dev/null
@@ -1,517 +0,0 @@
-import hashlib
-import hmac
-import json
-import time
-from typing import Callable, Dict, List, Optional, Tuple
-
-import requests
-from django.conf import settings
-from django.core.management.base import BaseCommand, CommandError
-from django.utils import timezone
-
-from apps.account.models import User
-from apps.course.models import Course, CourseLiveSession, LiveSessionUser, Participant
-
-
-class Command(BaseCommand):
- help = (
- "Run a real HTTP smoke test against the public PlugNMeet webhook endpoint "
- "and verify the expected database side effects for online-class webhooks."
- )
-
- def add_arguments(self, parser):
- parser.add_argument(
- "--base-url",
- dest="base_url",
- default="",
- help="Public backend base URL. Defaults to SITE_DOMAIN.",
- )
- parser.add_argument(
- "--session-id",
- dest="session_id",
- type=int,
- help="Use an existing active live session by database id.",
- )
- parser.add_argument(
- "--course-id",
- dest="course_id",
- type=int,
- help="Course id to use when creating a disposable smoke-test live session.",
- )
- parser.add_argument(
- "--create-session",
- action="store_true",
- dest="create_session",
- help="Create a disposable active live session for the smoke test.",
- )
- parser.add_argument(
- "--participant-user-id",
- dest="participant_user_id",
- type=int,
- help="Student user id to use for participant_joined/left checks.",
- )
- parser.add_argument(
- "--moderator-user-id",
- dest="moderator_user_id",
- type=int,
- help="Professor/admin user id to use for moderator join/leave checks.",
- )
- parser.add_argument(
- "--final-event",
- dest="final_event",
- choices=["room_finished", "session_ended"],
- default="room_finished",
- help="Final closing webhook to send at the end of the flow.",
- )
- parser.add_argument(
- "--skip-final-close",
- action="store_true",
- dest="skip_final_close",
- help="Skip the final closing webhook. Useful if you only want join/leave checks.",
- )
- parser.add_argument(
- "--timeout",
- dest="timeout",
- type=float,
- default=10.0,
- help="HTTP timeout in seconds for each webhook request.",
- )
- parser.add_argument(
- "--include-invalid-signature",
- action="store_true",
- dest="include_invalid_signature",
- help="Also verify that the endpoint rejects a bad signature with HTTP 403.",
- )
-
- def handle(self, *args, **options):
- secret = getattr(settings, "PLUGNMEET_API_SECRET", "")
- if not secret:
- raise CommandError("PLUGNMEET_API_SECRET is not configured.")
-
- webhook_url = self._build_webhook_url(options["base_url"])
- session, created_session = self._resolve_session(
- session_id=options.get("session_id"),
- course_id=options.get("course_id"),
- create_session=options.get("create_session", False),
- )
- moderator, participant = self._resolve_users(
- session=session,
- moderator_user_id=options.get("moderator_user_id"),
- participant_user_id=options.get("participant_user_id"),
- )
-
- self.stdout.write(
- self.style.WARNING(
- f"Webhook smoke test target: {webhook_url}\n"
- f"Session #{session.id} room_id={session.room_id}\n"
- f"Moderator user_id={moderator.id} Participant user_id={participant.id}"
- )
- )
-
- results: List[Tuple[str, bool, str]] = []
-
- if options.get("include_invalid_signature"):
- results.append(
- self._run_invalid_signature_check(
- webhook_url=webhook_url,
- session=session,
- timeout=options["timeout"],
- )
- )
-
- try:
- results.append(
- self._run_step(
- name="participant_joined(participant)",
- webhook_url=webhook_url,
- payload=self._participant_payload(
- event="participant_joined",
- room_id=session.room_id,
- user=participant,
- state="ACTIVE",
- ),
- secret=secret,
- timeout=options["timeout"],
- expected_status=200,
- verify=lambda: self._assert_live_session_user_state(
- session_id=session.id,
- user_id=participant.id,
- expected_online=True,
- expected_role="participant",
- ),
- )
- )
- results.append(
- self._run_step(
- name="participant_joined(moderator)",
- webhook_url=webhook_url,
- payload=self._participant_payload(
- event="participant_joined",
- room_id=session.room_id,
- user=moderator,
- state="ACTIVE",
- ),
- secret=secret,
- timeout=options["timeout"],
- expected_status=200,
- verify=lambda: self._assert_moderator_join_state(
- session_id=session.id,
- user_id=moderator.id,
- ),
- )
- )
- results.append(
- self._run_step(
- name="participant_left(moderator)",
- webhook_url=webhook_url,
- payload=self._participant_payload(
- event="participant_left",
- room_id=session.room_id,
- user=moderator,
- state="DISCONNECTED",
- ),
- secret=secret,
- timeout=options["timeout"],
- expected_status=200,
- verify=lambda: self._assert_moderator_leave_state(session_id=session.id),
- )
- )
- results.append(
- self._run_step(
- name="participant_joined(moderator-again)",
- webhook_url=webhook_url,
- payload=self._participant_payload(
- event="participant_joined",
- room_id=session.room_id,
- user=moderator,
- state="ACTIVE",
- ),
- secret=secret,
- timeout=options["timeout"],
- expected_status=200,
- verify=lambda: self._assert_moderator_join_state(
- session_id=session.id,
- user_id=moderator.id,
- ),
- )
- )
- results.append(
- self._run_step(
- name="participant_left(participant)",
- webhook_url=webhook_url,
- payload=self._participant_payload(
- event="participant_left",
- room_id=session.room_id,
- user=participant,
- state="DISCONNECTED",
- ),
- secret=secret,
- timeout=options["timeout"],
- expected_status=200,
- verify=lambda: self._assert_live_session_user_state(
- session_id=session.id,
- user_id=participant.id,
- expected_online=False,
- expected_role="participant",
- ),
- )
- )
-
- if not options.get("skip_final_close"):
- results.append(
- self._run_step(
- name=options["final_event"],
- webhook_url=webhook_url,
- payload=self._room_payload(
- event=options["final_event"],
- room_id=session.room_id,
- ),
- secret=secret,
- timeout=options["timeout"],
- expected_status=200,
- verify=lambda: self._assert_session_closed(session_id=session.id),
- )
- )
- finally:
- if created_session and options.get("skip_final_close"):
- self._close_session_locally(session)
-
- failed = [name for name, ok, _ in results if not ok]
- for name, ok, details in results:
- style = self.style.SUCCESS if ok else self.style.ERROR
- prefix = "PASS" if ok else "FAIL"
- self.stdout.write(style(f"[{prefix}] {name}: {details}"))
-
- if failed:
- raise CommandError(
- "Webhook smoke test failed for: " + ", ".join(failed)
- )
-
- self.stdout.write(self.style.SUCCESS("All webhook smoke-test checks passed."))
-
- def _build_webhook_url(self, base_url: str) -> str:
- normalized = (base_url or getattr(settings, "SITE_DOMAIN", "")).strip().rstrip("/")
- if not normalized:
- raise CommandError(
- "Unable to determine public base URL. Provide --base-url or configure SITE_DOMAIN."
- )
- if not normalized.startswith(("http://", "https://")):
- normalized = f"https://{normalized}"
- return f"{normalized}/api/courses/plugnmeet/webhook/"
-
- def _resolve_session(
- self,
- *,
- session_id: Optional[int],
- course_id: Optional[int],
- create_session: bool,
- ) -> Tuple[CourseLiveSession, bool]:
- if session_id and create_session:
- raise CommandError("Use either --session-id or --create-session with --course-id, not both.")
-
- if session_id:
- try:
- session = CourseLiveSession.objects.select_related("course").get(
- id=session_id,
- ended_at__isnull=True,
- )
- except CourseLiveSession.DoesNotExist as exc:
- raise CommandError(f"Active live session #{session_id} was not found.") from exc
- return session, False
-
- if create_session:
- if not course_id:
- raise CommandError("--course-id is required when using --create-session.")
- try:
- course = Course.objects.prefetch_related("professors", "participants").get(id=course_id)
- except Course.DoesNotExist as exc:
- raise CommandError(f"Course #{course_id} was not found.") from exc
-
- room_id = f"smoke-room-{course.id}-{int(time.time())}"
- next_session_number = course.live_sessions.count() + 1
- session = CourseLiveSession.objects.create(
- course=course,
- room_id=room_id,
- subject=f"Webhook Smoke Test {next_session_number}",
- started_at=timezone.now(),
- )
- return session, True
-
- raise CommandError(
- "Provide --session-id for an existing active session, or use --create-session with --course-id."
- )
-
- def _resolve_users(
- self,
- *,
- session: CourseLiveSession,
- moderator_user_id: Optional[int],
- participant_user_id: Optional[int],
- ) -> Tuple[User, User]:
- if moderator_user_id:
- moderator = self._get_user_or_error(moderator_user_id, "moderator")
- else:
- moderator = session.course.professors.first()
- if not moderator:
- raise CommandError(
- f"Course #{session.course_id} has no professor. Pass --moderator-user-id explicitly."
- )
-
- if not moderator.can_manage_course(session.course):
- raise CommandError(
- f"User #{moderator.id} cannot manage course #{session.course_id}; cannot use as moderator."
- )
-
- if participant_user_id:
- participant = self._get_user_or_error(participant_user_id, "participant")
- else:
- participant_relation = (
- Participant.objects.select_related("student")
- .filter(course=session.course, is_active=True)
- .first()
- )
- if not participant_relation:
- raise CommandError(
- f"Course #{session.course_id} has no active participant. Pass --participant-user-id explicitly."
- )
- participant = participant_relation.student
-
- if participant.can_manage_course(session.course):
- raise CommandError(
- f"User #{participant.id} can manage course #{session.course_id}; choose a non-moderator participant."
- )
-
- return moderator, participant
-
- def _get_user_or_error(self, user_id: int, role_label: str) -> User:
- try:
- return User.objects.get(id=user_id)
- except User.DoesNotExist as exc:
- raise CommandError(f"{role_label.title()} user #{user_id} was not found.") from exc
-
- def _run_invalid_signature_check(
- self,
- *,
- webhook_url: str,
- session: CourseLiveSession,
- timeout: float,
- ) -> Tuple[str, bool, str]:
- payload = self._room_payload(event="room_created", room_id=session.room_id)
- body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- response = requests.post(
- webhook_url,
- data=body,
- headers={
- "Content-Type": "application/webhook+json",
- "Hash-Token": "invalid-signature",
- },
- timeout=timeout,
- )
- ok = response.status_code == 403
- detail = f"expected 403, got {response.status_code}"
- return ("invalid_signature", ok, detail)
-
- def _run_step(
- self,
- *,
- name: str,
- webhook_url: str,
- payload: Dict,
- secret: str,
- timeout: float,
- expected_status: int,
- verify: Callable[[], str],
- ) -> Tuple[str, bool, str]:
- body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- signature = hmac.new(
- secret.encode("utf-8"),
- body,
- hashlib.sha256,
- ).hexdigest()
- response = requests.post(
- webhook_url,
- data=body,
- headers={
- "Content-Type": "application/webhook+json",
- "Hash-Token": signature,
- },
- timeout=timeout,
- )
- if response.status_code != expected_status:
- return (
- name,
- False,
- f"expected HTTP {expected_status}, got {response.status_code}: {response.text[:300]}",
- )
-
- try:
- verification_message = verify()
- except AssertionError as exc:
- return (name, False, str(exc))
-
- return (name, True, verification_message)
-
- def _participant_payload(self, *, event: str, room_id: str, user: User, state: str) -> Dict:
- return {
- "event": event,
- "room": {
- "name": room_id,
- },
- "participant": {
- "identity": str(user.id),
- "name": getattr(user, "fullname", None) or getattr(user, "email", str(user.id)),
- "state": state,
- },
- }
-
- def _room_payload(self, *, event: str, room_id: str) -> Dict:
- return {
- "event": event,
- "room": {
- "name": room_id,
- },
- }
-
- def _assert_live_session_user_state(
- self,
- *,
- session_id: int,
- user_id: int,
- expected_online: bool,
- expected_role: str,
- ) -> str:
- try:
- session_user = LiveSessionUser.objects.get(session_id=session_id, user_id=user_id)
- except LiveSessionUser.DoesNotExist as exc:
- raise AssertionError(
- f"LiveSessionUser missing for session={session_id} user={user_id}."
- ) from exc
-
- if session_user.role != expected_role:
- raise AssertionError(
- f"Expected role={expected_role} for user={user_id}, got role={session_user.role}."
- )
- if session_user.is_online != expected_online:
- raise AssertionError(
- f"Expected is_online={expected_online} for user={user_id}, got {session_user.is_online}."
- )
- if expected_online and session_user.exited_at is not None:
- raise AssertionError(f"Expected exited_at to be null for online user={user_id}.")
- if not expected_online and session_user.exited_at is None:
- raise AssertionError(f"Expected exited_at to be set for offline user={user_id}.")
- return (
- f"user={user_id} role={session_user.role} "
- f"is_online={session_user.is_online}"
- )
-
- def _assert_moderator_join_state(self, *, session_id: int, user_id: int) -> str:
- message = self._assert_live_session_user_state(
- session_id=session_id,
- user_id=user_id,
- expected_online=True,
- expected_role="moderator",
- )
- session = CourseLiveSession.objects.get(id=session_id)
- if session.last_moderator_left_at is not None or session.auto_close_after_moderator_exit_at is not None:
- raise AssertionError(
- "Expected moderator rejoin to clear pending auto-close fields, but they are still set."
- )
- return f"{message}; auto-close fields cleared"
-
- def _assert_moderator_leave_state(self, *, session_id: int) -> str:
- session = CourseLiveSession.objects.get(id=session_id)
- moderator_online = LiveSessionUser.objects.filter(
- session_id=session_id,
- role="moderator",
- is_online=True,
- ).exists()
- if moderator_online:
- raise AssertionError("Expected no moderators online after moderator leave webhook.")
- if session.last_moderator_left_at is None:
- raise AssertionError("Expected last_moderator_left_at to be set after moderator leave.")
- if session.auto_close_after_moderator_exit_at is None:
- raise AssertionError("Expected auto_close_after_moderator_exit_at to be set after moderator leave.")
- return "moderator offline; auto-close fields scheduled"
-
- def _assert_session_closed(self, *, session_id: int) -> str:
- session = CourseLiveSession.objects.get(id=session_id)
- if session.ended_at is None:
- raise AssertionError("Expected session.ended_at to be set after closing webhook.")
- online_users = LiveSessionUser.objects.filter(session_id=session_id, is_online=True).count()
- if online_users:
- raise AssertionError(
- f"Expected all session users to be offline after close; found {online_users} online."
- )
- return f"session ended_at={session.ended_at.isoformat()} and all users offline"
-
- def _close_session_locally(self, session: CourseLiveSession) -> None:
- now = timezone.now()
- CourseLiveSession.objects.filter(id=session.id, ended_at__isnull=True).update(
- ended_at=now,
- updated_at=now,
- )
- LiveSessionUser.objects.filter(session=session, is_online=True).update(
- is_online=False,
- exited_at=now,
- updated_at=now,
- )
diff --git a/apps/course/management/commands/sync_course_lessons_count.py b/apps/course/management/commands/sync_course_lessons_count.py
deleted file mode 100644
index a3f78ca..0000000
--- a/apps/course/management/commands/sync_course_lessons_count.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from django.core.management.base import BaseCommand
-
-from apps.course.models.course import Course
-
-
-class Command(BaseCommand):
- help = "Recalculate lessons_count for all courses based on active assigned course lessons."
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--dry-run',
- action='store_true',
- help='Preview updates without saving changes.',
- )
-
- def handle(self, *args, **options):
- dry_run = options['dry_run']
- updated_courses = 0
-
- for course in Course.objects.all().order_by('id'):
- actual_count = course.lessons.filter(is_active=True).count()
-
- if course.lessons_count != actual_count:
- updated_courses += 1
- self.stdout.write(
- f"Course {course.id} ({course.slug}): {course.lessons_count} -> {actual_count}"
- )
- if not dry_run:
- Course.objects.filter(pk=course.pk).update(lessons_count=actual_count)
-
- if dry_run:
- self.stdout.write(
- self.style.WARNING(
- f"Dry run complete. {updated_courses} course(s) would be updated."
- )
- )
- else:
- self.stdout.write(
- self.style.SUCCESS(
- f"Sync complete. {updated_courses} course(s) updated."
- )
- )
diff --git a/apps/course/management/commands/sync_quiz_lesson_numbers.py b/apps/course/management/commands/sync_quiz_lesson_numbers.py
deleted file mode 100644
index 11914ed..0000000
--- a/apps/course/management/commands/sync_quiz_lesson_numbers.py
+++ /dev/null
@@ -1,86 +0,0 @@
-from django.core.management.base import BaseCommand
-from django.db.models import Q
-from apps.quiz.models import Quiz
-from apps.course.models import CourseLesson
-
-
-class Command(BaseCommand):
- help = "Sync lesson_number for all quizzes connected to a lesson."
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--dry-run',
- action='store_true',
- help='Preview updates without saving changes.',
- )
-
- def handle(self, *args, **options):
- dry_run = options['dry_run']
- updated_quizzes = 0
-
- # We filter quizzes that have a lesson assigned
- quizzes = Quiz.objects.filter(lesson__isnull=False)
-
- for quiz in quizzes:
- lesson = quiz.lesson
- course = quiz.course or lesson.course
-
- if not course:
- self.stdout.write(
- self.style.WARNING(f"Quiz {quiz.id} has lesson {lesson.id} but no associated course found.")
- )
- continue
-
- # Find all active lessons for this course to calculate position
- lessons_list = list(CourseLesson.objects.filter(
- course=course,
- is_active=True
- ).filter(
- Q(chapter__isnull=True) | Q(chapter__is_active=True)
- ).order_by('chapter__priority', 'priority'))
-
- try:
- pos = lessons_list.index(lesson) + 1
- except ValueError:
- # Lesson might not be active, or not in the course's active list
- self.stdout.write(
- self.style.WARNING(
- f"Quiz {quiz.id}: Lesson {lesson.id} is not active or not in course active list. Skipping."
- )
- )
- continue
-
- needs_update = False
- update_fields = []
-
- if quiz.lesson_number != pos:
- self.stdout.write(
- f"Quiz {quiz.id}: lesson_number {quiz.lesson_number} -> {pos}"
- )
- quiz.lesson_number = pos
- needs_update = True
- update_fields.append('lesson_number')
-
- if not quiz.course:
- self.stdout.write(f"Quiz {quiz.id}: setting course to {course.id}")
- quiz.course = course
- needs_update = True
- update_fields.append('course')
-
- if needs_update:
- updated_quizzes += 1
- if not dry_run:
- quiz.save(update_fields=update_fields)
-
- if dry_run:
- self.stdout.write(
- self.style.WARNING(
- f"Dry run complete. {updated_quizzes} quiz(zes) would be updated."
- )
- )
- else:
- self.stdout.write(
- self.style.SUCCESS(
- f"Sync complete. {updated_quizzes} quiz(zes) updated."
- )
- )
diff --git a/apps/course/migrations/0001_initial.py b/apps/course/migrations/0001_initial.py
deleted file mode 100644
index 6195e4e..0000000
--- a/apps/course/migrations/0001_initial.py
+++ /dev/null
@@ -1,1007 +0,0 @@
-# Generated by Django 4.2.27 on 2026-01-22 10:48
-
-import apps.course.models.course
-import apps.course.models.lesson
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-import utils.schema
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ("account", "0001_initial"),
- ]
-
- operations = [
- migrations.CreateModel(
- name="Attachment",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "title",
- models.CharField(max_length=255, verbose_name="Attachment Title"),
- ),
- (
- "file",
- models.FileField(
- upload_to=apps.course.models.course.attachment_file_upload_to,
- verbose_name="Attachment File",
- ),
- ),
- (
- "file_size",
- models.PositiveIntegerField(
- blank=True, null=True, verbose_name="File Size (in bytes)"
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- ],
- options={
- "verbose_name": "Attachment",
- "verbose_name_plural": "Attachments",
- },
- ),
- migrations.CreateModel(
- name="Course",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "title",
- models.CharField(max_length=255, verbose_name="Course Title"),
- ),
- ("slug", models.SlugField(allow_unicode=True, unique=True)),
- (
- "thumbnail",
- models.ImageField(
- upload_to="courses/thumbnails/", verbose_name="Thumbnail"
- ),
- ),
- (
- "video_type",
- models.CharField(
- choices=[
- ("youtube_link", "Youtube Link"),
- ("video_file", "Video File"),
- ],
- max_length=20,
- verbose_name="Preview Video Type (YouTube Link or File Upload)",
- ),
- ),
- (
- "video_file",
- models.FileField(
- blank=True,
- null=True,
- upload_to=apps.course.models.course.course_file_upload_to,
- ),
- ),
- ("video_link", models.CharField(blank=True, max_length=500, null=True)),
- (
- "is_online",
- models.BooleanField(default=False, verbose_name="Is Online Course"),
- ),
- (
- "online_link",
- models.CharField(
- blank=True,
- max_length=500,
- null=True,
- verbose_name="Online Class Link",
- ),
- ),
- (
- "level",
- models.CharField(
- choices=[
- ("beginner", "Beginner"),
- ("mid", "Mid Level"),
- ("advanced", "Advanced"),
- ],
- max_length=10,
- verbose_name="Course Level",
- ),
- ),
- (
- "duration",
- models.PositiveIntegerField(verbose_name="Duration (in hours)"),
- ),
- (
- "lessons_count",
- models.PositiveIntegerField(verbose_name="Number of Lessons"),
- ),
- ("description", models.TextField(verbose_name="Course Description")),
- (
- "short_description",
- models.CharField(
- blank=True,
- max_length=500,
- null=True,
- verbose_name="Short Description",
- ),
- ),
- (
- "status",
- models.CharField(
- choices=[
- ("inactive", "Inactive"),
- ("upcoming", "Upcoming"),
- ("registering", "Registering"),
- ("ongoing", "Ongoing"),
- ("finished", "Finished"),
- ],
- default="inactive",
- max_length=15,
- verbose_name="Course Status",
- ),
- ),
- ("is_free", models.BooleanField(default=True, verbose_name="Is Free")),
- (
- "price",
- models.DecimalField(
- decimal_places=2,
- default=0.0,
- max_digits=10,
- verbose_name="Course Price",
- ),
- ),
- (
- "discount_percentage",
- models.PositiveIntegerField(
- default=0, verbose_name="Discount Percentage"
- ),
- ),
- (
- "final_price",
- models.DecimalField(
- blank=True,
- decimal_places=2,
- default=0.0,
- help_text="This field is automatically calculated based on the discount percentage.",
- max_digits=10,
- verbose_name="Course Final Price",
- ),
- ),
- (
- "timing",
- models.JSONField(
- blank=True,
- default=utils.schema.default_timing,
- null=True,
- verbose_name="Timing",
- ),
- ),
- (
- "features",
- models.JSONField(
- blank=True,
- default=dict,
- null=True,
- verbose_name="Course features",
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- ],
- options={
- "verbose_name": "Course",
- "verbose_name_plural": "Courses",
- },
- ),
- migrations.CreateModel(
- name="CourseCategory",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "name",
- models.CharField(max_length=255, verbose_name="Category Name"),
- ),
- ("slug", models.SlugField(max_length=255, unique=True)),
- ],
- ),
- migrations.CreateModel(
- name="CourseLiveSession",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "room_id",
- models.CharField(
- blank=True,
- help_text="Identifier of the PlugNMeet room.",
- max_length=255,
- null=True,
- unique=True,
- verbose_name="Room ID",
- ),
- ),
- (
- "subject",
- models.CharField(
- help_text="Topic of the live session.",
- max_length=255,
- verbose_name="Subject",
- ),
- ),
- (
- "started_at",
- models.DateTimeField(
- help_text="Start time of the live session.",
- verbose_name="Started At",
- ),
- ),
- (
- "ended_at",
- models.DateTimeField(
- blank=True,
- help_text="End time of the live session.",
- null=True,
- verbose_name="Ended At",
- ),
- ),
- (
- "recorded_file",
- models.FileField(
- blank=True,
- help_text="Recorded file of the live session.",
- null=True,
- upload_to="live_session_recordings/",
- verbose_name="Recorded File",
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- (
- "course",
- models.ForeignKey(
- help_text="Course that this live session belongs to.",
- on_delete=django.db.models.deletion.CASCADE,
- related_name="live_sessions",
- to="course.course",
- verbose_name="Course",
- ),
- ),
- ],
- options={
- "verbose_name": "Course Live Session",
- "verbose_name_plural": "Course Live Sessions",
- "ordering": ("-started_at", "-id"),
- },
- ),
- migrations.CreateModel(
- name="Glossary",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "title",
- models.CharField(max_length=555, verbose_name="Glossary Title"),
- ),
- ("description", models.TextField(verbose_name="Description")),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- ],
- options={
- "verbose_name": "Glossary",
- "verbose_name_plural": "Glossaries",
- },
- ),
- migrations.CreateModel(
- name="Lesson",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "title",
- models.CharField(max_length=255, verbose_name="Lesson Title"),
- ),
- (
- "content_type",
- models.CharField(
- choices=[
- ("youtube_link", "Youtube Link"),
- ("video_file", "Video File"),
- ],
- max_length=50,
- verbose_name="Content Type",
- ),
- ),
- (
- "content_file",
- models.FileField(
- blank=True,
- null=True,
- upload_to=apps.course.models.lesson.lesson_file_upload_to,
- ),
- ),
- (
- "video_link",
- models.CharField(
- blank=True, max_length=500, null=True, verbose_name="Link"
- ),
- ),
- (
- "duration",
- models.PositiveIntegerField(verbose_name="Duration (in minutes)"),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- ],
- options={
- "verbose_name": "Lesson",
- "verbose_name_plural": "Lessons",
- "indexes": [
- models.Index(
- fields=["content_type"], name="course_less_content_e1cf57_idx"
- ),
- models.Index(
- fields=["created_at"], name="course_less_created_4efb58_idx"
- ),
- ],
- },
- ),
- migrations.CreateModel(
- name="CourseLesson",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "title",
- models.CharField(
- blank=True,
- max_length=255,
- null=True,
- verbose_name="Course Lesson Title",
- ),
- ),
- (
- "priority",
- models.IntegerField(blank=True, null=True, verbose_name="Priority"),
- ),
- (
- "is_active",
- models.BooleanField(default=True, verbose_name="Is Active"),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- (
- "course",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="lessons",
- to="course.course",
- verbose_name="Course",
- ),
- ),
- (
- "lesson",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="course_lessons",
- to="course.lesson",
- verbose_name="Lesson",
- ),
- ),
- ],
- options={
- "verbose_name": "Course Lesson",
- "verbose_name_plural": "Course Lessons",
- },
- ),
- migrations.CreateModel(
- name="CourseGlossary",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- (
- "course",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="glossaries",
- to="course.course",
- verbose_name="Course",
- ),
- ),
- (
- "glossary",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="course_glossaries",
- to="course.glossary",
- verbose_name="Glossary",
- ),
- ),
- ],
- options={
- "verbose_name": "Course Glossary",
- "verbose_name_plural": "Course Glossaries",
- "ordering": ("-id",),
- },
- ),
- migrations.CreateModel(
- name="CourseAttachment",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- (
- "attachment",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="course_attachments",
- to="course.attachment",
- verbose_name="Attachment",
- ),
- ),
- (
- "course",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="attachments",
- to="course.course",
- verbose_name="Course",
- ),
- ),
- ],
- options={
- "verbose_name": "Course Attachment",
- "verbose_name_plural": "Course Attachments",
- "ordering": ("-id",),
- },
- ),
- migrations.AddField(
- model_name="course",
- name="category",
- field=models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="courses",
- to="course.coursecategory",
- verbose_name="Category",
- ),
- ),
- migrations.AddField(
- model_name="course",
- name="professor",
- field=models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="courses",
- to="account.professoruser",
- ),
- ),
- migrations.CreateModel(
- name="Participant",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- ("is_active", models.BooleanField(default=True)),
- ("joined_date", models.DateTimeField(auto_now_add=True)),
- ("unread_messages_count", models.IntegerField(default=0)),
- (
- "course",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="participants",
- to="course.course",
- ),
- ),
- (
- "student",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="participated_courses",
- to="account.studentuser",
- ),
- ),
- ],
- options={
- "indexes": [
- models.Index(
- fields=["student"], name="course_part_student_566b08_idx"
- ),
- models.Index(
- fields=["course"], name="course_part_course__7cbf7c_idx"
- ),
- models.Index(
- fields=["joined_date"], name="course_part_joined__27eaa0_idx"
- ),
- models.Index(
- fields=["student", "course"],
- name="course_part_student_c97a97_idx",
- ),
- ],
- "unique_together": {("student", "course")},
- },
- ),
- migrations.CreateModel(
- name="LiveSessionUser",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "role",
- models.CharField(
- choices=[
- ("participant", "Participant"),
- ("moderator", "Moderator"),
- ("observer", "Observer"),
- ],
- help_text="Role of the user in the session",
- max_length=50,
- verbose_name="Role",
- ),
- ),
- (
- "entered_at",
- models.DateTimeField(
- help_text="Time the user entered the session",
- verbose_name="Entered At",
- ),
- ),
- (
- "exited_at",
- models.DateTimeField(
- blank=True,
- default=None,
- help_text="Time the user exited the session",
- null=True,
- verbose_name="Exited At",
- ),
- ),
- (
- "is_online",
- models.BooleanField(
- default=True,
- help_text="Is the user currently online?",
- verbose_name="Is online",
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated At"),
- ),
- (
- "session",
- models.ForeignKey(
- help_text="Live session that the user joined.",
- on_delete=django.db.models.deletion.CASCADE,
- related_name="user_sessions",
- to="course.courselivesession",
- verbose_name="Live Session",
- ),
- ),
- (
- "user",
- models.ForeignKey(
- help_text="User participating in the live session.",
- on_delete=django.db.models.deletion.CASCADE,
- related_name="live_session_entries",
- to=settings.AUTH_USER_MODEL,
- verbose_name="User",
- ),
- ),
- ],
- options={
- "verbose_name": "User Session",
- "verbose_name_plural": "User Sessions",
- "ordering": ("-entered_at", "-id"),
- "indexes": [
- models.Index(
- fields=["session", "user"],
- name="course_live_session_b1eaa5_idx",
- ),
- models.Index(
- fields=["session", "is_online"],
- name="course_live_session_5ef9bc_idx",
- ),
- models.Index(
- fields=["user", "is_online"],
- name="course_live_user_id_384830_idx",
- ),
- ],
- "unique_together": {("session", "user", "entered_at")},
- },
- ),
- migrations.CreateModel(
- name="LiveSessionRecording",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "title",
- models.CharField(
- help_text="Title of the recording",
- max_length=255,
- verbose_name="Title",
- ),
- ),
- (
- "file",
- models.FileField(
- help_text="File of the recorded session",
- upload_to="recorded_sessions/",
- verbose_name="Recording File",
- ),
- ),
- (
- "file_time",
- models.DurationField(
- blank=True,
- help_text="Duration of the recording file",
- null=True,
- verbose_name="File Duration",
- ),
- ),
- (
- "recording_type",
- models.CharField(
- choices=[("voice", "Voice"), ("video", "Video")],
- help_text="Type of the recording (voice or video)",
- max_length=10,
- verbose_name="Recording Type",
- ),
- ),
- (
- "thumbnail",
- models.ImageField(
- blank=True,
- help_text="Thumbnail image for video recordings",
- null=True,
- upload_to="recording_thumbnails/",
- verbose_name="Thumbnail",
- ),
- ),
- (
- "created_at",
- models.DateTimeField(
- auto_now_add=True,
- help_text="Time the recording was created",
- verbose_name="Created At",
- ),
- ),
- (
- "updated_at",
- models.DateTimeField(
- auto_now=True,
- help_text="The datetime when the recording was last updated",
- verbose_name="Updated At",
- ),
- ),
- (
- "is_active",
- models.BooleanField(
- default=True,
- help_text="Whether this recording is active or not",
- verbose_name="Is Active",
- ),
- ),
- (
- "session",
- models.ForeignKey(
- help_text="Live session that this recording belongs to.",
- on_delete=django.db.models.deletion.CASCADE,
- related_name="recordings",
- to="course.courselivesession",
- verbose_name="Live Session",
- ),
- ),
- ],
- options={
- "verbose_name": "Live Session Recording",
- "verbose_name_plural": "Live Session Recordings",
- "ordering": ("-created_at", "-id"),
- "indexes": [
- models.Index(
- fields=["session", "is_active"],
- name="course_live_session_f35db0_idx",
- ),
- models.Index(
- fields=["session", "recording_type"],
- name="course_live_session_84b2bf_idx",
- ),
- ],
- },
- ),
- migrations.CreateModel(
- name="LessonCompletion",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- ("completed_at", models.DateTimeField(auto_now_add=True)),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
- ),
- (
- "course_lesson",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="completions",
- to="course.courselesson",
- ),
- ),
- (
- "student",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="lesson_completions",
- to="account.studentuser",
- ),
- ),
- ],
- options={
- "indexes": [
- models.Index(
- fields=["student"], name="course_less_student_f3c9b8_idx"
- ),
- models.Index(
- fields=["course_lesson"], name="course_less_course__1f3841_idx"
- ),
- models.Index(
- fields=["completed_at"], name="course_less_complet_8d2220_idx"
- ),
- models.Index(
- fields=["student", "course_lesson"],
- name="course_less_student_3b6367_idx",
- ),
- ],
- "unique_together": {("student", "course_lesson")},
- },
- ),
- migrations.AddIndex(
- model_name="courselivesession",
- index=models.Index(
- fields=["course", "started_at"], name="course_cour_course__b8968b_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="courselivesession",
- index=models.Index(
- fields=["course", "created_at"], name="course_cour_course__142085_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="courselivesession",
- index=models.Index(
- fields=["room_id"], name="course_cour_room_id_ed0222_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="courselesson",
- index=models.Index(
- fields=["course"], name="course_cour_course__4afa4c_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="courselesson",
- index=models.Index(
- fields=["lesson"], name="course_cour_lesson__e5c835_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="courselesson",
- index=models.Index(
- fields=["priority"], name="course_cour_priorit_dedac7_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="courselesson",
- index=models.Index(
- fields=["is_active"], name="course_cour_is_acti_490c61_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="courselesson",
- index=models.Index(
- fields=["course", "priority"], name="course_cour_course__192d2c_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="courselesson",
- index=models.Index(
- fields=["course", "is_active"], name="course_cour_course__7c6f06_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="courseattachment",
- index=models.Index(
- fields=["course"], name="course_cour_course__106cc8_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="courseattachment",
- index=models.Index(
- fields=["attachment"], name="course_cour_attachm_2da12a_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="course",
- index=models.Index(fields=["status"], name="course_cour_status_57ffd9_idx"),
- ),
- migrations.AddIndex(
- model_name="course",
- index=models.Index(
- fields=["is_free"], name="course_cour_is_free_9453a1_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="course",
- index=models.Index(
- fields=["created_at"], name="course_cour_created_49f06e_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="course",
- index=models.Index(fields=["slug"], name="course_cour_slug_235a66_idx"),
- ),
- migrations.AddIndex(
- model_name="course",
- index=models.Index(
- fields=["status", "created_at"], name="course_cour_status_bfcd24_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="course",
- index=models.Index(
- fields=["category", "status"], name="course_cour_categor_26bb4d_idx"
- ),
- ),
- migrations.AddIndex(
- model_name="course",
- index=models.Index(
- fields=["professor", "status"], name="course_cour_profess_5eae9a_idx"
- ),
- ),
- ]
diff --git a/apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py b/apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py
deleted file mode 100644
index 4420cf4..0000000
--- a/apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Generated by Django 5.2.12 on 2026-04-26 15:12
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0001_initial'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='course',
- name='is_chat_group_lock',
- field=models.BooleanField(default=False, verbose_name='Lock Group Chat'),
- ),
- migrations.AddField(
- model_name='course',
- name='is_prof_chat_lock',
- field=models.BooleanField(default=False, verbose_name='Lock Private Chats with Professor'),
- ),
- ]
diff --git a/apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py b/apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py
deleted file mode 100644
index 3d25c2e..0000000
--- a/apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Generated by Django 5.2.12 on 2026-04-26 15:18
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0002_course_is_chat_group_lock_course_is_prof_chat_lock'),
- ]
-
- operations = [
- migrations.RenameField(
- model_name='course',
- old_name='is_chat_group_lock',
- new_name='is_group_chat_locked',
- ),
- migrations.RenameField(
- model_name='course',
- old_name='is_prof_chat_lock',
- new_name='is_professor_chat_locked',
- ),
- ]
diff --git a/apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py b/apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py
deleted file mode 100644
index cf071bc..0000000
--- a/apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# Generated by Django 5.2.12 on 2026-05-03 14:02
-
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('account', '0002_alter_user_email_alter_user_username'),
- ('course', '0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more'),
- ]
-
- operations = [
- migrations.AlterModelOptions(
- name='lessoncompletion',
- options={'verbose_name': 'Lesson Completion', 'verbose_name_plural': 'Lesson Completions'},
- ),
- migrations.AlterModelOptions(
- name='participant',
- options={'verbose_name': 'Participant', 'verbose_name_plural': 'Participants'},
- ),
- migrations.AlterField(
- model_name='lessoncompletion',
- name='course_lesson',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='course.courselesson', verbose_name='Course Lesson'),
- ),
- migrations.AlterField(
- model_name='lessoncompletion',
- name='student',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_completions', to='account.studentuser', verbose_name='Student'),
- ),
- migrations.AlterField(
- model_name='participant',
- name='course',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='course.course', verbose_name='Course'),
- ),
- migrations.AlterField(
- model_name='participant',
- name='is_active',
- field=models.BooleanField(default=True, verbose_name='Is Active'),
- ),
- migrations.AlterField(
- model_name='participant',
- name='joined_date',
- field=models.DateTimeField(auto_now_add=True, verbose_name='Joined Date'),
- ),
- migrations.AlterField(
- model_name='participant',
- name='student',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participated_courses', to='account.studentuser', verbose_name='Student'),
- ),
- migrations.AlterField(
- model_name='participant',
- name='unread_messages_count',
- field=models.IntegerField(default=0, verbose_name='Unread Messages Count'),
- ),
- ]
diff --git a/apps/course/migrations/0005_alter_course_discount_percentage.py b/apps/course/migrations/0005_alter_course_discount_percentage.py
deleted file mode 100644
index df23292..0000000
--- a/apps/course/migrations/0005_alter_course_discount_percentage.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Generated by Django 5.2.12 on 2026-05-04 09:11
-
-import django.core.validators
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0004_alter_lessoncompletion_options_and_more'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='course',
- name='discount_percentage',
- field=models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Discount Percentage'),
- ),
- ]
diff --git a/apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py b/apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py
deleted file mode 100644
index 84fe8a8..0000000
--- a/apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# Generated by Django 5.2.12 on 2026-05-04 12:53
-
-import apps.course.models.course
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('account', '0003_alter_clientuser_options_and_more'),
- ('course', '0005_alter_course_discount_percentage'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='course',
- name='professor',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='account.professoruser', verbose_name='Professor'),
- ),
- migrations.AlterField(
- model_name='course',
- name='video_file',
- field=models.FileField(blank=True, null=True, upload_to=apps.course.models.course.course_file_upload_to, verbose_name='Video File'),
- ),
- migrations.AlterField(
- model_name='course',
- name='video_link',
- field=models.CharField(blank=True, max_length=500, null=True, verbose_name='Video Link'),
- ),
- ]
diff --git a/apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py b/apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py
deleted file mode 100644
index 59a1c70..0000000
--- a/apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# Generated by Django 5.2.12 on 2026-05-17 15:06
-
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0006_alter_course_professor_alter_course_video_file_and_more'),
- ]
-
- operations = [
- migrations.CreateModel(
- name='CourseChapter',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=255, verbose_name='Chapter Title')),
- ('priority', models.IntegerField(blank=True, null=True, verbose_name='Priority')),
- ('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('updated_at', models.DateTimeField(auto_now=True)),
- ],
- options={
- 'verbose_name': 'Course Chapter',
- 'verbose_name_plural': 'Course Chapters',
- 'ordering': ['priority'],
- },
- ),
- migrations.AlterModelOptions(
- name='courselesson',
- options={'ordering': ['priority'], 'verbose_name': 'Course Lesson', 'verbose_name_plural': 'Course Lessons'},
- ),
- migrations.RemoveIndex(
- model_name='courselesson',
- name='course_cour_course__4afa4c_idx',
- ),
- migrations.RemoveIndex(
- model_name='courselesson',
- name='course_cour_course__192d2c_idx',
- ),
- migrations.RemoveIndex(
- model_name='courselesson',
- name='course_cour_course__7c6f06_idx',
- ),
- migrations.AlterField(
- model_name='courselesson',
- name='title',
- field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Sub-lesson Title'),
- ),
- migrations.AddField(
- model_name='coursechapter',
- name='course',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='course.course', verbose_name='Course'),
- ),
- migrations.AddField(
- model_name='courselesson',
- name='chapter',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='course.coursechapter', verbose_name='Chapter'),
- ),
- migrations.AddIndex(
- model_name='courselesson',
- index=models.Index(fields=['chapter'], name='course_cour_chapter_09df20_idx'),
- ),
- ]
diff --git a/apps/course/migrations/0008_remove_course_course_cour_status_57ffd9_idx_and_more.py b/apps/course/migrations/0008_remove_course_course_cour_status_57ffd9_idx_and_more.py
deleted file mode 100644
index 7f6ca47..0000000
--- a/apps/course/migrations/0008_remove_course_course_cour_status_57ffd9_idx_and_more.py
+++ /dev/null
@@ -1,247 +0,0 @@
-# Generated by Django 5.2.12 on 2026-06-03 15:30
-
-import json
-from django.db import migrations, models
-
-
-def convert_to_json_format(apps, schema_editor):
- Attachment = apps.get_model('course', 'Attachment')
- Course = apps.get_model('course', 'Course')
- CourseCategory = apps.get_model('course', 'CourseCategory')
- CourseChapter = apps.get_model('course', 'CourseChapter')
- CourseLesson = apps.get_model('course', 'CourseLesson')
- Glossary = apps.get_model('course', 'Glossary')
- Lesson = apps.get_model('course', 'Lesson')
-
- def wrap_val(val):
- if not val:
- return "[]"
- if isinstance(val, str):
- stripped = val.strip()
- if stripped.startswith('[') and stripped.endswith(']'):
- try:
- # Already JSON
- json.loads(val)
- return val
- except Exception:
- pass
- # Wrap as JSON string
- return json.dumps([{"title": val, "language_code": "en"}], ensure_ascii=False)
- return json.dumps([{"title": str(val), "language_code": "en"}], ensure_ascii=False)
-
- # 1. Attachment
- for obj in Attachment.objects.all():
- obj.title = wrap_val(obj.title)
- obj.save()
-
- # 2. Course
- for obj in Course.objects.all():
- obj.title = wrap_val(obj.title)
- obj.slug = wrap_val(obj.slug)
- obj.level = wrap_val(obj.level)
- obj.status = wrap_val(obj.status)
- obj.timing = wrap_val(obj.timing)
- obj.features = wrap_val(obj.features)
- obj.save()
-
- # 3. CourseCategory
- for obj in CourseCategory.objects.all():
- obj.name = wrap_val(obj.name)
- obj.slug = wrap_val(obj.slug)
- obj.save()
-
- # 4. CourseChapter
- for obj in CourseChapter.objects.all():
- obj.title = wrap_val(obj.title)
- obj.save()
-
- # 5. CourseLesson
- for obj in CourseLesson.objects.all():
- obj.title = wrap_val(obj.title)
- obj.save()
-
- # 6. Glossary
- for obj in Glossary.objects.all():
- obj.description = wrap_val(obj.description)
- obj.save()
-
- # 7. Lesson
- for obj in Lesson.objects.all():
- obj.title = wrap_val(obj.title)
- obj.content_type = wrap_val(obj.content_type)
- obj.save()
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0007_coursechapter_alter_courselesson_options_and_more'),
- ]
-
- operations = [
- # Remove indexes
- migrations.RemoveIndex(
- model_name='course',
- name='course_cour_status_57ffd9_idx',
- ),
- migrations.RemoveIndex(
- model_name='course',
- name='course_cour_slug_235a66_idx',
- ),
- migrations.RemoveIndex(
- model_name='course',
- name='course_cour_status_bfcd24_idx',
- ),
- migrations.RemoveIndex(
- model_name='course',
- name='course_cour_categor_26bb4d_idx',
- ),
- migrations.RemoveIndex(
- model_name='course',
- name='course_cour_profess_5eae9a_idx',
- ),
- migrations.RemoveIndex(
- model_name='lesson',
- name='course_less_content_e1cf57_idx',
- ),
-
- # Phase 1: Temporary convert columns to TextField to lift length limits
- migrations.AlterField(
- model_name='attachment',
- name='title',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='course',
- name='title',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='course',
- name='slug',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='course',
- name='level',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='course',
- name='status',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='coursecategory',
- name='name',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='coursecategory',
- name='slug',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='coursechapter',
- name='title',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='courselesson',
- name='title',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='glossary',
- name='description',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='lesson',
- name='content_type',
- field=models.TextField(blank=True, null=True),
- ),
- migrations.AlterField(
- model_name='lesson',
- name='title',
- field=models.TextField(blank=True, null=True),
- ),
-
- # Phase 2: Run Python conversion logic to format as JSON strings
- migrations.RunPython(
- convert_to_json_format,
- reverse_code=migrations.RunPython.noop
- ),
-
- # Phase 3: Final conversion to JSONField database columns
- migrations.AlterField(
- model_name='attachment',
- name='title',
- field=models.JSONField(default=list, verbose_name='Attachment Title'),
- ),
- migrations.AlterField(
- model_name='course',
- name='features',
- field=models.JSONField(blank=True, default=list, null=True, verbose_name='Course features'),
- ),
- migrations.AlterField(
- model_name='course',
- name='level',
- field=models.JSONField(blank=True, default=list, null=True, verbose_name='Course Level'),
- ),
- migrations.AlterField(
- model_name='course',
- name='slug',
- field=models.JSONField(blank=True, default=list, null=True, verbose_name='Slug'),
- ),
- migrations.AlterField(
- model_name='course',
- name='status',
- field=models.JSONField(blank=True, default=list, null=True, verbose_name='Course Status'),
- ),
- migrations.AlterField(
- model_name='course',
- name='timing',
- field=models.JSONField(blank=True, default=list, null=True, verbose_name='Timing'),
- ),
- migrations.AlterField(
- model_name='course',
- name='title',
- field=models.JSONField(default=list, verbose_name='Course Title'),
- ),
- migrations.AlterField(
- model_name='coursecategory',
- name='name',
- field=models.JSONField(default=list, verbose_name='Category Name'),
- ),
- migrations.AlterField(
- model_name='coursecategory',
- name='slug',
- field=models.JSONField(blank=True, default=list, null=True, verbose_name='Slug'),
- ),
- migrations.AlterField(
- model_name='coursechapter',
- name='title',
- field=models.JSONField(default=list, verbose_name='Chapter Title'),
- ),
- migrations.AlterField(
- model_name='courselesson',
- name='title',
- field=models.JSONField(blank=True, default=list, null=True, verbose_name='Sub-lesson Title'),
- ),
- migrations.AlterField(
- model_name='glossary',
- name='description',
- field=models.JSONField(default=list, verbose_name='Description'),
- ),
- migrations.AlterField(
- model_name='lesson',
- name='content_type',
- field=models.JSONField(default=list, verbose_name='Content Type'),
- ),
- migrations.AlterField(
- model_name='lesson',
- name='title',
- field=models.JSONField(default=list, verbose_name='Lesson Title'),
- ),
- ]
diff --git a/apps/course/migrations/0009_alter_course_description_and_more.py b/apps/course/migrations/0009_alter_course_description_and_more.py
deleted file mode 100644
index fb59e4b..0000000
--- a/apps/course/migrations/0009_alter_course_description_and_more.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# Generated by Django 5.2.12 on 2026-06-06 09:11
-
-from django.db import migrations, models
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0008_remove_course_course_cour_status_57ffd9_idx_and_more'),
- ]
-
- operations = [
- migrations.SeparateDatabaseAndState(
- database_operations=[
- migrations.RunSQL(
- sql=[
- "ALTER TABLE course_course ALTER COLUMN description TYPE jsonb USING CASE WHEN description IS NULL OR description = '' THEN '[]'::jsonb ELSE jsonb_build_array(jsonb_build_object('title', description, 'language_code', 'en')) END;",
- "ALTER TABLE course_course ALTER COLUMN short_description TYPE jsonb USING CASE WHEN short_description IS NULL OR short_description = '' THEN '[]'::jsonb ELSE jsonb_build_array(jsonb_build_object('title', short_description, 'language_code', 'en')) END;"
- ],
- reverse_sql=[
- "ALTER TABLE course_course ALTER COLUMN description TYPE text USING CASE WHEN jsonb_array_length(description) > 0 THEN description->0->>'title' ELSE '' END;",
- "ALTER TABLE course_course ALTER COLUMN short_description TYPE varchar(500) USING CASE WHEN jsonb_array_length(short_description) > 0 THEN LEFT(short_description->0->>'title', 500) ELSE '' END;"
- ]
- )
- ],
- state_operations=[
- migrations.AlterField(
- model_name='course',
- name='description',
- field=models.JSONField(blank=True, default=list, null=True, verbose_name='Course Description'),
- ),
- migrations.AlterField(
- model_name='course',
- name='short_description',
- field=models.JSONField(blank=True, default=list, null=True, verbose_name='Short Description'),
- ),
- ]
- )
- ]
diff --git a/apps/course/migrations/0010_course_rub_prices.py b/apps/course/migrations/0010_course_rub_prices.py
deleted file mode 100644
index 3d21519..0000000
--- a/apps/course/migrations/0010_course_rub_prices.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0009_alter_course_description_and_more'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='course',
- name='final_price_rub',
- field=models.DecimalField(blank=True, decimal_places=2, default=0.0, help_text='This field is automatically calculated based on the discount percentage.', max_digits=10, verbose_name='Course Final Price (RUB)'),
- ),
- migrations.AddField(
- model_name='course',
- name='price_rub',
- field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='Course Price (RUB)'),
- ),
- ]
diff --git a/apps/course/migrations/0011_coursecategory_icon.py b/apps/course/migrations/0011_coursecategory_icon.py
deleted file mode 100644
index 2cf0a6b..0000000
--- a/apps/course/migrations/0011_coursecategory_icon.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("course", "0010_course_rub_prices"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="coursecategory",
- name="icon",
- field=models.URLField(blank=True, null=True, verbose_name="Icon URL"),
- ),
- ]
diff --git a/apps/course/migrations/0012_courselivesession_recording_title.py b/apps/course/migrations/0012_courselivesession_recording_title.py
deleted file mode 100644
index aee701c..0000000
--- a/apps/course/migrations/0012_courselivesession_recording_title.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Generated by Django 5.2.12 on 2026-06-10 12:29
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0011_coursecategory_icon'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='courselivesession',
- name='recording_title',
- field=models.CharField(blank=True, help_text='Custom title specified by the professor for the recording/lesson.', max_length=255, null=True, verbose_name='Recording Title'),
- ),
- ]
diff --git a/apps/course/migrations/0013_courselivesession_auto_close_fields.py b/apps/course/migrations/0013_courselivesession_auto_close_fields.py
deleted file mode 100644
index 783b920..0000000
--- a/apps/course/migrations/0013_courselivesession_auto_close_fields.py
+++ /dev/null
@@ -1,38 +0,0 @@
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("course", "0012_courselivesession_recording_title"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="courselivesession",
- name="auto_close_after_moderator_exit_at",
- field=models.DateTimeField(
- blank=True,
- help_text="When the session should be auto-closed if no moderator returns.",
- null=True,
- verbose_name="Auto Close After Moderator Exit At",
- ),
- ),
- migrations.AddField(
- model_name="courselivesession",
- name="last_moderator_left_at",
- field=models.DateTimeField(
- blank=True,
- help_text="Timestamp when the last moderator left the live session.",
- null=True,
- verbose_name="Last Moderator Left At",
- ),
- ),
- migrations.AddIndex(
- model_name="courselivesession",
- index=models.Index(
- fields=["ended_at", "auto_close_after_moderator_exit_at"],
- name="course_cour_ended_a_0f47b4_idx",
- ),
- ),
- ]
diff --git a/apps/course/migrations/0014_courselivesession_web_egress_fields.py b/apps/course/migrations/0014_courselivesession_web_egress_fields.py
deleted file mode 100644
index af51f5e..0000000
--- a/apps/course/migrations/0014_courselivesession_web_egress_fields.py
+++ /dev/null
@@ -1,78 +0,0 @@
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("course", "0013_courselivesession_auto_close_fields"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="courselivesession",
- name="web_egress_error",
- field=models.TextField(
- blank=True,
- help_text="Last WebEgress error message, if any.",
- null=True,
- verbose_name="Web Egress Error",
- ),
- ),
- migrations.AddField(
- model_name="courselivesession",
- name="web_egress_id",
- field=models.CharField(
- blank=True,
- help_text="Active LiveKit WebEgress identifier for this session.",
- max_length=255,
- null=True,
- verbose_name="Web Egress ID",
- ),
- ),
- migrations.AddField(
- model_name="courselivesession",
- name="web_egress_started_at",
- field=models.DateTimeField(
- blank=True,
- help_text="Timestamp when WebEgress recording started.",
- null=True,
- verbose_name="Web Egress Started At",
- ),
- ),
- migrations.AddField(
- model_name="courselivesession",
- name="web_egress_status",
- field=models.CharField(
- choices=[
- ("idle", "Idle"),
- ("starting", "Starting"),
- ("recording", "Recording"),
- ("stopping", "Stopping"),
- ("processing", "Processing"),
- ("completed", "Completed"),
- ("failed", "Failed"),
- ],
- default="idle",
- help_text="Current WebEgress recording status for this session.",
- max_length=20,
- verbose_name="Web Egress Status",
- ),
- ),
- migrations.AddField(
- model_name="courselivesession",
- name="web_egress_stopped_at",
- field=models.DateTimeField(
- blank=True,
- help_text="Timestamp when WebEgress recording stopped.",
- null=True,
- verbose_name="Web Egress Stopped At",
- ),
- ),
- migrations.AddIndex(
- model_name="courselivesession",
- index=models.Index(
- fields=["web_egress_status"],
- name="course_cour_web_egr_5d55cb_idx",
- ),
- ),
- ]
diff --git a/apps/course/migrations/0015_rename_course_cour_ended_a_0f47b4_idx_course_cour_ended_a_32eaaa_idx_and_more.py b/apps/course/migrations/0015_rename_course_cour_ended_a_0f47b4_idx_course_cour_ended_a_32eaaa_idx_and_more.py
deleted file mode 100644
index 50121ad..0000000
--- a/apps/course/migrations/0015_rename_course_cour_ended_a_0f47b4_idx_course_cour_ended_a_32eaaa_idx_and_more.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# Generated by Django 5.2.12 on 2026-06-14 11:53
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0014_courselivesession_web_egress_fields'),
- ]
-
- operations = [
- migrations.RenameIndex(
- model_name='courselivesession',
- new_name='course_cour_ended_a_32eaaa_idx',
- old_name='course_cour_ended_a_0f47b4_idx',
- ),
- migrations.RenameIndex(
- model_name='courselivesession',
- new_name='course_cour_web_egr_c57929_idx',
- old_name='course_cour_web_egr_5d55cb_idx',
- ),
- migrations.AlterField(
- model_name='coursecategory',
- name='icon',
- field=models.CharField(blank=True, max_length=500, null=True, verbose_name='Icon URL'),
- ),
- ]
diff --git a/apps/course/migrations/0016_remove_course_professor_course_professors.py b/apps/course/migrations/0016_remove_course_professor_course_professors.py
deleted file mode 100644
index 6f98019..0000000
--- a/apps/course/migrations/0016_remove_course_professor_course_professors.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Generated by Django 5.2.12 on 2026-06-17 10:51
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('account', '0005_user_password_enc'),
- ('course', '0015_rename_course_cour_ended_a_0f47b4_idx_course_cour_ended_a_32eaaa_idx_and_more'),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name='course',
- name='professor',
- ),
- migrations.AddField(
- model_name='course',
- name='professors',
- field=models.ManyToManyField(related_name='courses', to='account.professoruser', verbose_name='Professors'),
- ),
- ]
diff --git a/apps/course/migrations/0017_alter_coursecategory_options.py b/apps/course/migrations/0017_alter_coursecategory_options.py
deleted file mode 100644
index 862273b..0000000
--- a/apps/course/migrations/0017_alter_coursecategory_options.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# Generated by Django 5.2.12 on 2026-07-04 14:39
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0016_remove_course_professor_course_professors'),
- ]
-
- operations = [
- migrations.AlterModelOptions(
- name='coursecategory',
- options={'ordering': ('-id',)},
- ),
- ]
diff --git a/apps/course/migrations/0018_course_notified_new_registration_and_more.py b/apps/course/migrations/0018_course_notified_new_registration_and_more.py
deleted file mode 100644
index 064c1c8..0000000
--- a/apps/course/migrations/0018_course_notified_new_registration_and_more.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# Generated by Django 5.2.12 on 2026-07-08 11:04
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0017_alter_coursecategory_options'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='course',
- name='notified_new_registration',
- field=models.BooleanField(default=False, help_text="Designates whether students have been notified of this course's registration.", verbose_name='New Registration Notified'),
- ),
- migrations.AddField(
- model_name='courselivesession',
- name='is_cancelled',
- field=models.BooleanField(default=False, help_text='Designates whether this live session has been cancelled.', verbose_name='Is Cancelled'),
- ),
- migrations.AddField(
- model_name='courselivesession',
- name='reminder_sent',
- field=models.BooleanField(default=False, help_text='Designates whether a start reminder has been sent for this session.', verbose_name='Reminder Sent'),
- ),
- ]
diff --git a/apps/course/migrations/__init__.py b/apps/course/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/course/models/__init__.py b/apps/course/models/__init__.py
deleted file mode 100644
index bff68d7..0000000
--- a/apps/course/models/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .course import *
-from .lesson import *
-from .participant import *
-from .live_session import *
diff --git a/apps/course/models/course.py b/apps/course/models/course.py
deleted file mode 100644
index a812aca..0000000
--- a/apps/course/models/course.py
+++ /dev/null
@@ -1,699 +0,0 @@
-import os
-from decimal import Decimal
-import math
-from django.db import models
-from django.conf import settings
-from django.db.models import TextChoices
-from django.utils.text import slugify
-from django.utils.translation import gettext_lazy as _
-
-from apps.account.models import ProfessorUser
-from utils.schema import default_timing
-from utils import generate_slug_for_model, generate_language_slugs
-from django.core.validators import MinValueValidator, MaxValueValidator
-
-def extract_text_from_json(value):
- if not value:
- return ""
- if isinstance(value, list):
- for item in value:
- if isinstance(item, dict):
- text = item.get('title') or item.get('value') or item.get('text') or item.get('name')
- if text:
- return str(text)
- else:
- if item:
- return str(item)
- return ""
- if isinstance(value, dict):
- for lang in ("fa", "en", "ru"):
- if lang in value and value[lang]:
- v = value[lang]
- if isinstance(v, dict):
- return str(v.get('title') or v.get('value') or v.get('text') or v.get('name') or "")
- return str(v)
- for v in value.values():
- if isinstance(v, dict):
- txt = v.get('title') or v.get('value') or v.get('text') or v.get('name')
- if txt:
- return str(txt)
- elif v:
- return str(v)
- return ""
- if isinstance(value, (str, int, float)):
- return str(value)
- return ""
-
-
-def is_multilingual_list(value):
- if not isinstance(value, list):
- return False
- if len(value) == 0:
- return True
- return all(isinstance(item, dict) and 'language_code' in item for item in value)
-
-
-def format_multilingual_field(value):
- if value is None:
- return []
- if is_multilingual_list(value):
- return value
- if isinstance(value, dict) and any(k in value for k in ("fa", "en", "ru", "ar", "tr")):
- return [{"title": v, "language_code": k} for k, v in value.items() if v]
- return [{"title": value, "language_code": "en"}]
-
-
-def get_localized_field(lang, field_value):
- try:
- if isinstance(field_value, list) and field_value:
- requested_lang = str(lang or "").lower()
- fallback_map = {}
-
- for tr in field_value:
- if not isinstance(tr, dict):
- continue
-
- text = tr.get('title') or tr.get('text') or tr.get('value') or tr.get('name')
- language_code = str(tr.get('language_code') or "").lower()
-
- if language_code:
- fallback_map[language_code] = text
-
- if language_code == requested_lang and text:
- return text
-
- if fallback_map.get('en'):
- return fallback_map['en']
-
- if fallback_map.get('ru'):
- return fallback_map['ru']
-
- for tr in reversed(field_value):
- if isinstance(tr, dict):
- text = tr.get('title') or tr.get('text') or tr.get('value') or tr.get('name')
- if text:
- return text
-
- return extract_text_from_json(field_value)
- return extract_text_from_json(field_value)
- except Exception as exp:
- print(f'---> Error in get_localized_field: {exp}')
- return None
-
-
-
-
-def course_file_upload_to(instance, filename):
- return os.path.join(f"courses/{get_course_storage_slug(instance)}/videos/{filename}")
-
-
-def attachment_file_upload_to(instance, filename):
- return os.path.join(f"attachments/{filename}")
-
-
-def course_attachment_file_upload_to(instance, filename):
- return os.path.join(
- f"courses/{get_course_storage_slug(instance.course)}/attachments/{filename}"
- )
-
-
-def get_course_storage_slug(instance):
- slug_source = instance.slug
-
- if isinstance(slug_source, list):
- for lang in ("en", "fa", "ru"):
- for item in slug_source:
- if isinstance(item, dict) and item.get("language_code") == lang:
- localized_slug = item.get("title") or item.get("value") or item.get("text") or item.get("name")
- safe_slug = slugify(str(localized_slug or ""))
- if safe_slug:
- return safe_slug
-
- safe_slug = slugify(extract_text_from_json(slug_source))
- if safe_slug:
- return safe_slug
-
- safe_title = slugify(extract_text_from_json(instance.title))
- if safe_title:
- return safe_title
-
- if instance.pk:
- return f"course-{instance.pk}"
-
- return "course-draft"
-
-
-
-
-class CourseCategory(models.Model):
- name = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Category Name'))
-
- slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Slug'))
- icon = models.CharField(max_length=500, null=True, blank=True, verbose_name=_('Icon URL'))
-
- def __str__(self):
- return extract_text_from_json(self.name)
-
- def save(self, *args, **kwargs):
- self.name = format_multilingual_field(self.name)
- try:
- self.slug = generate_language_slugs(self.name)
- except Exception:
- self.slug = []
-
- if not self.icon or str(self.icon).strip() == "":
- self.icon = "/media/Frame.svg"
-
- super().save(*args, **kwargs)
-
- @property
- def course_count(self):
- return self.courses.exclude(status="inactive").count()
-
- class Meta:
- ordering = ('-id',)
-
-LEVEL_TRANSLATIONS = {
- 'beginner': {
- 'en': 'Beginner',
- 'ru': 'Начальный',
- },
- 'mid': {
- 'en': 'Mid Level',
- 'ru': 'Средний',
- },
- 'advanced': {
- 'en': 'Advanced',
- 'ru': 'Продвинутый',
- }
-}
-
-STATUS_TRANSLATIONS = {
- 'inactive': {
- 'en': 'Inactive',
- 'ru': 'Неактивен',
- },
- 'upcoming': {
- 'en': 'Upcoming',
- 'ru': 'Предстоящий',
- },
- 'registering': {
- 'en': 'Registering',
- 'ru': 'Регистрация',
- },
- 'ongoing': {
- 'en': 'Ongoing',
- 'ru': 'В процессе',
- },
- 'finished': {
- 'en': 'Finished',
- 'ru': 'Завершен',
- }
-}
-
-CURRENCY_USD = 'USD'
-CURRENCY_RUB = 'RUB'
-
-def normalize_level(value):
- if not value:
- return 'beginner'
- text = extract_text_from_json(value).lower().strip()
- if 'beginner' in text or 'начальн' in text or 'начинающ' in text:
- return 'beginner'
- if 'mid' in text or 'средн' in text:
- return 'mid'
- if 'advanced' in text or 'продвинут' in text:
- return 'advanced'
- return 'beginner'
-
-def normalize_status(value):
- if not value:
- return 'inactive'
- text = extract_text_from_json(value).lower().strip()
- if 'inactive' in text or 'неактив' in text:
- return 'inactive'
- if 'upcoming' in text or 'предстоящ' in text:
- return 'upcoming'
- if 'registering' in text or 'регистрац' in text:
- return 'registering'
- if 'ongoing' in text or 'в процессе' in text:
- return 'ongoing'
- if 'finished' in text or 'заверш' in text or 'законч' in text:
- return 'finished'
- return 'inactive'
-
-WEEKDAY_TRANSLATIONS = {
- 'monday': {
- 'en': 'Monday',
- 'ru': 'Понедельник',
- 'fa': 'دوشنبه',
- },
- 'tuesday': {
- 'en': 'Tuesday',
- 'ru': 'Вторник',
- 'fa': 'سهشنبه',
- },
- 'wednesday': {
- 'en': 'Wednesday',
- 'ru': 'Среда',
- 'fa': 'چهارشنبه',
- },
- 'thursday': {
- 'en': 'Thursday',
- 'ru': 'Четверг',
- 'fa': 'پنجشنبه',
- },
- 'friday': {
- 'en': 'Friday',
- 'ru': 'Пятница',
- 'fa': 'جمعه',
- },
- 'saturday': {
- 'en': 'Saturday',
- 'ru': 'Суббота',
- 'fa': 'شنبه',
- },
- 'sunday': {
- 'en': 'Sunday',
- 'ru': 'Воскресенье',
- 'fa': 'یکشنبه',
- }
-}
-
-def get_weekday_label(day_key, lang='en'):
- normalized_lang = (lang or 'en').lower()
- if normalized_lang.startswith('fa'):
- lang_key = 'fa'
- elif normalized_lang.startswith('ru'):
- lang_key = 'ru'
- else:
- lang_key = 'en'
- return WEEKDAY_TRANSLATIONS.get(day_key, {}).get(lang_key, day_key)
-
-def normalize_weekday_to_key(day_value):
- if not day_value:
- return 'saturday'
- txt = str(day_value).lower().strip()
- if 'monday' in txt or 'понедельник' in txt or 'دوشنبه' in txt:
- return 'monday'
- if 'tuesday' in txt or 'вторник' in txt or 'سه شنبه' in txt or 'سهشنبه' in txt:
- return 'tuesday'
- if 'wednesday' in txt or 'среда' in txt or 'چهارشنبه' in txt:
- return 'wednesday'
- if 'thursday' in txt or 'четверг' in txt or 'پنج شنبه' in txt or 'پنجشنبه' in txt:
- return 'thursday'
- if 'friday' in txt or 'пятница' in txt or 'جمعه' in txt:
- return 'friday'
- if 'saturday' in txt or 'суббота' in txt or 'شنبه' in txt:
- return 'saturday'
- if 'sunday' in txt or 'воскресенье' in txt or 'یک شنبه' in txt or 'یکشنبه' in txt:
- return 'sunday'
- return 'saturday'
-
-class Course(models.Model):
-
- class LevelChoices(TextChoices):
- BEGINNER = 'beginner', _('Beginner')
- MID = 'mid', _('Mid Level')
- ADVANCED = 'advanced', _('Advanced')
-
- class StatusChoices(TextChoices):
- INACTIVE = 'inactive', _('Inactive') # Not Active (does not show)
- UPCOMING = 'upcoming', _('Upcoming') # Upcoming (visible but registration not allowed)-Предстоящие
- REGISTERING = 'registering', _('Registering') # Registering (registration is open)-регистрация
- ONGOING = 'ongoing', _('Ongoing') # Ongoing (course has started, registration closed)-В процессе
- FINISHED = 'finished', _('Finished') # Finished (course has ended)-закончился
-
- class VedioTypeChoices(models.TextChoices):
- YOUTUBE_LINK = 'youtube_link', _('Youtube Link')
- VIDEO_FILE = 'video_file', _('Video File')
-
-
-
- title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Course Title'))
- slug = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Slug'))
- category = models.ForeignKey(CourseCategory, on_delete=models.CASCADE, related_name='courses', verbose_name=_('Category'))
- professors = models.ManyToManyField(
- ProfessorUser,
- related_name="courses",
- verbose_name=_("Professors")
- )
-
- thumbnail = models.ImageField(upload_to="courses/thumbnails/", verbose_name=_('Thumbnail'))
- video_type = models.CharField(
- max_length=20,
- choices=VedioTypeChoices.choices,
- verbose_name=_('Preview Video Type (YouTube Link or File Upload)')
- )
- video_file = models.FileField(
- upload_to=course_file_upload_to,
- null=True,
- blank=True,
- verbose_name=_("Video File")
- )
- video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Video Link"))
-
- is_online = models.BooleanField(default=False, verbose_name=_('Is Online Course'))
- online_link = models.CharField(max_length=500, null=True, blank=True, verbose_name=_('Online Class Link'))
- level = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Course Level'))
- duration = models.PositiveIntegerField(verbose_name=_('Duration (in hours)'))
- lessons_count = models.PositiveIntegerField(verbose_name=_('Number of Lessons'))
-
- description = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Course Description'))
- short_description = models.JSONField(default=list, null=True, blank=True, verbose_name=_("Short Description"))
- status = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Course Status'))
- is_free = models.BooleanField(default=True, verbose_name=_('Is Free'))
- price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name=_('Course Price'))
- price_rub = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name=_('Course Price (RUB)'))
- discount_percentage = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], verbose_name=_('Discount Percentage'))
- final_price = models.DecimalField(
- verbose_name=_('Course Final Price'), decimal_places=2, max_digits=10, default=0.00, blank=True,
- help_text=_('This field is automatically calculated based on the discount percentage.')
- )
- final_price_rub = models.DecimalField(
- verbose_name=_('Course Final Price (RUB)'), decimal_places=2, max_digits=10, default=0.00, blank=True,
- help_text=_('This field is automatically calculated based on the discount percentage.')
- )
-
- notified_new_registration = models.BooleanField(
- default=False,
- verbose_name=_("New Registration Notified"),
- help_text=_("Designates whether students have been notified of this course's registration.")
- )
- is_group_chat_locked = models.BooleanField(
- default=False,
- verbose_name=_('Lock Group Chat')
- )
- is_professor_chat_locked = models.BooleanField(
- default=False,
- verbose_name=_('Lock Private Chats with Professor')
- )
-
- timing = models.JSONField(blank=True, null=True, default=list, verbose_name=_("Timing"))
- features = models.JSONField(verbose_name=_('Course features'), default=list, blank=True, null=True)
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
-
-
- def __str__(self):
- return extract_text_from_json(self.title)
-
- def get_completed_lessons_count(self, student):
- return self.lessons.filter(completions__student=student).count()
-
- def is_student_participant(self, student):
- return self.participants.filter(student=student).exists()
-
- @classmethod
- def recalculate_lessons_count_for_course(cls, course_id):
- if not course_id:
- return 0
-
- from apps.course.models.lesson import CourseLesson
-
- lessons_count = CourseLesson.objects.filter(
- course_id=course_id,
- is_active=True,
- ).count()
- cls.objects.filter(pk=course_id).update(lessons_count=lessons_count)
- return lessons_count
-
- def recalculate_lessons_count(self):
- return self.__class__.recalculate_lessons_count_for_course(self.pk)
-
- def get_currency_for_language(self, lang):
- return CURRENCY_RUB
-
- def get_price_for_language(self, lang):
- if self.is_free:
- return Decimal('0.00')
- return Decimal(self.price_rub or 0)
-
- def get_final_price_for_language(self, lang):
- if self.is_free:
- return Decimal('0.00')
- return Decimal(self.final_price_rub or 0)
-
-
- def save(self, *args, **kwargs):
- self.title = format_multilingual_field(self.title)
-
- # Check if the title has changed (or if this is a new course)
- title_changed = False
- if not self.pk:
- title_changed = True
- else:
- try:
- original = self.__class__.objects.get(pk=self.pk)
- title_changed = original.title != self.title
- except self.__class__.DoesNotExist:
- title_changed = True
-
- if title_changed:
- # Extract the English title
- english_title = ""
- if isinstance(self.title, list):
- for tr in self.title:
- if isinstance(tr, dict) and tr.get("language_code") == "en":
- english_title = tr.get("title") or tr.get("text") or tr.get("value") or ""
- break
- # Fallback to any non-empty title if English title isn't found
- if not english_title:
- for tr in self.title:
- if isinstance(tr, dict):
- english_title = tr.get("title") or tr.get("text") or tr.get("value") or ""
- if english_title:
- break
-
- base_slug = slugify(str(english_title or ""))
- if not base_slug:
- base_slug = "course"
-
- val = base_slug
- counter = 1
- # Resolve global uniqueness collision (across other courses)
- while True:
- qs = self.__class__.objects.filter(slug__contains=[{"title": val}])
- if self.pk:
- qs = qs.exclude(pk=self.pk)
-
- if qs.exists():
- val = f"{base_slug}-{counter}"
- counter += 1
- else:
- break
-
- self.slug = [{"language_code": "en", "title": val}]
- else:
- self.slug = format_multilingual_field(self.slug)
-
- level_key = normalize_level(self.level)
- self.level = [
- {"title": LEVEL_TRANSLATIONS[level_key]["en"], "language_code": "en"},
- {"title": LEVEL_TRANSLATIONS[level_key]["ru"], "language_code": "ru"},
- ]
-
- status_key = normalize_status(self.status)
- self.status = [
- {"title": STATUS_TRANSLATIONS[status_key]["en"], "language_code": "en"},
- {"title": STATUS_TRANSLATIONS[status_key]["ru"], "language_code": "ru"},
- ]
-
- self.description = format_multilingual_field(self.description)
- self.short_description = format_multilingual_field(self.short_description)
- # Process timing (translate weekdays)
- import json
- raw_timings = []
- val = self.timing
- if val:
- if isinstance(val, str):
- try:
- val = json.loads(val)
- except Exception:
- pass
-
- if isinstance(val, list) and all(isinstance(x, dict) and 'language_code' in x for x in val):
- # We can extract the timing list from any language item
- extracted = []
- for x in val:
- title_val = x.get('title')
- if title_val:
- if isinstance(title_val, str):
- try:
- title_val = json.loads(title_val)
- except Exception:
- pass
- if isinstance(title_val, list):
- extracted = title_val
- break
- raw_timings = extracted
- elif isinstance(val, list):
- raw_timings = val
- elif isinstance(val, dict):
- raw_timings = []
- for k, v in val.items():
- raw_timings.append({"day": k, "time": str(v)})
-
- normalized_timings = []
- for item in raw_timings:
- if isinstance(item, dict):
- day_key = normalize_weekday_to_key(item.get('day'))
- time_val = item.get('time') or item.get('timing') or ''
- normalized_timings.append({
- "day": day_key,
- "time": str(time_val).strip(),
- })
-
- self.timing = normalized_timings
- self.features = format_multilingual_field(self.features)
-
- usd_price = Decimal(self.price or 0)
- rub_price = Decimal(self.price_rub or 0)
-
- # Ensure consistency: if both prices are 0, set is_free to True and discount_percentage to 0
- if usd_price == 0 and rub_price == 0:
- self.is_free = True
- self.discount_percentage = 0
- self.final_price = Decimal('0.00')
- self.final_price_rub = Decimal('0.00')
- elif self.is_free:
- self.price = Decimal('0.00')
- self.price_rub = Decimal('0.00')
- self.discount_percentage = 0
- self.final_price = Decimal('0.00')
- self.final_price_rub = Decimal('0.00')
- elif self.discount_percentage > 0:
- usd_discount_amount = (usd_price * Decimal(self.discount_percentage)) / Decimal('100')
- rub_discount_amount = (rub_price * Decimal(self.discount_percentage)) / Decimal('100')
- usd_final_price = usd_price - usd_discount_amount
- rub_final_price = rub_price - rub_discount_amount
- self.final_price = usd_final_price.quantize(Decimal('0.00'))
- self.final_price_rub = rub_final_price.quantize(Decimal('0.00'))
- else:
- self.final_price = usd_price.quantize(Decimal('0.00'))
- self.final_price_rub = rub_price.quantize(Decimal('0.00'))
-
- super().save(*args, **kwargs)
-
-
- class Meta:
- verbose_name = _("Course")
- verbose_name_plural = _("Courses")
-
- indexes = [
- models.Index(fields=['is_free']),
- models.Index(fields=['created_at']),
- ]
-
-
-class Glossary(models.Model):
- """
- Base Glossary model that contains the actual content
- """
- title = models.CharField(max_length=555, verbose_name=_('Glossary Title'))
- description = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Description'))
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
-
- def __str__(self):
- return self.title
-
- def save(self, *args, **kwargs):
- self.description = format_multilingual_field(self.description)
- super().save(*args, **kwargs)
-
- class Meta:
- verbose_name = _("Glossary")
- verbose_name_plural = _("Glossaries")
-
-
-
-class CourseGlossary(models.Model):
- """
- Intermediate model that connects Course with Glossary
- """
- course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='glossaries', verbose_name=_('Course'))
- glossary = models.ForeignKey(Glossary, on_delete=models.CASCADE, related_name='course_glossaries', verbose_name=_('Glossary'))
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
-
- def __str__(self):
- return f"{extract_text_from_json(self.course.title)} - {self.glossary.title}"
-
- @property
- def title(self):
- return self.glossary.title
-
- @property
- def description(self):
- return self.glossary.description
-
- class Meta:
- ordering = ("-id",)
- verbose_name = _("Course Glossary")
- verbose_name_plural = _("Course Glossaries")
-
-
-
-class Attachment(models.Model):
- """
- Base Attachment model that contains the actual file
- """
- title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Attachment Title'))
- file = models.FileField(
- upload_to=attachment_file_upload_to,
- verbose_name=_('Attachment File')
- )
- file_size = models.PositiveIntegerField(verbose_name=_('File Size (in bytes)'), null=True, blank=True)
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
-
- def save(self, *args, **kwargs):
- self.title = format_multilingual_field(self.title)
- # Calculate the file size before saving
- if self.file and not self.file_size:
- self.file_size = self.file.size
- super().save(*args, **kwargs)
-
- def __str__(self):
- return extract_text_from_json(self.title)
-
- class Meta:
- verbose_name = _("Attachment")
- verbose_name_plural = _("Attachments")
-
-
-
-class CourseAttachment(models.Model):
- """
- Intermediate model that connects Course with Attachment
- """
- course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='attachments', verbose_name=_('Course'))
- attachment = models.ForeignKey(Attachment, on_delete=models.CASCADE, related_name='course_attachments', verbose_name=_('Attachment'))
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
-
- def __str__(self):
- return f"{extract_text_from_json(self.course.title)} - {extract_text_from_json(self.attachment.title)}"
-
- @property
- def title(self):
- return self.attachment.title
-
- @property
- def file(self):
- return self.attachment.file
-
- @property
- def file_size(self):
- return self.attachment.file_size
-
- class Meta:
- ordering = ("-id",)
- verbose_name = _("Course Attachment")
- verbose_name_plural = _("Course Attachments")
-
- indexes = [
- models.Index(fields=['course']),
- models.Index(fields=['attachment']),
- ]
diff --git a/apps/course/models/lesson.py b/apps/course/models/lesson.py
deleted file mode 100644
index adac636..0000000
--- a/apps/course/models/lesson.py
+++ /dev/null
@@ -1,188 +0,0 @@
-import os
-from django.db import models
-from django.utils.translation import gettext_lazy as _
-
-from filer.fields.image import FilerImageField
-from filer.fields.file import FilerFileField
-
-from apps.account.models import StudentUser
-from apps.course.models.course import extract_text_from_json, format_multilingual_field, get_localized_field
-
-
-def lesson_file_upload_to(instance, filename):
- return os.path.join(f"lessons/{filename}")
-
-
-
-class Lesson(models.Model):
- """
- Base Lesson model that contains the actual content (video file or link)
- """
- class ContentTypeChoices(models.TextChoices):
- YOUTUBE_LINK = 'youtube_link', _('Youtube Link')
- VIDEO_FILE = 'video_file', _('Video File')
-
- title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Lesson Title'))
- content_type = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Content Type'))
-
- content_file = models.FileField(
- null=True,
- blank=True,
- upload_to=lesson_file_upload_to,
- )
- video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name=_('Link'))
- duration = models.PositiveIntegerField(verbose_name=_('Duration (in minutes)'))
-
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
-
- def __str__(self):
- return extract_text_from_json(self.title)
-
- def save(self, *args, **kwargs):
- self.title = format_multilingual_field(self.title)
- self.content_type = format_multilingual_field(self.content_type)
- super().save(*args, **kwargs)
-
- class Meta:
- verbose_name = _("Lesson")
- verbose_name_plural = _("Lessons")
-
- indexes = [
- models.Index(fields=['created_at']),
- ]
-
-class CourseChapter(models.Model):
- """
- V2 Grouping Layer: Groups lessons together into chapters/sections.
- """
- course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='chapters', verbose_name=_('Course'))
- title = models.JSONField(default=list, null=False, blank=False, verbose_name=_('Chapter Title'))
- priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority'))
- is_active = models.BooleanField(default=True, verbose_name=_('Is Active'))
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
-
- def __str__(self):
- return f"{extract_text_from_json(self.course.title)} - {extract_text_from_json(self.title)}"
-
- def save(self, *args, **kwargs):
- self.title = format_multilingual_field(self.title)
- if self.priority is None:
- max_priority = self.course.chapters.aggregate(max_p=models.Max('priority'))['max_p']
- self.priority = (max_priority or 0) + 1
- else:
- self._adjust_priorities()
- super().save(*args, **kwargs)
-
- def _adjust_priorities(self):
- chapters = self.course.chapters.exclude(pk=self.pk)
- chapters.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1)
-
- class Meta:
- verbose_name = _("Course Chapter")
- verbose_name_plural = _("Course Chapters")
- ordering = ['priority']
-
-class CourseLesson(models.Model):
- """
- Intermediate model that connects a Chapter with a Lesson (the actual context)
- """
- course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name=_('Course'))
- chapter = models.ForeignKey(CourseChapter, on_delete=models.CASCADE, related_name='lessons', verbose_name=_('Chapter') , null=True , blank=True)
- lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='course_lessons', verbose_name=_('Lesson'))
- title = models.JSONField(default=list, null=True, blank=True, verbose_name=_('Sub-lesson Title'))
- priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority'))
-
- is_active = models.BooleanField(default=True, verbose_name=_('Is Active'))
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
-
- def __str__(self):
- title = extract_text_from_json(self.title) or extract_text_from_json(self.lesson.title)
- return f"{extract_text_from_json(self.chapter.title)} - {title}"
-
- def is_completed_by(self, student):
- return self.completions.filter(student=student).exists()
-
- @property
- def content_type(self):
- return self.lesson.content_type
-
- @property
- def content_file(self):
- return self.lesson.content_file
-
- @property
- def video_link(self):
- return self.lesson.video_link
-
- @property
- def duration(self):
- return self.lesson.duration
-
- def save(self, *args, **kwargs):
- # 👇 3. Auto-sync v1 and v2 relations!
- if self.chapter and getattr(self, 'course_id', None) != self.chapter.course_id:
- self.course = self.chapter.course
-
- if not self.title or self.title == []:
- self.title = self.lesson.title
-
- self.title = format_multilingual_field(self.title)
-
- if self.priority is None:
- max_priority = self.course.lessons.aggregate(max_priority=models.Max('priority'))['max_priority']
- self.priority = (max_priority or 0) + 1
- else:
- self._adjust_priorities()
- super().save(*args, **kwargs)
- def _adjust_priorities(self):
- lessons = self.chapter.lessons.exclude(pk=self.pk)
- lessons.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1)
-
- class Meta:
- verbose_name = _("Course Lesson")
- verbose_name_plural = _("Course Lessons")
- ordering = ['priority']
- indexes = [
- models.Index(fields=['chapter']),
- models.Index(fields=['lesson']),
- models.Index(fields=['priority']),
- models.Index(fields=['is_active']),
- ]
-
-
-class LessonCompletion(models.Model):
- student = models.ForeignKey(
- StudentUser,
- on_delete=models.CASCADE,
- related_name='lesson_completions',
- verbose_name=_('Student')
- )
- course_lesson = models.ForeignKey(
- CourseLesson,
- on_delete=models.CASCADE,
- related_name='completions',
- verbose_name=_('Course Lesson')
- )
-
- completed_at = models.DateTimeField(auto_now_add=True)
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
-
- class Meta:
- unique_together = ('student', 'course_lesson')
- verbose_name = _("Lesson Completion")
- verbose_name_plural = _("Lesson Completions")
-
- indexes = [
- models.Index(fields=['student']),
- models.Index(fields=['course_lesson']),
- models.Index(fields=['completed_at']),
- models.Index(fields=['student', 'course_lesson']),
- ]
-
- def __str__(self):
- return f"{self.student.fullname} - {self.course_lesson.title} - Completed"
-
-
diff --git a/apps/course/models/live_session.py b/apps/course/models/live_session.py
deleted file mode 100644
index 2e34f7b..0000000
--- a/apps/course/models/live_session.py
+++ /dev/null
@@ -1,270 +0,0 @@
-from django.db import models
-from django.utils.translation import gettext_lazy as _
-
-from .course import Course
-from apps.account.models import User
-
-
-class CourseLiveSession(models.Model):
- WEB_EGRESS_STATUS_IDLE = "idle"
- WEB_EGRESS_STATUS_STARTING = "starting"
- WEB_EGRESS_STATUS_RECORDING = "recording"
- WEB_EGRESS_STATUS_STOPPING = "stopping"
- WEB_EGRESS_STATUS_PROCESSING = "processing"
- WEB_EGRESS_STATUS_COMPLETED = "completed"
- WEB_EGRESS_STATUS_FAILED = "failed"
-
- WEB_EGRESS_STATUS_CHOICES = (
- (WEB_EGRESS_STATUS_IDLE, _("Idle")),
- (WEB_EGRESS_STATUS_STARTING, _("Starting")),
- (WEB_EGRESS_STATUS_RECORDING, _("Recording")),
- (WEB_EGRESS_STATUS_STOPPING, _("Stopping")),
- (WEB_EGRESS_STATUS_PROCESSING, _("Processing")),
- (WEB_EGRESS_STATUS_COMPLETED, _("Completed")),
- (WEB_EGRESS_STATUS_FAILED, _("Failed")),
- )
-
- course = models.ForeignKey(
- Course,
- on_delete=models.CASCADE,
- related_name="live_sessions",
- verbose_name=_("Course"),
- help_text=_("Course that this live session belongs to."),
- )
- room_id = models.CharField(
- max_length=255,
- verbose_name=_("Room ID"),
- help_text=_("Identifier of the PlugNMeet room."),
- unique=True,
- null=True,
- blank=True,
- )
- subject = models.CharField(
- max_length=255,
- verbose_name=_("Subject"),
- help_text=_("Topic of the live session."),
- )
- started_at = models.DateTimeField(
- verbose_name=_("Started At"),
- help_text=_("Start time of the live session."),
- )
- ended_at = models.DateTimeField(
- verbose_name=_("Ended At"),
- help_text=_("End time of the live session."),
- null=True,
- blank=True,
- )
- last_moderator_left_at = models.DateTimeField(
- verbose_name=_("Last Moderator Left At"),
- help_text=_("Timestamp when the last moderator left the live session."),
- null=True,
- blank=True,
- )
- auto_close_after_moderator_exit_at = models.DateTimeField(
- verbose_name=_("Auto Close After Moderator Exit At"),
- help_text=_("When the session should be auto-closed if no moderator returns."),
- null=True,
- blank=True,
- )
- recording_title = models.CharField(
- max_length=255,
- verbose_name=_("Recording Title"),
- help_text=_("Custom title specified by the professor for the recording/lesson."),
- null=True,
- blank=True,
- )
- web_egress_id = models.CharField(
- max_length=255,
- verbose_name=_("Web Egress ID"),
- help_text=_("Active LiveKit WebEgress identifier for this session."),
- null=True,
- blank=True,
- )
- web_egress_status = models.CharField(
- max_length=20,
- choices=WEB_EGRESS_STATUS_CHOICES,
- default=WEB_EGRESS_STATUS_IDLE,
- verbose_name=_("Web Egress Status"),
- help_text=_("Current WebEgress recording status for this session."),
- )
- web_egress_started_at = models.DateTimeField(
- verbose_name=_("Web Egress Started At"),
- help_text=_("Timestamp when WebEgress recording started."),
- null=True,
- blank=True,
- )
- web_egress_stopped_at = models.DateTimeField(
- verbose_name=_("Web Egress Stopped At"),
- help_text=_("Timestamp when WebEgress recording stopped."),
- null=True,
- blank=True,
- )
- web_egress_error = models.TextField(
- verbose_name=_("Web Egress Error"),
- help_text=_("Last WebEgress error message, if any."),
- null=True,
- blank=True,
- )
- recorded_file = models.FileField(
- upload_to="live_session_recordings/",
- verbose_name=_("Recorded File"),
- help_text=_("Recorded file of the live session."),
- null=True,
- blank=True,
- )
- is_cancelled = models.BooleanField(
- default=False,
- verbose_name=_("Is Cancelled"),
- help_text=_("Designates whether this live session has been cancelled.")
- )
- reminder_sent = models.BooleanField(
- default=False,
- verbose_name=_("Reminder Sent"),
- help_text=_("Designates whether a start reminder has been sent for this session.")
- )
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
-
- def __str__(self):
- return f"{self.course} - {self.subject}"
-
- class Meta:
- ordering = ("-started_at", "-id")
- verbose_name = _("Course Live Session")
- verbose_name_plural = _("Course Live Sessions")
- indexes = [
- models.Index(fields=["course", "started_at"]),
- models.Index(fields=["course", "created_at"]),
- models.Index(fields=["room_id"]),
- models.Index(fields=["ended_at", "auto_close_after_moderator_exit_at"]),
- models.Index(fields=["web_egress_status"]),
- ]
-
-
-USER_ROLE_CHOICES = (
- ("participant", _("Participant")),
- ("moderator", _("Moderator")),
- ("observer", _("Observer")),
-)
-
-
-
-class LiveSessionUser(models.Model):
- session = models.ForeignKey(
- CourseLiveSession,
- on_delete=models.CASCADE,
- related_name="user_sessions",
- verbose_name=_("Live Session"),
- help_text=_("Live session that the user joined."),
- )
- user = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name="live_session_entries",
- verbose_name=_("User"),
- help_text=_("User participating in the live session."),
- )
- role = models.CharField(
- max_length=50,
- choices=USER_ROLE_CHOICES,
- verbose_name=_("Role"),
- help_text=_("Role of the user in the session"),
- )
- entered_at = models.DateTimeField(
- verbose_name=_("Entered At"),
- help_text=_("Time the user entered the session"),
- )
- exited_at = models.DateTimeField(
- verbose_name=_("Exited At"),
- help_text=_("Time the user exited the session"),
- null=True,
- blank=True,
- default=None,
- )
- is_online = models.BooleanField(
- default=True,
- verbose_name=_("Is online"),
- help_text=_("Is the user currently online?"),
- )
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
-
- def __str__(self):
- return f"{self.user} @ {self.session}"
-
- class Meta:
- verbose_name = _("User Session")
- verbose_name_plural = _("User Sessions")
- ordering = ("-entered_at", "-id")
- indexes = [
- models.Index(fields=["session", "user"]),
- models.Index(fields=["session", "is_online"]),
- models.Index(fields=["user", "is_online"]),
- ]
- unique_together = ("session", "user", "entered_at")
-
-
-RECORDING_TYPE_CHOICES = (
- ("voice", _("Voice")),
- ("video", _("Video")),
-)
-
-
-
-class LiveSessionRecording(models.Model):
- session = models.ForeignKey(
- CourseLiveSession,
- on_delete=models.CASCADE,
- related_name="recordings",
- verbose_name=_("Live Session"),
- help_text=_("Live session that this recording belongs to."),
- )
- title = models.CharField(
- max_length=255,
- verbose_name=_("Title"),
- help_text=_("Title of the recording"),
- )
- file = models.FileField(
- upload_to="recorded_sessions/",
- verbose_name=_("Recording File"),
- help_text=_("File of the recorded session"),
- )
- file_time = models.DurationField(
- verbose_name=_("File Duration"),
- help_text=_("Duration of the recording file"),
- null=True,
- blank=True,
- )
- recording_type = models.CharField(
- max_length=10,
- choices=RECORDING_TYPE_CHOICES,
- verbose_name=_("Recording Type"),
- help_text=_("Type of the recording (voice or video)"),
- )
- thumbnail = models.ImageField(
- upload_to="recording_thumbnails/",
- verbose_name=_("Thumbnail"),
- help_text=_("Thumbnail image for video recordings"),
- null=True,
- blank=True,
- )
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"), help_text=_("Time the recording was created"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"), help_text=_("The datetime when the recording was last updated"))
- is_active = models.BooleanField(
- default=True,
- verbose_name=_("Is Active"),
- help_text=_("Whether this recording is active or not"),
- )
-
- def __str__(self):
- meet_id = getattr(self.session, "meet_id", self.session_id)
- return f"meet:<{meet_id}><{self.id}>{self.title} - {self.recording_type}"
-
- class Meta:
- verbose_name = _("Live Session Recording")
- verbose_name_plural = _("Live Session Recordings")
- ordering = ("-created_at", "-id")
- indexes = [
- models.Index(fields=["session", "is_active"]),
- models.Index(fields=["session", "recording_type"]),
- ]
diff --git a/apps/course/models/participant.py b/apps/course/models/participant.py
deleted file mode 100644
index 497d300..0000000
--- a/apps/course/models/participant.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from django.db import models
-from django.utils.translation import gettext_lazy as _
-
-from apps.account.models import StudentUser, User
-from apps.course.models import Course
-
-
-class Participant(models.Model):
- student = models.ForeignKey(
- StudentUser,
- on_delete=models.CASCADE,
- related_name='participated_courses',
- verbose_name=_('Student')
- )
- course = models.ForeignKey(
- Course,
- on_delete=models.CASCADE,
- related_name='participants',
- verbose_name=_('Course')
- )
- is_active = models.BooleanField(default=True, verbose_name=_('Is Active'))
- joined_date = models.DateTimeField(auto_now_add=True, verbose_name=_('Joined Date'))
- unread_messages_count = models.IntegerField(default=0, verbose_name=_('Unread Messages Count'))
-
- class Meta:
- unique_together = ('student', 'course')
- verbose_name = _('Participant')
- verbose_name_plural = _('Participants')
- indexes = [
- models.Index(fields=['student']),
- models.Index(fields=['course']),
- models.Index(fields=['joined_date']),
- models.Index(fields=['student', 'course']),
- ]
diff --git a/apps/course/serializers/__init__.py b/apps/course/serializers/__init__.py
deleted file mode 100644
index cb5e526..0000000
--- a/apps/course/serializers/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from .course import *
-from .lesson import *
-from .participant import *
-from .online import *
-from .professor import *
diff --git a/apps/course/serializers/admin.py b/apps/course/serializers/admin.py
deleted file mode 100644
index 19b7454..0000000
--- a/apps/course/serializers/admin.py
+++ /dev/null
@@ -1,672 +0,0 @@
-from rest_framework import serializers
-from utils import FileFieldSerializer, get_thumbs
-from apps.course.models import (
- Course,
- CourseCategory,
- CourseChapter,
- CourseLesson,
- Lesson,
- CourseAttachment,
- Attachment,
- CourseGlossary,
- Glossary,
- CourseLiveSession,
- LiveSessionUser,
- LiveSessionRecording,
- Participant
-)
-from apps.account.models import ProfessorUser
-from apps.course.models.course import extract_text_from_json, get_localized_field
-
-def get_request_lang(serializer_instance):
- request = serializer_instance.context.get('request')
- language_code = getattr(request, 'LANGUAGE_CODE', None)
- if language_code:
- return str(language_code).lower()
-
- header_value = ''
- if request is not None:
- if hasattr(request, 'headers'):
- header_value = request.headers.get('Accept-Language', '')
- if not header_value and hasattr(request, 'META'):
- header_value = request.META.get('HTTP_ACCEPT_LANGUAGE', '')
-
- if header_value:
- return header_value.split(',')[0].strip().lower()
-
- return 'en'
-
-class AdminLessonSerializer(serializers.ModelSerializer):
- title_i18n = serializers.JSONField(source='title', read_only=True)
- content_type_i18n = serializers.JSONField(source='content_type', read_only=True)
-
- class Meta:
- model = Lesson
- fields = [
- 'id',
- 'title',
- 'title_i18n',
- 'content_type',
- 'content_type_i18n',
- 'content_file',
- 'video_link',
- 'duration',
- 'created_at',
- 'updated_at',
- ]
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- lang = get_request_lang(self)
- ret['title'] = get_localized_field(lang, instance.title)
- ret['content_type'] = get_localized_field(lang, instance.content_type)
- return ret
-
-class AdminGlossarySerializer(serializers.ModelSerializer):
- description_i18n = serializers.JSONField(source='description', read_only=True)
-
- class Meta:
- model = Glossary
- fields = '__all__'
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- lang = get_request_lang(self)
- ret['description'] = get_localized_field(lang, instance.description)
- return ret
-
-class AdminAttachmentSerializer(serializers.ModelSerializer):
- title_i18n = serializers.JSONField(source='title', read_only=True)
-
- class Meta:
- model = Attachment
- fields = '__all__'
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- lang = get_request_lang(self)
- ret['title'] = get_localized_field(lang, instance.title)
- return ret
-
-
-def serialize_file(value, serializer):
- if not value:
- return None
- field = FileFieldSerializer()
- field.bind('', serializer)
- return field.to_representation(value)
-
-class AdminCourseCategorySerializer(serializers.ModelSerializer):
- slug = serializers.JSONField(required=False, allow_null=True)
- name_i18n = serializers.JSONField(source='name', read_only=True)
- slug_i18n = serializers.JSONField(source='slug', read_only=True)
-
- class Meta:
- model = CourseCategory
- fields = ['id', 'name', 'name_i18n', 'slug', 'slug_i18n', 'icon']
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- lang = get_request_lang(self)
- ret['name'] = get_localized_field(lang, instance.name)
- ret['slug'] = get_localized_field(lang, instance.slug)
- icon = ret.get('icon')
- if icon and icon.startswith('/'):
- request = self.context.get('request')
- if request:
- ret['icon'] = request.build_absolute_uri(icon)
- else:
- from django.conf import settings
- ret['icon'] = f"{settings.SITE_DOMAIN}{icon}"
- return ret
-
-class AdminCourseListSerializer(serializers.ModelSerializer):
- category_name = serializers.SerializerMethodField()
- professor_name = serializers.SerializerMethodField(read_only=True)
- professors = serializers.PrimaryKeyRelatedField(queryset=ProfessorUser.objects.filter(is_active=True), many=True, required=False)
- professor = serializers.PrimaryKeyRelatedField(queryset=ProfessorUser.objects.filter(is_active=True), required=False, allow_null=True, write_only=True)
- participant_count = serializers.SerializerMethodField()
- thumbnail = serializers.SerializerMethodField()
-
- class Meta:
- model = Course
- fields = [
- 'id',
- 'title',
- 'slug',
- 'category',
- 'category_name',
- 'professor',
- 'professors',
- 'professor_name',
- 'thumbnail',
- 'is_online',
- 'level',
- 'duration',
- 'lessons_count',
- 'status',
- 'is_free',
- 'price_rub',
- 'discount_percentage',
- 'final_price_rub',
- 'participant_count',
- 'created_at'
- ]
-
- def get_professor_name(self, obj):
- return ", ".join([p.fullname for p in obj.professors.all()])
-
- def get_participant_count(self, obj):
- return obj.participants.count()
-
- def get_thumbnail(self, obj):
- return get_thumbs(obj.thumbnail, self.context.get('request'))
-
- def get_category_name(self, obj):
- if not obj.category:
- return ""
- lang = get_request_lang(self)
- return get_localized_field(lang, obj.category.name)
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- lang = get_request_lang(self)
- ret['title'] = get_localized_field(lang, instance.title)
- ret['slug'] = get_localized_field(lang, instance.slug)
- ret['level'] = get_localized_field(lang, instance.level)
- ret['status'] = get_localized_field(lang, instance.status)
-
- # Inject legacy fields
- first_prof = instance.professors.first()
- ret['professor'] = first_prof.id if first_prof else None
- ret['professor_name'] = first_prof.fullname if first_prof else ""
- ret['professors'] = [p.id for p in instance.professors.all()]
- return ret
-
-
-class AdminCourseDetailSerializer(serializers.ModelSerializer):
- slug = serializers.JSONField(required=False, allow_null=True)
- category_name = serializers.SerializerMethodField()
- professor_name = serializers.SerializerMethodField(read_only=True)
- professors = serializers.PrimaryKeyRelatedField(queryset=ProfessorUser.objects.filter(is_active=True), many=True, required=False)
- professor = serializers.PrimaryKeyRelatedField(queryset=ProfessorUser.objects.filter(is_active=True), required=False, allow_null=True, write_only=True)
- thumbnail = FileFieldSerializer(required=False, allow_null=True)
- video_file = FileFieldSerializer(required=False, allow_null=True)
- participant_count = serializers.SerializerMethodField()
-
- class Meta:
- model = Course
- fields = [
- 'id',
- 'title',
- 'slug',
- 'category',
- 'category_name',
- 'professor',
- 'professors',
- 'professor_name',
- 'thumbnail',
- 'video_type',
- 'video_file',
- 'video_link',
- 'is_online',
- 'online_link',
- 'level',
- 'duration',
- 'lessons_count',
- 'description',
- 'short_description',
- 'status',
- 'is_free',
- 'price_rub',
- 'discount_percentage',
- 'final_price_rub',
- 'is_group_chat_locked',
- 'is_professor_chat_locked',
- 'timing',
- 'features',
- 'participant_count',
- 'created_at',
- 'updated_at'
- ]
- read_only_fields = ['id', 'final_price_rub', 'created_at', 'updated_at']
-
- def get_professor_name(self, obj):
- return ", ".join([p.fullname for p in obj.professors.all()])
-
- def get_participant_count(self, obj):
- return obj.participants.count()
-
- def get_category_name(self, obj):
- if not obj.category:
- return ""
- lang = get_request_lang(self)
- return get_localized_field(lang, obj.category.name)
-
- def create(self, validated_data):
- professor = validated_data.pop('professor', None)
- professors = validated_data.pop('professors', None)
- instance = super().create(validated_data)
- if professors is not None:
- instance.professors.set(professors)
- elif professor is not None:
- instance.professors.set([professor])
- return instance
-
- def update(self, instance, validated_data):
- professor = validated_data.pop('professor', None)
- professors = validated_data.pop('professors', None)
- instance = super().update(instance, validated_data)
- if professors is not None:
- instance.professors.set(professors)
- elif professor is not None:
- instance.professors.set([professor])
- return instance
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- # Do not extract localized field strings for these fields.
- # The admin panel edit form requires the raw JSON arrays to populate multilingual inputs.
- # Inject legacy fields
- first_prof = instance.professors.first()
- ret['professor'] = first_prof.id if first_prof else None
- ret['professor_name'] = first_prof.fullname if first_prof else ""
- ret['professors'] = [p.id for p in instance.professors.all()]
- return ret
-
-
-class AdminCourseChapterSerializer(serializers.ModelSerializer):
- title_i18n = serializers.JSONField(source='title', read_only=True)
-
- class Meta:
- model = CourseChapter
- fields = ['id', 'course', 'title', 'title_i18n', 'priority', 'is_active', 'created_at']
- read_only_fields = ['id', 'created_at']
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- lang = get_request_lang(self)
- ret['title'] = get_localized_field(lang, instance.title)
- return ret
-
-
-class AdminCourseLessonSerializer(serializers.Serializer):
- id = serializers.IntegerField(read_only=True)
- course = serializers.IntegerField(required=False, read_only=True)
- chapter = serializers.IntegerField()
- lesson = serializers.IntegerField(required=False, write_only=True, allow_null=True)
- title = serializers.JSONField(required=False, allow_null=True)
- priority = serializers.IntegerField(required=False, allow_null=True)
- is_active = serializers.BooleanField(default=True)
-
- # Lesson (Base Content) fields
- content_type = serializers.JSONField(required=False, allow_null=True)
- content_file = FileFieldSerializer(required=False, allow_null=True)
- video_link = serializers.CharField(max_length=500, required=False, allow_blank=True, allow_null=True)
- duration = serializers.IntegerField(min_value=0, required=False, default=0)
-
- class Meta:
- ref_name = 'CourseAdminCourseLesson'
-
- def to_representation(self, instance):
- """
- Map a CourseLesson instance to representation fields.
- """
- lesson_obj = instance.lesson
- lang = get_request_lang(self)
-
- raw_title = instance.title or (lesson_obj.title if lesson_obj else [])
- localized_title = get_localized_field(lang, raw_title)
-
- raw_content_type = lesson_obj.content_type if lesson_obj else []
- localized_content_type = get_localized_field(lang, raw_content_type)
-
- return {
- 'id': instance.id,
- 'course': instance.course_id,
- 'chapter': instance.chapter_id,
- 'lesson': instance.lesson_id,
- 'title': localized_title,
- 'title_i18n': raw_title,
- 'priority': instance.priority,
- 'is_active': instance.is_active,
- 'content_type': localized_content_type,
- 'content_type_i18n': raw_content_type,
- 'content_file': serialize_file(lesson_obj.content_file, self) if lesson_obj and lesson_obj.content_file else None,
- 'video_link': lesson_obj.video_link if lesson_obj else "",
- 'duration': lesson_obj.duration if lesson_obj else 0,
- }
-
- def create(self, validated_data):
- chapter_id = validated_data.pop('chapter')
- chapter = CourseChapter.objects.get(pk=chapter_id)
- course = chapter.course
-
- title = validated_data.get('title')
- priority = validated_data.get('priority')
- is_active = validated_data.get('is_active', True)
-
- lesson_id = validated_data.get('lesson')
- if lesson_id:
- try:
- lesson = Lesson.objects.get(pk=lesson_id)
- except Lesson.DoesNotExist:
- raise serializers.ValidationError({"lesson": "Lesson does not exist."})
-
- # Check if already linked to this course
- if CourseLesson.objects.filter(course=course, lesson=lesson).exists():
- raise serializers.ValidationError({"detail": "This lesson is already linked to this course."})
- else:
- content_type = validated_data.get('content_type')
- if not content_type:
- raise serializers.ValidationError({"detail": "Content type is required for new lessons."})
- content_file = validated_data.get('content_file')
- video_link = validated_data.get('video_link')
- duration = validated_data.get('duration', 0)
-
- # 1. Create Lesson Base
- lesson = Lesson.objects.create(
- title=title or "Base Lesson",
- content_type=content_type,
- content_file=content_file,
- video_link=video_link,
- duration=duration
- )
-
- # 2. Create CourseLesson junction
- course_lesson = CourseLesson.objects.create(
- course=course,
- chapter=chapter,
- lesson=lesson,
- title=title,
- priority=priority,
- is_active=is_active
- )
- return course_lesson
-
- def update(self, instance, validated_data):
- chapter_id = validated_data.get('chapter')
- if chapter_id:
- chapter = CourseChapter.objects.get(pk=chapter_id)
- instance.chapter = chapter
- instance.course = chapter.course
-
- if 'title' in validated_data:
- instance.title = validated_data['title']
- if 'priority' in validated_data:
- instance.priority = validated_data['priority']
- if 'is_active' in validated_data:
- instance.is_active = validated_data['is_active']
-
- instance.save()
-
- # Update the Base Lesson as well
- lesson = instance.lesson
- if lesson:
- if 'title' in validated_data and validated_data['title']:
- lesson.title = validated_data['title']
- if 'content_type' in validated_data:
- lesson.content_type = validated_data['content_type']
- if 'content_file' in validated_data:
- lesson.content_file = validated_data['content_file']
- if 'video_link' in validated_data:
- lesson.video_link = validated_data['video_link']
- if 'duration' in validated_data:
- lesson.duration = validated_data['duration']
- lesson.save()
-
- return instance
-
-
-class AdminCourseAttachmentSerializer(serializers.Serializer):
- id = serializers.IntegerField(read_only=True)
- course = serializers.IntegerField()
- attachment = serializers.IntegerField(required=False, write_only=True, allow_null=True)
- title = serializers.JSONField(required=False, allow_null=True)
- file = FileFieldSerializer(required=False, allow_null=True)
- file_size = serializers.IntegerField(read_only=True)
-
- def to_representation(self, instance):
- attachment_obj = instance.attachment
- lang = get_request_lang(self)
- raw_title = attachment_obj.title if attachment_obj else []
- localized_title = get_localized_field(lang, raw_title)
- return {
- 'id': instance.id,
- 'course': instance.course_id,
- 'attachment': instance.attachment_id,
- 'title': localized_title,
- 'title_i18n': raw_title,
- 'file': serialize_file(attachment_obj.file, self) if attachment_obj and attachment_obj.file else None,
- 'file_size': attachment_obj.file_size if attachment_obj else 0
- }
-
- def create(self, validated_data):
- course_id = validated_data.pop('course')
- course = Course.objects.get(pk=course_id)
-
- attachment_id = validated_data.get('attachment')
- if attachment_id:
- try:
- attachment = Attachment.objects.get(pk=attachment_id)
- except Attachment.DoesNotExist:
- raise serializers.ValidationError({"attachment": "Attachment does not exist."})
-
- # Check if already linked
- if CourseAttachment.objects.filter(course=course, attachment=attachment).exists():
- raise serializers.ValidationError({"detail": "This attachment is already linked to this course."})
- else:
- title = validated_data.get('title')
- file_data = validated_data.get('file')
- if not title or not file_data:
- raise serializers.ValidationError({"detail": "Title and file are required for new attachments."})
-
- # 1. Create Base Attachment
- attachment = Attachment.objects.create(
- title=title,
- file=file_data
- )
-
- # 2. Link to Course
- course_attachment = CourseAttachment.objects.create(
- course=course,
- attachment=attachment
- )
- return course_attachment
-
- def update(self, instance, validated_data):
- # We don't change the course in updates, just details of attachment
- attachment = instance.attachment
- if attachment:
- if 'title' in validated_data:
- attachment.title = validated_data['title']
- if 'file' in validated_data:
- attachment.file = validated_data['file']
- # Reset file size to trigger recalculation in save()
- attachment.file_size = None
- attachment.save()
-
- return instance
-
-
-class AdminCourseGlossarySerializer(serializers.Serializer):
- id = serializers.IntegerField(read_only=True)
- course = serializers.IntegerField()
- glossary = serializers.IntegerField(required=False, write_only=True, allow_null=True)
- title = serializers.CharField(max_length=555, required=False, allow_blank=True, allow_null=True)
- description = serializers.JSONField(required=False, allow_null=True)
-
- def to_representation(self, instance):
- glossary_obj = instance.glossary
- lang = get_request_lang(self)
- raw_description = glossary_obj.description if glossary_obj else []
- localized_description = get_localized_field(lang, raw_description)
- return {
- 'id': instance.id,
- 'course': instance.course_id,
- 'glossary': instance.glossary_id,
- 'title': glossary_obj.title if glossary_obj else "",
- 'description': localized_description,
- 'description_i18n': raw_description,
- }
-
- def create(self, validated_data):
- course_id = validated_data.pop('course')
- course = Course.objects.get(pk=course_id)
-
- glossary_id = validated_data.get('glossary')
- if glossary_id:
- try:
- glossary = Glossary.objects.get(pk=glossary_id)
- except Glossary.DoesNotExist:
- raise serializers.ValidationError({"glossary": "Glossary term does not exist."})
-
- # Check if already linked
- if CourseGlossary.objects.filter(course=course, glossary=glossary).exists():
- raise serializers.ValidationError({"detail": "This glossary term is already linked to this course."})
- else:
- title = validated_data.get('title')
- description = validated_data.get('description')
- if not title or not description:
- raise serializers.ValidationError({"detail": "Title and description are required for new glossary terms."})
-
- # 1. Create Base Glossary
- glossary = Glossary.objects.create(
- title=title,
- description=description
- )
-
- # 2. Link to Course
- course_glossary = CourseGlossary.objects.create(
- course=course,
- glossary=glossary
- )
- return course_glossary
-
- def update(self, instance, validated_data):
- glossary = instance.glossary
- if glossary:
- if 'title' in validated_data:
- glossary.title = validated_data['title']
- if 'description' in validated_data:
- glossary.description = validated_data['description']
- glossary.save()
-
- return instance
-
-
-class AdminLiveSessionUserSerializer(serializers.ModelSerializer):
- user_name = serializers.CharField(source='user.fullname', read_only=True)
- user_email = serializers.CharField(source='user.email', read_only=True)
-
- class Meta:
- model = LiveSessionUser
- fields = ['id', 'session', 'user', 'user_name', 'user_email', 'role', 'entered_at', 'exited_at', 'is_online']
-
-
-class AdminLiveSessionRecordingSerializer(serializers.ModelSerializer):
- file = FileFieldSerializer(required=False, allow_null=True)
- thumbnail = FileFieldSerializer(required=False, allow_null=True)
-
- class Meta:
- model = LiveSessionRecording
- fields = ['id', 'session', 'title', 'file', 'file_time', 'recording_type', 'thumbnail', 'is_active', 'created_at']
-
-
-class AdminLiveSessionListSerializer(serializers.ModelSerializer):
- course_title = serializers.SerializerMethodField()
- participant_count = serializers.SerializerMethodField()
- media_count = serializers.IntegerField(read_only=True)
-
- class Meta:
- model = CourseLiveSession
- fields = ['id', 'course', 'course_title', 'room_id', 'subject', 'started_at', 'ended_at', 'participant_count', 'media_count', 'created_at']
-
- def get_course_title(self, obj):
- if not obj.course:
- return ""
- lang = get_request_lang(self)
- return get_localized_field(lang, obj.course.title)
-
- def get_participant_count(self, obj):
- return obj.user_sessions.count()
-
-
-class AdminLiveSessionDetailSerializer(serializers.ModelSerializer):
- course_title = serializers.SerializerMethodField()
- user_sessions = serializers.SerializerMethodField()
- recordings = AdminLiveSessionRecordingSerializer(many=True, read_only=True)
- recorded_file = FileFieldSerializer(required=False, allow_null=True)
-
- class Meta:
- model = CourseLiveSession
- fields = ['id', 'course', 'course_title', 'room_id', 'subject', 'started_at', 'ended_at', 'recorded_file', 'user_sessions', 'recordings', 'created_at', 'updated_at']
-
- def get_course_title(self, obj):
- if not obj.course:
- return ""
- lang = get_request_lang(self)
- return get_localized_field(lang, obj.course.title)
-
- def get_user_sessions(self, obj):
- # 1. Get all active participants of this course
- participants = obj.course.participants.filter(is_active=True).select_related('student')
-
- # 2. Get all live session user records for this session
- attendance_map = {
- ls_user.user_id: ls_user
- for ls_user in obj.user_sessions.all().select_related('user')
- }
-
- # 3. Build the combined list
- data = []
- for p in participants:
- student = p.student
- # Check if this student has an attendance record
- attendance = attendance_map.get(student.id)
-
- if attendance:
- data.append({
- 'id': attendance.id,
- 'session': obj.id,
- 'user': student.id,
- 'user_name': student.fullname or student.username,
- 'user_email': student.email,
- 'role': attendance.role,
- 'entered_at': attendance.entered_at.isoformat() if attendance.entered_at else None,
- 'exited_at': attendance.exited_at.isoformat() if attendance.exited_at else None,
- 'is_online': attendance.is_online,
- 'has_attended': True
- })
- else:
- data.append({
- 'id': None,
- 'session': obj.id,
- 'user': student.id,
- 'user_name': student.fullname or student.username,
- 'user_email': student.email,
- 'role': 'participant',
- 'entered_at': None,
- 'exited_at': None,
- 'is_online': False,
- 'has_attended': False
- })
-
- # Also append any attendees who are NOT in the participants list (e.g. professor/moderator/observer)
- participant_student_ids = {p.student_id for p in participants}
- for user_id, attendance in attendance_map.items():
- if user_id not in participant_student_ids:
- data.append({
- 'id': attendance.id,
- 'session': obj.id,
- 'user': user_id,
- 'user_name': attendance.user.fullname or attendance.user.username,
- 'user_email': attendance.user.email,
- 'role': attendance.role,
- 'entered_at': attendance.entered_at.isoformat() if attendance.entered_at else None,
- 'exited_at': attendance.exited_at.isoformat() if attendance.exited_at else None,
- 'is_online': attendance.is_online,
- 'has_attended': True
- })
-
- return data
diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py
deleted file mode 100644
index 9ff38b2..0000000
--- a/apps/course/serializers/course.py
+++ /dev/null
@@ -1,729 +0,0 @@
-from rest_framework import serializers
-# from dj_filer.admin import get_thumbs
-from utils import get_thumbs
-from apps.course.models import Course, CourseCategory, Attachment, Glossary, LessonCompletion, Participant, Lesson, CourseAttachment, CourseGlossary, CourseLesson
-from apps.course.models.course import (
- get_localized_field,
- get_weekday_label,
- normalize_level,
- normalize_status,
- normalize_weekday_to_key,
-)
-from apps.chat.models import RoomMessage
-from apps.account.serializers import UserProfileSerializer
-from decimal import Decimal
-import ast
-import json
-
-
-def resolve_request_lang(request, default='en'):
- language_code = getattr(request, 'LANGUAGE_CODE', None)
- if language_code:
- return str(language_code).lower()
-
- if not request:
- return default
-
- header_value = ''
- if hasattr(request, 'headers'):
- header_value = request.headers.get('Accept-Language', '')
- if not header_value and hasattr(request, 'META'):
- header_value = request.META.get('HTTP_ACCEPT_LANGUAGE', '')
-
- if header_value:
- return header_value.split(',')[0].strip().lower()
-
- return default
-
-
-
-class CourseCategorySerializer(serializers.ModelSerializer):
- course_count = serializers.SerializerMethodField()
- name = serializers.SerializerMethodField()
- slug = serializers.SerializerMethodField()
-
- class Meta:
- model = CourseCategory
- fields = ['id', 'name', 'slug', 'icon', 'course_count']
-
- def _lang(self):
- request = self.context.get('request')
- return resolve_request_lang(request)
-
- def get_name(self, obj):
- return get_localized_field(self._lang(), obj.name)
-
- def get_slug(self, obj):
- return get_localized_field(self._lang(), obj.slug)
-
- def get_course_count(self, obj):
- return obj.course_count
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- icon = ret.get('icon')
- if icon and icon.startswith('/'):
- request = self.context.get('request')
- if request:
- ret['icon'] = request.build_absolute_uri(icon)
- else:
- from django.conf import settings
- ret['icon'] = f"{settings.SITE_DOMAIN}{icon}"
- return ret
-
-
-class CourseListSerializer(serializers.ModelSerializer):
- category = CourseCategorySerializer()
- thumbnail = serializers.SerializerMethodField()
- participant_count = serializers.SerializerMethodField()
- lessons_count = serializers.SerializerMethodField()
- price = serializers.SerializerMethodField()
- discount_percentage = serializers.SerializerMethodField()
- final_price = serializers.SerializerMethodField()
- currency = serializers.SerializerMethodField()
- is_free = serializers.SerializerMethodField()
- title = serializers.SerializerMethodField()
- slug = serializers.SerializerMethodField()
- level = serializers.SerializerMethodField()
- status = serializers.SerializerMethodField()
- short_description = serializers.SerializerMethodField()
-
- class Meta:
- model = Course
- fields = [
- 'id',
- 'title',
- 'slug',
- 'participant_count',
- 'category',
- 'thumbnail',
- 'is_online',
- 'online_link',
- 'level',
- 'duration',
- 'lessons_count',
- 'short_description',
- 'status',
- 'is_free',
- 'price',
- 'discount_percentage',
- 'final_price',
- 'currency',
- ]
-
- def _lang(self):
- request = self.context.get('request')
- return resolve_request_lang(request)
-
- def get_title(self, obj):
- return get_localized_field(self._lang(), obj.title)
-
- def get_slug(self, obj):
- return get_localized_field(self._lang(), obj.slug)
-
- def get_level(self, obj):
- return normalize_level(obj.level)
-
- def get_status(self, obj):
- return normalize_status(obj.status)
-
- def get_short_description(self, obj):
- return get_localized_field(self._lang(), obj.short_description)
-
- def get_thumbnail(self, obj):
- return get_thumbs(obj.thumbnail, self.context.get('request'))
-
- def get_participant_count(self, obj):
- return obj.participants.count()
-
- def get_lessons_count(self, obj):
- # Use prefetched chapters and lessons if available
- if hasattr(obj, 'chapters') and obj.chapters.all():
- lessons_count = 0
- for chapter in obj.chapters.all():
- if chapter.is_active:
- lessons_count += sum(1 for lesson in chapter.lessons.all() if lesson.is_active)
- return max(lessons_count, obj.lessons_count)
-
- # Fallback to direct query
- from apps.course.models.lesson import CourseLesson
- lessons_count = CourseLesson.objects.filter(chapter__course=obj, is_active=True, chapter__is_active=True).count()
- return max(lessons_count, obj.lessons_count)
-
- def get_price(self, obj):
- amount = obj.get_price_for_language(self._lang())
- if obj.is_free or amount == 0:
- return "0.00"
- return str(amount)
-
- def get_discount_percentage(self, obj):
- if obj.is_free or obj.get_price_for_language(self._lang()) == 0:
- return 0
- return obj.discount_percentage
-
- def get_final_price(self, obj):
- amount = obj.get_final_price_for_language(self._lang())
- if obj.is_free or amount == 0:
- return "0.00"
- return str(amount)
-
- def get_currency(self, obj):
- return obj.get_currency_for_language(self._lang())
-
- def get_is_free(self, obj):
- return obj.is_free or (Decimal(obj.price or 0) == 0 and Decimal(obj.price_rub or 0) == 0)
-
-
-class CourseDetailSerializer(serializers.ModelSerializer):
- category = CourseCategorySerializer()
- professors = serializers.SerializerMethodField()
- professor = serializers.SerializerMethodField()
- thumbnail = serializers.SerializerMethodField()
- participant_count = serializers.SerializerMethodField()
- access = serializers.SerializerMethodField()
- lessons_complated_count = serializers.SerializerMethodField()
- lessons_count = serializers.SerializerMethodField()
- last_lesson_id = serializers.SerializerMethodField()
- room_id = serializers.SerializerMethodField()
- user_transaction_status = serializers.SerializerMethodField()
- price = serializers.SerializerMethodField()
- discount_percentage = serializers.SerializerMethodField()
- final_price = serializers.SerializerMethodField()
- currency = serializers.SerializerMethodField()
- is_free = serializers.SerializerMethodField()
- is_professor = serializers.SerializerMethodField()
- title = serializers.SerializerMethodField()
- slug = serializers.SerializerMethodField()
- level = serializers.SerializerMethodField()
- status = serializers.SerializerMethodField()
- timing = serializers.SerializerMethodField()
- features = serializers.SerializerMethodField()
- description = serializers.SerializerMethodField()
- short_description = serializers.SerializerMethodField()
- share_link = serializers.SerializerMethodField()
-
- class Meta:
- model = Course
- fields = [
- 'id',
- 'title',
- 'slug',
- 'category',
- 'access',
- 'participant_count',
- 'professors',
- 'professor',
- 'is_professor',
- 'thumbnail',
- 'video_type',
- 'video_file',
- 'video_link',
- 'is_online',
- 'online_link',
- 'level',
- 'description',
- 'duration',
- 'lessons_count',
- 'lessons_complated_count',
- 'short_description',
- 'status',
- 'is_free',
- 'price',
- 'discount_percentage',
- 'final_price',
- 'currency',
- 'timing',
- 'features',
- 'last_lesson_id',
- 'room_id',
- 'user_transaction_status',
- 'is_group_chat_locked',
- 'is_professor_chat_locked',
- 'share_link'
- ]
-
- def get_room_id(self, obj):
- # Use prefetched room_messages if available
- if hasattr(obj, 'room_messages') and obj.room_messages.all():
- return obj.room_messages.first().id
- # Fallback to direct query if not prefetched
- room_message = RoomMessage.objects.filter(course=obj).first()
- if room_message:
- return room_message.id
- return None
-
- def get_user_transaction_status(self, obj):
- from apps.transaction.models import TransactionParticipant
- if student := self._get_authenticated_user():
- latest_transaction = TransactionParticipant.objects.filter(
- user=student,
- course=obj,
- is_deleted=False
- ).order_by('-created_at').first()
- if latest_transaction:
- return latest_transaction.status
- return None
-
- def get_last_lesson_id(self, obj):
- request = self.context.get('request')
- if request and request.user.is_authenticated:
- user = request.user
-
- # Gather all active lessons across all active chapters
- all_active_lessons = []
- if hasattr(obj, 'chapters') and obj.chapters.all():
- for chapter in obj.chapters.all():
- if chapter.is_active:
- all_active_lessons.extend([l for l in chapter.lessons.all() if l.is_active])
-
- # Sort them by chapter priority first, then lesson priority
- all_active_lessons.sort(key=lambda x: (x.chapter.priority, x.priority))
-
- completed_lessons = []
- for lesson in all_active_lessons:
- if hasattr(lesson, 'completions') and lesson.completions.all():
- if any(completion.student_id == user.id for completion in lesson.completions.all()):
- completed_lessons.append(lesson)
-
- if completed_lessons:
- # Find the last completed lesson
- last_completed = completed_lessons[-1]
-
- # Find next lesson
- # We look for lessons that come AFTER the last completed one in our sorted list
- last_completed_index = all_active_lessons.index(last_completed)
- if last_completed_index + 1 < len(all_active_lessons):
- return all_active_lessons[last_completed_index + 1].id
-
- # If no completed lessons or no next lesson, return first lesson
- if all_active_lessons:
- return all_active_lessons[0].id
-
- # Fallback to direct queries if not prefetched
- last_completed_lesson = LessonCompletion.objects.filter(
- student=user,
- course_lesson__chapter__course=obj,
- course_lesson__is_active=True,
- course_lesson__chapter__is_active=True
- ).order_by('-completed_at').first()
-
- from apps.course.models.lesson import CourseLesson
-
- if last_completed_lesson:
- cl = last_completed_lesson.course_lesson
- # Try to find next lesson in same chapter
- next_lesson = CourseLesson.objects.filter(
- chapter=cl.chapter,
- priority__gt=cl.priority,
- is_active=True
- ).order_by('priority').first()
-
- # If no more lessons in this chapter, find first lesson in next chapter
- if not next_lesson:
- next_lesson = CourseLesson.objects.filter(
- chapter__course=obj,
- chapter__priority__gt=cl.chapter.priority,
- chapter__is_active=True,
- is_active=True
- ).order_by('chapter__priority', 'priority').first()
-
- if next_lesson:
- return next_lesson.id
- else:
- # No completions at all, just get the very first lesson
- first_lesson = CourseLesson.objects.filter(
- chapter__course=obj,
- chapter__is_active=True,
- is_active=True
- ).order_by('chapter__priority', 'priority').first()
- if first_lesson:
- return first_lesson.id
-
- return None
-
-
-
- def get_access(self, obj):
- if user := self._get_authenticated_user():
- return self._has_access(user, obj)
- return False
-
- def get_professors(self, obj):
- """Return the course professor's profile using UserProfileSerializer"""
- return UserProfileSerializer(obj.professors.all(), many=True, context=self.context).data
-
- def get_is_professor(self, obj):
- if user := self._get_authenticated_user():
- # چک کردن اینکه آیا کاربر در لیست اساتید این دوره هست یا نه
- return obj.professors.filter(id=user.id).exists()
- return False
-
- def get_professor(self, obj):
- """Return the first professor's profile using UserProfileSerializer"""
- first_prof = obj.professors.first()
- if not first_prof:
- return None
- return UserProfileSerializer(first_prof, context=self.context).data
-
- def get_lessons_count(self, obj):
- if hasattr(obj, 'chapters') and obj.chapters.all():
- lessons_count = 0
- for chapter in obj.chapters.all():
- if chapter.is_active:
- lessons_count += sum(1 for lesson in chapter.lessons.all() if lesson.is_active)
- return max(lessons_count, obj.lessons_count)
-
- from apps.course.models.lesson import CourseLesson
- lessons_count = CourseLesson.objects.filter(chapter__course=obj, is_active=True, chapter__is_active=True).count()
- return max(lessons_count, obj.lessons_count)
-
-
- def get_lessons_complated_count(self, obj):
- if student := self._get_authenticated_user():
- if not self._is_participant(student, obj):
- return None
- completed_count = self._get_completed_lessons_count(student, obj)
- # Ensure completed count doesn't exceed total lessons count
- total_lessons = self.get_lessons_count(obj)
- return min(completed_count, total_lessons)
- return None
-
- def _has_access(self, user, course):
- """
- Check if the user has access to the course content.
- Access is granted if:
- 1. User is the professor of the course.
- 2. User is staff (admin).
- 3. User is an active participant in the course.
- """
- if user.is_staff or user.is_superuser:
- return True
-
- if course.professors.filter(id=user.id).exists():
- return True
-
- return Participant.objects.filter(
- student_id=user.id,
- course=course,
- is_active=True
- ).exists()
-
- def _is_participant(self, student, course):
- """Deprecated: use _has_access instead. Kept for backward compatibility if needed."""
- return self._has_access(student, course)
-
- def _get_authenticated_user(self):
- """Helper method to retrieve the authenticated user from the context."""
- request = self.context.get('request')
- return request.user if request and request.user.is_authenticated else None
-
- def _get_completed_lessons_count(self, student, course):
- if hasattr(course, 'chapters') and course.chapters.all():
- completed_count = 0
- for chapter in course.chapters.all():
- if chapter.is_active:
- for lesson in chapter.lessons.all():
- if lesson.is_active and hasattr(lesson, 'completions') and lesson.completions.all():
- if any(completion.student_id == student.id for completion in lesson.completions.all()):
- completed_count += 1
- return completed_count
-
- return LessonCompletion.objects.filter(
- student=student,
- course_lesson__chapter__course=course,
- course_lesson__is_active=True,
- course_lesson__chapter__is_active=True
- ).count()
-
-
- def get_thumbnail(self, obj):
- return get_thumbs(obj.thumbnail, self.context.get('request'))
-
- def get_participant_count(self, obj):
- # Use prefetched participants if available
- if hasattr(obj, 'participants') and obj.participants.all():
- return len(obj.participants.all())
- # Fallback to direct query
- return obj.participants.count()
-
- def get_price(self, obj):
- amount = obj.get_price_for_language(self._lang())
- if obj.is_free or amount == 0:
- return "0.00"
- return str(amount)
-
- def get_discount_percentage(self, obj):
- if obj.is_free or obj.get_price_for_language(self._lang()) == 0:
- return 0
- return obj.discount_percentage
-
- def get_final_price(self, obj):
- amount = obj.get_final_price_for_language(self._lang())
- if obj.is_free or amount == 0:
- return "0.00"
- return str(amount)
- def get_currency(self, obj):
- return obj.get_currency_for_language(self._lang())
- def get_is_free(self, obj):
- return obj.is_free or (Decimal(obj.price or 0) == 0 and Decimal(obj.price_rub or 0) == 0)
-
- def _lang(self):
- request = self.context.get('request')
- return resolve_request_lang(request)
-
- def get_title(self, obj):
- return get_localized_field(self._lang(), obj.title)
-
- def get_slug(self, obj):
- return get_localized_field(self._lang(), obj.slug)
-
- def get_level(self, obj):
- return normalize_level(obj.level)
-
- def get_status(self, obj):
- return normalize_status(obj.status)
-
- def get_description(self, obj):
- return get_localized_field(self._lang(), obj.description)
-
- def get_short_description(self, obj):
- return get_localized_field(self._lang(), obj.short_description)
-
- def _parse_builder_payload(self, value):
- if value is None:
- return None
- if isinstance(value, str):
- for parser in (json.loads, ast.literal_eval):
- try:
- parsed = parser(value)
- return parsed
- except Exception:
- continue
- return value
- return value
-
- def _extract_builder_list(self, value):
- """
- Return the raw builder list for the current language.
- Supports both:
- - direct lists like [{'day': 'monday', 'timing': '12:22'}, ...]
- - multilingual wrappers like [{'language_code': 'en', 'title': "[...]"}, ...]
- """
- parsed = self._parse_builder_payload(value)
- if parsed is None:
- return []
-
- if isinstance(parsed, list) and parsed:
- if all(isinstance(item, dict) and 'language_code' in item for item in parsed):
- current_lang = self._lang()
- selected = next(
- (
- item for item in parsed
- if isinstance(item, dict) and item.get('language_code') == current_lang
- ),
- parsed[0],
- )
- parsed = self._parse_builder_payload(
- selected.get('title')
- or selected.get('text')
- or selected.get('value')
- or selected.get('name')
- or []
- )
-
- if isinstance(parsed, list):
- return parsed
- if isinstance(parsed, dict):
- if isinstance(parsed.get('list'), list):
- return parsed['list']
- if isinstance(parsed.get('items'), list):
- return parsed['items']
- return []
-
- def get_timing(self, obj):
- localized_rows = []
- for item in self._extract_builder_list(obj.timing):
- if not isinstance(item, dict):
- continue
- day_key = normalize_weekday_to_key(item.get('day'))
- localized_rows.append({
- "day": get_weekday_label(day_key, self._lang()),
- "time": item.get('time') or item.get('timing') or "",
- })
- return localized_rows
-
- def get_features(self, obj):
- return self._extract_builder_list(obj.features)
-
- def get_share_link(self, obj):
- from django.conf import settings
- course_slug = self.get_slug(obj)
- domain = getattr(settings, 'SITE_DOMAIN', '').rstrip('/')
- return f"{domain}/courses/{course_slug}"
-
-
-
-
-class MyCourseListSerializer(serializers.ModelSerializer):
- category = CourseCategorySerializer()
- thumbnail = serializers.SerializerMethodField()
- lessons_count = serializers.SerializerMethodField()
- lessons_complated_count = serializers.SerializerMethodField()
- title = serializers.SerializerMethodField()
- slug = serializers.SerializerMethodField()
- status = serializers.SerializerMethodField()
- short_description = serializers.SerializerMethodField()
-
- class Meta:
- model = Course
- fields = [
- 'id',
- 'title',
- 'slug',
- 'category',
- 'thumbnail',
- 'lessons_count',
- 'lessons_complated_count',
- 'short_description',
- 'status',
- ]
-
- def get_thumbnail(self, obj):
- return get_thumbs(obj.thumbnail, self.context.get('request'))
-
- def get_lessons_count(self, obj):
- """Get the actual count of active lessons"""
- # Use prefetched lessons if available
- if hasattr(obj, 'lessons') and obj.lessons.all():
- lessons_count = sum(1 for lesson in obj.lessons.all() if lesson.is_active)
- return max(lessons_count, obj.lessons_count)
- # Fallback to direct query
- lessons_count = obj.lessons.filter(is_active=True).count()
- return max(lessons_count, obj.lessons_count)
-
- def get_lessons_complated_count(self, obj):
- if student := self._get_authenticated_user():
- if not self._is_participant(student, obj):
- return None
- completed_count = self._get_completed_lessons_count(student, obj)
- # Ensure completed count doesn't exceed total lessons count
- total_lessons = self.get_lessons_count(obj)
- return min(completed_count, total_lessons)
- return None
-
- def _is_participant(self, student, course):
- """Helper method to check if a student is a participant in the given course."""
- if student.is_staff or student.is_superuser:
- return True
-
- # اگر کاربر استاد دوره است، دسترسی کامل دارد
- if course.professors.filter(id=student.id).exists():
- return True
-
- # در غیر این صورت چک میکنیم که آیا participant است یا خیر
- return Participant.objects.filter(
- student_id=student.id,
- course=course,
- is_active=True
- ).exists()
-
- def _get_authenticated_user(self):
- """Helper method to retrieve the authenticated user from the context."""
- request = self.context.get('request')
- return request.user if request and request.user.is_authenticated else None
-
- def _get_completed_lessons_count(self, student, course):
- """Helper method to count completed lessons for the student in the given course."""
- # Use prefetched completions if available
- if hasattr(course, 'lessons') and course.lessons.all():
- completed_count = 0
- for lesson in course.lessons.all():
- if hasattr(lesson, 'completions') and lesson.completions.all():
- if any(completion.student_id == student.id for completion in lesson.completions.all()):
- completed_count += 1
- return completed_count
-
- # Fallback to direct query if not prefetched
- return LessonCompletion.objects.filter(
- student=student,
- course_lesson__course=course
- ).count()
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_title(self, obj):
- return get_localized_field(self._lang(), obj.title)
-
- def get_slug(self, obj):
- return get_localized_field(self._lang(), obj.slug)
-
- def get_status(self, obj):
- return normalize_status(obj.status)
-
- def get_short_description(self, obj):
- return get_localized_field(self._lang(), obj.short_description)
-
-
-class AttachmentSerializer(serializers.ModelSerializer):
- title = serializers.SerializerMethodField()
-
- class Meta:
- model = Attachment
- fields = ['id', 'title', 'file', 'file_size']
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_title(self, obj):
- return get_localized_field(self._lang(), obj.title)
-
-
-class CourseAttachmentSerializer(serializers.ModelSerializer):
- title = serializers.SerializerMethodField()
- file = serializers.FileField(source='attachment.file', read_only=True)
- file_size = serializers.IntegerField(source='attachment.file_size', read_only=True)
-
- class Meta:
- model = CourseAttachment
- fields = ['id', 'title', 'file', 'file_size']
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_title(self, obj):
- return get_localized_field(self._lang(), obj.attachment.title)
-
-
-class GlossarySerializer(serializers.ModelSerializer):
- description = serializers.SerializerMethodField()
-
- class Meta:
- model = Glossary
- fields = ['id', 'title', 'description']
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_description(self, obj):
- return get_localized_field(self._lang(), obj.description)
-
-
-class CourseGlossarySerializer(serializers.ModelSerializer):
- title = serializers.CharField(source='glossary.title', read_only=True)
- description = serializers.SerializerMethodField()
-
- class Meta:
- model = CourseGlossary
- fields = ['id', 'title', 'description']
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_description(self, obj):
- return get_localized_field(self._lang(), obj.glossary.description)
diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py
deleted file mode 100644
index 18615d9..0000000
--- a/apps/course/serializers/lesson.py
+++ /dev/null
@@ -1,176 +0,0 @@
-from rest_framework import serializers
-from django.db.models import Q
-from apps.course.models import Lesson, CourseLesson, Participant, LessonCompletion
-from apps.course.access import user_has_course_access
-from apps.quiz.serializers import QuizListSerializer
-from apps.course.models.course import get_localized_field
-
-
-class LessonSerializer(serializers.ModelSerializer):
- title = serializers.SerializerMethodField()
- content_type = serializers.SerializerMethodField()
-
- class Meta:
- model = Lesson
- fields = ['id', 'title', 'content_type', 'content_file', 'video_link', 'duration']
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_title(self, obj):
- return get_localized_field(self._lang(), obj.title)
-
- def get_content_type(self, obj):
- return get_localized_field(self._lang(), obj.content_type)
-
-
-class CourseLessonSerializer(serializers.ModelSerializer):
- is_active = serializers.SerializerMethodField()
- is_complated = serializers.SerializerMethodField()
- quizs = serializers.SerializerMethodField()
- permission = serializers.SerializerMethodField()
- title = serializers.SerializerMethodField()
- content_type = serializers.SerializerMethodField()
- content_file = serializers.FileField(source='lesson.content_file', read_only=True)
- video_link = serializers.CharField(source='lesson.video_link', read_only=True)
- duration = serializers.IntegerField(source='lesson.duration', read_only=True)
-
- class Meta:
- model = CourseLesson
- fields = ['id', 'title', 'priority', 'is_active', 'permission', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs']
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_title(self, obj):
- return get_localized_field(self._lang(), obj.title)
-
- def get_content_type(self, obj):
- return get_localized_field(self._lang(), obj.lesson.content_type)
-
- def get_is_active(self, obj):
- return obj.is_active and self._has_access_for_object(obj)
-
- def get_permission(self, obj):
- return self._has_access_for_object(obj)
-
- def _has_access_for_object(self, obj):
- if user := self._get_authenticated_user():
- return self._has_access(user, obj.course)
- return False
-
- def _has_access(self, user, course):
- return user_has_course_access(user, course)
-
- def _is_participant(self, student, course):
- """Deprecated: use _has_access instead."""
- return self._has_access(student, course)
-
- def _get_authenticated_user(self):
- """Helper method to retrieve the authenticated user from the context."""
- request = self.context.get('request')
- return request.user if request and request.user.is_authenticated else None
-
- def get_is_complated(self, obj):
- request = self.context.get('request')
- if not request or not request.user.is_authenticated:
- return False
- user = request.user
-
- if not self._has_access(user, obj.course):
- return False
-
- # Use prefetched completions if available
- if hasattr(obj, 'completions') and obj.completions.all():
- return any(completion.student_id == user.id for completion in obj.completions.all())
-
- # Fallback to direct queries
- is_participant = Participant.objects.filter(
- student=user,
- course=obj.course
- ).exists()
-
- if not is_participant:
- return False
-
- return LessonCompletion.objects.filter(
- student=user,
- course_lesson=obj
- ).exists()
-
- def get_quizs(self, obj):
- context_key = f'course_lessons_{obj.course_id}'
- if context_key not in self.context:
- lessons_list = list(CourseLesson.objects.filter(
- course_id=obj.course_id,
- is_active=True
- ).filter(
- Q(chapter__isnull=True) | Q(chapter__is_active=True)
- ).order_by('chapter__priority', 'priority'))
- self.context[context_key] = lessons_list
- else:
- lessons_list = self.context[context_key]
-
- try:
- pos = lessons_list.index(obj) + 1
- except ValueError:
- pos = None
-
- quizzes_key = f'course_quizzes_{obj.course_id}'
- if quizzes_key not in self.context:
- from apps.quiz.models import Quiz
- all_quizzes = list(Quiz.objects.filter(course_id=obj.course_id, status=True))
- self.context[quizzes_key] = all_quizzes
- else:
- all_quizzes = self.context[quizzes_key]
-
- matching_quizzes = []
- for quiz in all_quizzes:
- if (quiz.lesson_id == obj.id) or (pos is not None and quiz.lesson_number == pos):
- matching_quizzes.append(quiz)
-
- if matching_quizzes:
- return QuizListSerializer(matching_quizzes, many=True, context=self.context).data
- return None
-
-
-from apps.course.models import CourseChapter
-
-# 👇 ADD V2 SERIALIZERS
-
-class CourseChapterSerializer(serializers.ModelSerializer):
- """
- V2 API: Returns a Chapter and a nested list of all its active lessons
- """
- is_active = serializers.SerializerMethodField()
- lessons = serializers.SerializerMethodField()
- title = serializers.SerializerMethodField()
-
- class Meta:
- model = CourseChapter
- fields = ['id', 'title', 'priority', 'is_active', 'lessons']
-
- def _lang(self):
- request = self.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
- def get_title(self, obj):
- return get_localized_field(self._lang(), obj.title)
-
- def get_is_active(self, obj):
- if not obj.is_active:
- return False
-
- request = self.context.get('request')
- if not request or not request.user.is_authenticated:
- return False
-
- return user_has_course_access(request.user, obj.course)
-
- def get_lessons(self, obj):
- # Grab all active lessons tied to this specific chapter
- chapter_lessons = obj.lessons.filter(is_active=True).order_by('priority')
- # Re-use the V1 CourseLessonSerializer to serialize the nested items
- return CourseLessonSerializer(chapter_lessons, many=True, context=self.context).data
diff --git a/apps/course/serializers/online.py b/apps/course/serializers/online.py
deleted file mode 100644
index 8893174..0000000
--- a/apps/course/serializers/online.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from rest_framework import serializers
-from utils import FileFieldSerializer
-
-
-class OnlineClassTokenCreateSerializer(serializers.Serializer):
- redirect_path = serializers.CharField(required=False)
-
- def validate_redirect_path(self, value: str) -> str:
- value = value.strip()
- if value and value.startswith("http"):
- raise serializers.ValidationError("Redirect path must be relative to the frontend domain.")
- return value
-
-
-class OnlineClassTokenVerifySerializer(serializers.Serializer):
- token = serializers.CharField(max_length=128)
-
- def validate_token(self, value: str) -> str:
- value = value.strip()
- if not value:
- raise serializers.ValidationError("Token is required.")
- return value
-
-
-class LiveSessionRoomCreateSerializer(serializers.Serializer):
- room_id = serializers.CharField(required=False, max_length=255, allow_blank=True)
- subject = serializers.CharField(required=False, max_length=255, allow_blank=True)
-
- def validate_room_id(self, value: str) -> str:
- return value.strip()
-
- def validate_subject(self, value: str) -> str:
- return value.strip()
-
-
-class LiveSessionTokenSerializer(serializers.Serializer):
- course_slug = serializers.CharField(max_length=255)
-
- def validate_course_slug(self, value: str) -> str:
- value = value.strip()
- if not value:
- raise serializers.ValidationError("course_slug is required.")
- return value
-
-
-class LiveSessionRecordingControlSerializer(serializers.Serializer):
- action = serializers.ChoiceField(choices=["start", "stop"], required=True)
-
-
-class LiveSessionRecordedFileSerializer(serializers.Serializer):
- recorded_file = serializers.FileField(required=True)
-
- def validate_recorded_file(self, value):
- if not value:
- raise serializers.ValidationError("recorded_file is required.")
- return value
-
-
-class LiveSessionRecordingSerializer(serializers.Serializer):
- recorded_file = FileFieldSerializer(required=True, source='file')
- title = serializers.CharField(required=False, max_length=255, allow_blank=True)
- recording_type = serializers.ChoiceField(choices=['voice', 'video'], required=False, default='video')
- file_time = serializers.DurationField(required=False, allow_null=True)
-
- def validate_recorded_file(self, value):
- if not value:
- raise serializers.ValidationError("recorded_file is required.")
- return value
-
- def validate_title(self, value):
- return value.strip() if value else None
diff --git a/apps/course/serializers/participant.py b/apps/course/serializers/participant.py
deleted file mode 100644
index 8a00bc2..0000000
--- a/apps/course/serializers/participant.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from rest_framework import serializers
-
-
-from apps.course.models import Lesson, Participant, LessonCompletion
-from apps.account.models import StudentUser, User
-
-
-
-
-class ParticipantSerializer(serializers.ModelSerializer):
- email = serializers.EmailField(required=True)
- gender = serializers.ChoiceField(choices=User.GenderChoices.choices, required=True)
-
-
- class Meta:
- model = StudentUser
- fields = ['fullname' , 'phone_number', 'gender', 'email', 'birthdate']
diff --git a/apps/course/serializers/professor.py b/apps/course/serializers/professor.py
deleted file mode 100644
index b0bea9b..0000000
--- a/apps/course/serializers/professor.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from django.contrib.auth import get_user_model
-from rest_framework import serializers
-
-from apps.account.serializers import UserProfileSerializer
-from utils import FileFieldSerializer, absolute_url
-
-
-User = get_user_model()
-
-
-class ProfessorListSerializer(serializers.ModelSerializer):
- course_count = serializers.IntegerField(read_only=True)
- lesson_count = serializers.IntegerField(read_only=True)
- avatar = FileFieldSerializer(required=False)
-
- class Meta:
- model = User
- fields = ['id', 'slug', 'fullname', 'email', 'avatar', 'experience_years', 'course_count', 'lesson_count']
-
-
-class ProfessorDetailSerializer(UserProfileSerializer):
- course_count = serializers.IntegerField(read_only=True)
- lesson_count = serializers.IntegerField(read_only=True)
- experience_years = serializers.IntegerField(read_only=True)
- slug = serializers.CharField(read_only=True)
-
- class Meta(UserProfileSerializer.Meta):
- fields = UserProfileSerializer.Meta.fields + ['slug', 'experience_years', 'course_count', 'lesson_count']
- read_only_fields = list(set(UserProfileSerializer.Meta.read_only_fields + ['slug', 'experience_years', 'course_count', 'lesson_count']))
diff --git a/apps/course/services/__init__.py b/apps/course/services/__init__.py
deleted file mode 100644
index cbecc3f..0000000
--- a/apps/course/services/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from .plugnmeet import PlugNMeetClient, PlugNMeetError
-
-__all__ = [
- 'PlugNMeetClient',
- 'PlugNMeetError',
- 'WebEgressClient',
- 'WebEgressError',
-]
diff --git a/apps/course/services/api.md b/apps/course/services/api.md
deleted file mode 100644
index 53b2c6b..0000000
--- a/apps/course/services/api.md
+++ /dev/null
@@ -1,1660 +0,0 @@
-# 📚 Plugnmeet Server API Documentation
-
-> **مستندات کامل API سرور کنفرانس ویدیویی Plugnmeet**
-
-این مستند راهنمای کامل API های Plugnmeet Server را شامل میشود که بر پایه LiveKit ساخته شده است.
-
----
-
-## 📖 فهرست مطالب
-
-### 🚀 شروع سریع
-- [بررسی فعال بودن روم](#-quick-check-room-status)
-- [قابلیتهای کلیدی](#-core-features)
-
-### 🔐 احراز هویت و امنیت
-- [نحوه احراز هویت](#-authentication)
- - [روش `/auth` (HMAC + JSON)](#1-auth-endpoints-hmac--json)
- - [روش `/api` (Bearer Token + Protobuf)](#2-api-endpoints-bearer-token--protobuf)
- - [روشهای خاص (LTI & BBB)](#3-special-authentication-methods)
-
-### 🎯 API Reference
-- [**Room Management** - مدیریت اتاقها](#-room-management-api)
-- [**Recording Management** - مدیریت ضبطها](#-recording-management-api)
-- [**Analytics** - آنالیتیکس و گزارشگیری](#-analytics-api)
-- [**In-Meeting Controls** - کنترلهای داخل جلسه](#-in-meeting-controls-api)
-- [**Advanced Features** - امکانات پیشرفته](#-advanced-features)
-
-### 🔧 سایر سرویسها
-- [Webhook, Health Check, Downloads](#-other-services)
-- [BBB & LTI Compatibility](#-compatibility-apis)
-
----
-
-## 🚀 Quick Check: Room Status
-
-سادهترین روش برای بررسی فعال بودن یک روم:
-
-### Endpoint
-```http
-POST /auth/room/isRoomActive
-```
-d
-### Request
-```json
-{
- "roomId": "your-room-id"
-}
-```
-
-### Response
-```json
-{
- "status": true,
- "msg": "room is active",
- "isActive": true
-}
-```
-
-### cURL Example
-```bash
-#!/bin/bash
-API_KEY="your-api-key"
-SECRET="your-secret-key"
-BODY='{"roomId":"algebra-1402"}'
-
-# محاسبه HMAC-SHA256
-SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
-
-# ارسال درخواست
-curl -X POST 'https://your-domain.com/auth/room/isRoomActive' \
- -H "API-KEY: $API_KEY" \
- -H "HASH-SIGNATURE: $SIG" \
- -H 'Content-Type: application/json' \
- -d "$BODY"
-```
-
----
-
-## ⭐ Core Features
-
-Plugnmeet Server مجموعه کاملی از قابلیتهای حرفهای برای برگزاری کنفرانسهای آنلاین را فراهم میکند:
-
-### 🎥 Video Conferencing
-- ✅ HD Audio/Video با کیفیت بالا
-- ✅ Screen Sharing - اشتراکگذاری صفحه
-- ✅ Virtual Backgrounds - پسزمینه مجازی
-- ✅ Adaptive Streaming (Simulcast & Dynacast)
-
-### 📊 Collaboration Tools
-- ✅ Interactive Whiteboard با پشتیبانی از فایلهای PDF/Office
-- ✅ Shared Notepad - یادداشت مشترک
-- ✅ Live Polls - نظرسنجی زنده
-- ✅ Breakout Rooms - اتاقهای گروهی
-
-### 🎬 Recording & Streaming
-- ✅ Cloud Recording - ضبط ابری با فرمت MP4
-- ✅ RTMP Streaming - پخش زنده
-- ✅ Ingress Support (RTMP/WHIP)
-
-### 🛡️ Security & Control
-- ✅ Waiting Room - اتاق انتظار
-- ✅ Lock Settings - قفل کردن قابلیتها
-- ✅ User Management - مدیریت کاربران
-- ✅ End-to-End Encryption
-
-### 📈 Analytics & Monitoring
-- ✅ Session Analytics - آنالیتیکس جلسات
-- ✅ Participant Reports - گزارش شرکتکنندگان
-- ✅ Real-time Monitoring
-
-### ♿ Accessibility
-- ✅ Speech-to-Text - گفتار به متن
-- ✅ Real-time Translation (Azure)
-
----
-
-## 🔐 Authentication
-
-Plugnmeet از سه روش احراز هویت مختلف استفاده میکند:
-
-### 1. `/auth` Endpoints (HMAC + JSON)
-
-برای عملیات مدیریتی و ساخت توکنها استفاده میشود.
-
-#### Headers
-```http
-API-KEY: your_api_key
-HASH-SIGNATURE: hmac_sha256_hex_signature
-Content-Type: application/json
-```
-
-#### محاسبه HMAC Signature
-
-**Bash/Shell:**
-```bash
-SECRET="your-secret-key"
-BODY='{"roomId":"test-room"}'
-SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
-```
-
-**Python:**
-```python
-import hmac
-import hashlib
-import json
-
-secret = "your-secret-key"
-body = {"roomId": "test-room"}
-body_json = json.dumps(body)
-
-signature = hmac.new(
- secret.encode('utf-8'),
- body_json.encode('utf-8'),
- hashlib.sha256
-).hexdigest()
-```
-
-**Node.js:**
-```javascript
-const crypto = require('crypto');
-
-const secret = 'your-secret-key';
-const body = JSON.stringify({ roomId: 'test-room' });
-
-const signature = crypto
- .createHmac('sha256', secret)
- .update(body)
- .digest('hex');
-```
-
----
-
-### 2. `/api` Endpoints (Bearer Token + Protobuf)
-
-برای کنترلهای داخل جلسه استفاده میشود.
-
-#### Headers
-```http
-Authorization:
-Content-Type: application/octet-stream
-```
-
-> **توکن دسترسی** از طریق `/auth/room/getJoinToken` دریافت میشود.
-
-#### Request/Response Format
-- **بدنه درخواست**: Binary Protobuf (استفاده از SDK توصیه میشود)
-- **پاسخ**: Binary Protobuf
-
-#### cURL Example با Protobuf
-```bash
-# ساخت فایل باینری با SDK
-# سپس ارسال با curl
-curl -X POST 'https://your-domain.com/api/recording' \
- -H "Authorization: $TOKEN" \
- -H 'Content-Type: application/octet-stream' \
- --data-binary @recording_req.bin \
- -o response.bin
-```
-
-> **نکته**: برخی endpoint های `/api` مانند `convertWhiteboardFile` و `fileUpload` از JSON استفاده میکنند.
-
----
-
-### 3. Special Authentication Methods
-
-#### LTI (Learning Tools Interoperability)
-```http
-Authorization:
-```
-مسیرها: `/lti/v1/...`
-
-#### BigBlueButton Compatibility
-نیازمند `checksum` محاسبه شده مطابق استاندارد BBB
-مسیرها: `/:apiKey/bigbluebutton/api/...`
-
----
-
-## 📋 Room Management API
-
-### 🏗️ Create Room
-
-اتاق جلسه جدید ایجاد میکند.
-
-#### Endpoint
-```http
-POST /auth/room/create
-```
-
-#### Request Body
-```json
-{
- "roomId": "algebra-class-1402",
- "maxParticipants": 50,
- "emptyTimeout": 300,
- "metadata": {
- "roomTitle": "کلاس جبر خطی",
- "welcomeMessage": "به کلاس جبر خوش آمدید",
- "defaultLockSettings": {
- "lockMicrophone": false,
- "lockWebcam": false,
- "lockScreenSharing": true,
- "lockChat": false,
- "lockChatSendMessage": false,
- "lockChatFileShare": false,
- "lockPrivateChat": false,
- "lockWhiteboard": true,
- "lockSharedNotepad": false
- },
- "roomFeatures": {
- "allowWebcams": true,
- "muteOnStart": false,
- "allowScreenSharing": true,
- "allowRecording": true,
- "allowRtmp": true,
- "allowViewOtherWebcams": true,
- "allowViewOtherParticipantsList": true,
- "adminOnlyWebcams": false,
- "allowPolls": true,
- "roomDuration": 0,
- "recordingFeatures": {
- "isAllow": true,
- "isAllowCloud": true,
- "enableAutoCloudRecording": false
- },
- "chatFeatures": {
- "allowChat": true,
- "allowFileUpload": true
- },
- "sharedNotePadFeatures": {
- "allowedSharedNotePad": true
- },
- "whiteboardFeatures": {
- "allowedWhiteboard": true,
- "preloadFile": ""
- },
- "breakoutRoomFeatures": {
- "isAllow": true,
- "allowedNumberRooms": 6
- },
- "displayExternalLinkFeatures": {
- "isAllow": true
- },
- "ingressFeatures": {
- "isAllow": false
- },
- "speechToTextTranslationFeatures": {
- "isAllow": true,
- "isAllowTranslation": true
- }
- },
- "webhookUrl": "https://your-domain.com/webhook",
- "isBreakoutRoom": false,
- "parentRoomId": ""
- }
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "room created successfully",
- "roomId": "algebra-class-1402"
-}
-```
-
----
-
-### 🎫 Generate Join Token
-
-توکن ورود کاربر به جلسه را ایجاد میکند.
-
-#### Endpoint
-```http
-POST /auth/room/getJoinToken
-```
-
-#### Request Body
-```json
-{
- "roomId": "algebra-class-1402",
- "userInfo": {
- "userId": "student-123",
- "name": "علی احمدی",
- "isAdmin": false,
- "isHidden": false,
- "userMetadata": {
- "profilePic": "https://example.com/avatar.jpg",
- "lockSettings": {
- "lockMicrophone": false,
- "lockWebcam": false,
- "lockScreenSharing": true,
- "lockChat": false
- }
- }
- }
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "token generated",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
-}
-```
-
-#### Integration Example
-```html
-
-
-
- Join Meeting
-
-
-
-
-
-```
-
----
-
-### ✅ Check Room Status
-
-#### Endpoint
-```http
-POST /auth/room/isRoomActive
-```
-
-#### Request
-```json
-{
- "roomId": "algebra-class-1402"
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "room is active",
- "isActive": true
-}
-```
-
----
-
-### 📊 Get Active Room Info
-
-اطلاعات کامل یک روم فعال و لیست شرکتکنندگان آن را برمیگرداند.
-
-#### Endpoint
-```http
-POST /auth/room/getActiveRoomInfo
-```
-
-#### Request
-```json
-{
- "roomId": "algebra-class-1402"
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "success",
- "room": {
- "roomInfo": {
- "sid": "RM_xxxxxxxxxxxx",
- "roomId": "algebra-class-1402",
- "name": "algebra-class-1402",
- "emptyTimeout": 300,
- "maxParticipants": 50,
- "creationTime": "1699123456",
- "metadata": "{...}"
- },
- "participantsInfo": [
- {
- "sid": "PA_xxxxxxxxxxxx",
- "identity": "student-123",
- "name": "علی احمدی",
- "state": 0,
- "joinedAt": "1699123500"
- }
- ]
- }
-}
-```
-
----
-
-### 📋 List All Active Rooms
-
-لیست تمام رومهای فعال را برمیگرداند.
-
-#### Endpoint
-```http
-POST /auth/room/getActiveRoomsInfo
-```
-
-#### Request
-```json
-{}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "success",
- "rooms": [
- {
- "roomId": "algebra-class-1402",
- "sid": "RM_xxxxxxxxxxxx",
- "numParticipants": 15,
- "creationTime": "1699123456"
- },
- {
- "roomId": "physics-class-1402",
- "sid": "RM_yyyyyyyyyyyy",
- "numParticipants": 8,
- "creationTime": "1699123789"
- }
- ]
-}
-```
-
----
-
-### 🛑 End Room
-
-جلسه را به پایان میرساند و تمام شرکتکنندگان را خارج میکند.
-
-#### Endpoint
-```http
-POST /auth/room/endRoom
-```
-
-#### Request
-```json
-{
- "roomId": "algebra-class-1402"
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "room ended successfully"
-}
-```
-
----
-
-### 📜 Fetch Past Rooms
-
-لیست رومهای گذشته را با امکان فیلتر و صفحهبندی برمیگرداند.
-
-#### Endpoint
-```http
-POST /auth/room/fetchPastRooms
-```
-
-#### Request
-```json
-{
- "roomIds": ["algebra-class-1402", "physics-class-1402"],
- "from": 0,
- "limit": 20,
- "orderBy": "DESC"
-}
-```
-
-#### Request Parameters
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| `roomIds` | `string[]` | لیست شناسههای روم (اختیاری) |
-| `from` | `number` | شروع صفحهبندی |
-| `limit` | `number` | تعداد نتایج (حداکثر 100) |
-| `orderBy` | `string` | ترتیب: `ASC` یا `DESC` |
-
-#### Response
-```json
-{
- "status": true,
- "msg": "success",
- "result": {
- "totalRooms": 45,
- "from": 0,
- "limit": 20,
- "orderBy": "DESC",
- "roomsList": [
- {
- "roomId": "algebra-class-1402",
- "sid": "RM_xxxxxxxxxxxx",
- "roomTitle": "کلاس جبر خطی",
- "creationTime": "1699123456",
- "ended": "1699127056",
- "roomDuration": 3600
- }
- ]
- }
-}
-```
-
----
-
-## 🎬 Recording Management API
-
-### 📋 Fetch Recordings
-
-لیست ضبطهای انجام شده را دریافت میکند.
-
-#### Endpoint
-```http
-POST /auth/recording/fetch
-```
-
-#### Request
-```json
-{
- "roomIds": ["algebra-class-1402"],
- "from": 0,
- "limit": 20,
- "orderBy": "DESC"
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "success",
- "result": {
- "totalRecordings": 5,
- "from": 0,
- "limit": 20,
- "orderBy": "DESC",
- "recordings": [
- {
- "recordId": "rec_xxxxxxxxxxxx",
- "roomId": "algebra-class-1402",
- "roomSid": "RM_xxxxxxxxxxxx",
- "filePath": "/recordings/algebra-class-1402_20231105.mp4",
- "fileSize": 524288000,
- "creationTime": "1699123456",
- "roomCreationTime": "1699120000",
- "recordingDuration": 3600
- }
- ]
- }
-}
-```
-
----
-
-### 📄 Get Recording Info
-
-اطلاعات کامل یک ضبط را برمیگرداند.
-
-#### Endpoint
-```http
-POST /auth/recording/recordingInfo
-```
-
-#### Request
-```json
-{
- "recordId": "rec_xxxxxxxxxxxx"
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "success",
- "recordingInfo": {
- "recordId": "rec_xxxxxxxxxxxx",
- "roomId": "algebra-class-1402",
- "filePath": "/recordings/algebra-class-1402_20231105.mp4",
- "fileSize": 524288000,
- "creationTime": "1699123456"
- }
-}
-```
-
----
-
-### 🗑️ Delete Recording
-
-یک ضبط را حذف میکند.
-
-#### Endpoint
-```http
-POST /auth/recording/delete
-```
-
-#### Request
-```json
-{
- "recordId": "rec_xxxxxxxxxxxx"
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "recording deleted successfully"
-}
-```
-
----
-
-### 🔗 Get Download Token
-
-توکن موقت برای دانلود فایل ضبط شده ایجاد میکند.
-
-#### Endpoint
-```http
-POST /auth/recording/getDownloadToken
-```
-
-#### Request
-```json
-{
- "recordId": "rec_xxxxxxxxxxxx"
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "token generated",
- "token": "download_token_xxxxxxxxxxxx"
-}
-```
-
-#### Download File
-```bash
-# دانلود فایل با توکن
-curl -o recording.mp4 \
- "https://your-domain.com/download/recording/download_token_xxxxxxxxxxxx"
-```
-
----
-
-## 📈 Analytics API
-
-### 📋 Fetch Analytics
-
-لیست آنالیتیکس جلسات را دریافت میکند.
-
-#### Endpoint
-```http
-POST /auth/analytics/fetch
-```
-
-#### Request
-```json
-{
- "roomIds": ["algebra-class-1402"],
- "from": 0,
- "limit": 20
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "success",
- "result": {
- "totalAnalytics": 10,
- "analyticsList": [
- {
- "analyticsId": "ana_xxxxxxxxxxxx",
- "roomId": "algebra-class-1402",
- "roomSid": "RM_xxxxxxxxxxxx",
- "fileId": "file_xxxxxxxxxxxx",
- "fileName": "analytics_algebra-class-1402_20231105.json",
- "filePath": "/analytics/algebra-class-1402_20231105.json",
- "fileSize": 102400,
- "creationTime": "1699127056"
- }
- ]
- }
-}
-```
-
----
-
-### 🗑️ Delete Analytics
-
-#### Endpoint
-```http
-POST /auth/analytics/delete
-```
-
-#### Request
-```json
-{
- "analyticsId": "ana_xxxxxxxxxxxx"
-}
-```
-
----
-
-### 🔗 Get Download Token
-
-#### Endpoint
-```http
-POST /auth/analytics/getDownloadToken
-```
-
-#### Request
-```json
-{
- "analyticsId": "ana_xxxxxxxxxxxx"
-}
-```
-
-#### Response
-```json
-{
- "status": true,
- "msg": "token generated",
- "token": "analytics_token_xxxxxxxxxxxx"
-}
-```
-
-#### Download Analytics File
-```bash
-curl -o analytics.json \
- "https://your-domain.com/download/analytics/analytics_token_xxxxxxxxxxxx"
-```
-
----
-
-## 🎮 In-Meeting Controls API
-
-> **نکته مهم**: تمام endpoint های این بخش نیازمند **Bearer Token** در هدر `Authorization` هستند و از **Binary Protobuf** استفاده میکنند (مگر در موارد خاص که JSON ذکر شده باشد).
-
-### 🔐 Verify Token
-
-توکن کاربر را تایید کرده و اطلاعات اتصال را برمیگرداند.
-
-#### Endpoint
-```http
-POST /api/verifyToken
-```
-
-#### Request (Protobuf)
-```protobuf
-message VerifyTokenReq {}
-```
-
-#### Response (Protobuf)
-```protobuf
-message VerifyTokenRes {
- bool status = 1;
- string msg = 2;
- string roomId = 3;
- string userId = 4;
- string roomSid = 5;
- repeated string natsWsUrls = 6;
- string natsSubject = 7;
- string serverVersion = 8;
-}
-```
-
----
-
-### 🎬 Recording & RTMP Control
-
-#### Start/Stop Recording
-
-**Endpoint:**
-```http
-POST /api/recording
-```
-
-**Request (Protobuf):**
-```protobuf
-message RecordingReq {
- string sid = 1; // Room SID
- RecordingTasks task = 2; // START_RECORDING | STOP_RECORDING
- string rtmpUrl = 3; // برای RTMP
-}
-
-enum RecordingTasks {
- START_RECORDING = 0;
- STOP_RECORDING = 1;
- START_RTMP = 2;
- STOP_RTMP = 3;
-}
-```
-
-**Response (Protobuf):**
-```protobuf
-message RecordingRes {
- bool status = 1;
- string msg = 2;
-}
-```
-
----
-
-### 🛑 End Room
-
-اتاق را از داخل جلسه به پایان میرساند (فقط ادمین).
-
-#### Endpoint
-```http
-POST /api/endRoom
-```
-
-#### Request (Protobuf)
-```protobuf
-message RoomEndReq {
- string roomId = 1;
-}
-```
-
----
-
-### 🔒 Update Lock Settings
-
-تنظیمات قفل کاربران را تغییر میدهد (فقط ادمین).
-
-#### Endpoint
-```http
-POST /api/updateLockSettings
-```
-
-#### Request (Protobuf)
-```protobuf
-message UpdateUserLockSettingsReq {
- string roomSid = 1;
- string roomId = 2;
- string userId = 3; // "all" برای همه | شناسه کاربر خاص
- string service = 4; // mic | webcam | screenShare | chat | etc.
- string direction = 5; // "lock" | "unlock"
-}
-```
-
-#### Available Services
-- `mic` - میکروفون
-- `webcam` - وبکم
-- `screenShare` - اشتراکگذاری صفحه
-- `chat` - چت
-- `sendChatMsg` - ارسال پیام در چت
-- `chatFile` - ارسال فایل در چت
-- `privateChat` - چت خصوصی
-- `whiteboard` - وایتبرد
-- `sharedNotepad` - یادداشت مشترک
-
----
-
-### 🔇 Mute/Unmute Track
-
-میکروفون یک یا تمام کاربران را قطع یا وصل میکند (فقط ادمین).
-
-#### Endpoint
-```http
-POST /api/muteUnmuteTrack
-```
-
-#### Request (Protobuf)
-```protobuf
-message MuteUnMuteTrackReq {
- string sid = 1; // Room SID
- string roomId = 2;
- string userId = 3; // "all" برای همه | شناسه کاربر
- string trackSid = 4; // اختیاری
- bool muted = 5; // true = mute | false = unmute
-}
-```
-
----
-
-### 👤 Remove Participant
-
-کاربر را از جلسه حذف میکند (فقط ادمین).
-
-#### Endpoint
-```http
-POST /api/removeParticipant
-```
-
-#### Request (Protobuf)
-```protobuf
-message RemoveParticipantReq {
- string sid = 1;
- string roomId = 2;
- string userId = 3;
- string msg = 4; // پیام برای کاربر
- bool blockUser = 5; // مسدود کردن دائمی
-}
-```
-
----
-
-### 🎤 Switch Presenter
-
-نقش ارائهدهنده را به کاربر میدهد یا میگیرد (فقط ادمین).
-
-#### Endpoint
-```http
-POST /api/switchPresenter
-```
-
-#### Request (Protobuf)
-```protobuf
-message SwitchPresenterReq {
- string userId = 1;
- SwitchPresenterTask task = 2; // PROMOTE | DEMOTE
-}
-
-enum SwitchPresenterTask {
- PROMOTE = 0;
- DEMOTE = 1;
-}
-```
-
----
-
-## 🎨 Advanced Features
-
-### 🔗 External Display Link
-
-لینک خارجی را برای تمام شرکتکنندگان نمایش میدهد (فقط ادمین).
-
-#### Endpoint
-```http
-POST /api/externalDisplayLink
-```
-
-#### Request (Protobuf)
-```protobuf
-message ExternalDisplayLinkReq {
- ExternalDisplayLinkTask task = 1; // START_EXTERNAL_LINK | STOP_EXTERNAL_LINK
- string url = 2;
-}
-```
-
----
-
-### 🎵 External Media Player
-
-ویدیو یا صدای خارجی را پخش میکند (فقط ادمین).
-
-#### Endpoint
-```http
-POST /api/externalMediaPlayer
-```
-
-#### Request (Protobuf)
-```protobuf
-message ExternalMediaPlayerReq {
- ExternalMediaPlayerTask task = 1; // START_PLAYBACK | STOP_PLAYBACK
- string url = 2;
- bool isPresentation = 3;
-}
-```
-
-> **نکته**: میتوانید فایل را با `/api/fileUpload` آپلود کرده و لینک `/download/uploadedFile/...` را استفاده کنید.
-
----
-
-### 🚪 Waiting Room
-
-#### Approve Users
-
-کاربران در اتاق انتظار را تایید میکند (فقط ادمین).
-
-**Endpoint:**
-```http
-POST /api/waitingRoom/approveUsers
-```
-
-**Request (Protobuf):**
-```protobuf
-message ApproveWaitingUsersReq {
- repeated string userIds = 1;
-}
-```
-
-#### Update Waiting Room Message
-
-**Endpoint:**
-```http
-POST /api/waitingRoom/updateMsg
-```
-
-**Request (Protobuf):**
-```protobuf
-message UpdateWaitingRoomMessageReq {
- string message = 1;
-}
-```
-
----
-
-### 📊 Polls (نظرسنجی)
-
-#### Create Poll
-
-نظرسنجی جدید ایجاد میکند (فقط ادمین).
-
-**Endpoint:**
-```http
-POST /api/polls/create
-```
-
-**Request (Protobuf):**
-```protobuf
-message CreatePollReq {
- string question = 1;
- repeated PollOption options = 2;
- bool isAnonymous = 3;
- bool allowMultipleVotes = 4;
-}
-
-message PollOption {
- uint64 id = 1;
- string text = 2;
-}
-```
-
----
-
-#### List Polls
-
-**Endpoint:**
-```http
-GET /api/polls/listPolls
-```
-
-**Response:** Binary Protobuf
-
----
-
-#### Submit Poll Response
-
-**Endpoint:**
-```http
-POST /api/polls/submitResponse
-```
-
-**Request (Protobuf):**
-```protobuf
-message SubmitPollResponseReq {
- string pollId = 1;
- repeated uint64 selectedOptionIds = 2;
-}
-```
-
----
-
-#### Get Poll Results
-
-**Endpoint:**
-```http
-GET /api/polls/pollResponsesResult/:pollId
-```
-
-**Response:** Binary Protobuf با نتایج نظرسنجی
-
----
-
-### 🏢 Breakout Rooms (اتاقهای گروهی)
-
-#### Create Breakout Rooms
-
-**Endpoint:**
-```http
-POST /api/breakoutRoom/create
-```
-
-**Request (Protobuf):**
-```protobuf
-message CreateBreakoutRoomsReq {
- uint64 duration = 1;
- repeated BreakoutRoom rooms = 2;
-}
-
-message BreakoutRoom {
- string id = 1;
- string title = 2;
- repeated string userIds = 3;
-}
-```
-
----
-
-#### Join Breakout Room
-
-**Endpoint:**
-```http
-POST /api/breakoutRoom/join
-```
-
-**Request (Protobuf):**
-```protobuf
-message JoinBreakoutRoomReq {
- string breakoutRoomId = 1;
-}
-```
-
----
-
-#### List Breakout Rooms
-
-**Endpoint:**
-```http
-GET /api/breakoutRoom/listRooms
-```
-
----
-
-#### End Breakout Room
-
-**Endpoint:**
-```http
-POST /api/breakoutRoom/endRoom
-```
-
-**Request (Protobuf):**
-```protobuf
-message EndBreakoutRoomReq {
- string breakoutRoomId = 1;
-}
-```
-
----
-
-#### End All Breakout Rooms
-
-**Endpoint:**
-```http
-POST /api/breakoutRoom/endAllRooms
-```
-
----
-
-### 📡 Ingress (RTMP/WHIP Input)
-
-ورودی استریم خارجی ایجاد میکند.
-
-#### Endpoint
-```http
-POST /api/ingress/create
-```
-
-#### Request (Protobuf)
-```protobuf
-message CreateIngressReq {
- IngressInput inputType = 1; // RTMP_INPUT | WHIP_INPUT
- string participantName = 2;
- string roomId = 3;
-}
-
-enum IngressInput {
- RTMP_INPUT = 0;
- WHIP_INPUT = 1;
-}
-```
-
-#### Response (Protobuf)
-```protobuf
-message CreateIngressRes {
- bool status = 1;
- string msg = 2;
- string ingressId = 3;
- string url = 4;
- string streamKey = 5;
-}
-```
-
-#### Usage Example
-پس از دریافت `url` و `streamKey`:
-```bash
-# استریم با FFmpeg
-ffmpeg -re -i input.mp4 \
- -c:v libx264 -c:a aac \
- -f flv "rtmp://url/stream_key"
-```
-
----
-
-### 🗣️ Speech Services (Azure)
-
-#### Enable/Disable Speech Service
-
-**Endpoint:**
-```http
-POST /api/speechServices/serviceStatus
-```
-
-**Request (Protobuf):**
-```protobuf
-message SpeechToTextTranslationReq {
- bool enabled = 1;
-}
-```
-
----
-
-#### Get Azure Token
-
-**Endpoint:**
-```http
-POST /api/speechServices/azureToken
-```
-
-**Request (Protobuf):**
-```protobuf
-message GenerateAzureTokenReq {
- string userSid = 1;
-}
-```
-
----
-
-### 📁 File Upload & Whiteboard
-
-#### Upload File (Resumable)
-
-برای آپلود فایلهای بزرگ به صورت chunk به chunk.
-
-**Endpoint:**
-```http
-POST /api/fileUpload?resumable=true&roomSid=xxx&roomId=xxx&userId=xxx
-```
-
-**Headers:**
-```http
-Authorization:
-Content-Type: multipart/form-data
-```
-
-**Response:** `part_uploaded` or error
-
----
-
-#### Merge Uploaded Chunks
-
-**Endpoint:**
-```http
-POST /api/uploadedFileMerge
-```
-
-**Request (JSON):**
-```json
-{
- "roomSid": "RM_xxxxxxxxxxxx",
- "roomId": "algebra-class-1402",
- "resumableIdentifier": "unique-file-id",
- "resumableFilename": "document.pdf",
- "resumableTotalChunks": 10
-}
-```
-
-**Response (JSON):**
-```json
-{
- "status": true,
- "msg": "file merged successfully",
- "filePath": "/uploads/document.pdf",
- "fileName": "document.pdf",
- "fileExtension": "pdf"
-}
-```
-
----
-
-#### Convert Whiteboard File
-
-فایلهای PDF/Office را به تصاویر برای وایتبرد تبدیل میکند.
-
-> **پیشنیاز**: `libreoffice` و `mupdf-tools` (mutool) باید روی سرور نصب باشند.
-
-**Endpoint:**
-```http
-POST /api/convertWhiteboardFile
-```
-
-**Request (JSON):**
-```json
-{
- "roomSid": "RM_xxxxxxxxxxxx",
- "roomId": "algebra-class-1402",
- "filePath": "/uploads/document.pdf",
- "userId": "teacher-123"
-}
-```
-
-**Response (JSON):**
-```json
-{
- "status": true,
- "msg": "file converted successfully",
- "fileName": "document",
- "fileId": "file_xxxxxxxxxxxx",
- "filePath": "/whiteboard/document/",
- "totalPages": 15
-}
-```
-
----
-
-## 🔧 Other Services
-
-### 🔔 Webhook
-
-برای دریافت رویدادهای LiveKit.
-
-**Endpoint:**
-```http
-POST /webhook
-```
-
-**Headers:**
-```http
-Authorization:
-```
-
-**Webhook Events:**
-- `room_started` - شروع روم
-- `room_finished` - پایان روم
-- `participant_joined` - ورود کاربر
-- `participant_left` - خروج کاربر
-- `track_published` - انتشار track
-- `track_unpublished` - حذف track
-- `recording_started` - شروع ضبط
-- `recording_finished` - پایان ضبط
-- و بیشتر...
-
----
-
-### ❤️ Health Check
-
-وضعیت سلامت سرور را بررسی میکند.
-
-**Endpoint:**
-```http
-GET /healthCheck
-```
-
-**Response:**
-```
-Healthy
-```
-
-سرویسهای بررسی شده:
-- ✅ Database (MySQL/MariaDB)
-- ✅ Redis
-- ✅ NATS
-
----
-
-### 📥 Download Services
-
-#### Download Uploaded File
-```http
-GET /download/uploadedFile/:sid/*
-```
-
-#### Download Recording
-```http
-GET /download/recording/:token
-```
-
-#### Download Analytics
-```http
-GET /download/analytics/:token
-```
-
----
-
-## 🔄 Compatibility APIs
-
-### 🟦 BigBlueButton (BBB) Compatibility
-
-Plugnmeet از API های BigBlueButton پشتیبانی میکند.
-
-**Base Path:**
-```
-/:apiKey/bigbluebutton/api
-```
-
-**Available Endpoints:**
-- `GET/POST /create` - ایجاد جلسه
-- `GET/POST /join` - ورود به جلسه
-- `GET/POST /isMeetingRunning` - بررسی فعال بودن
-- `GET/POST /getMeetingInfo` - اطلاعات جلسه
-- `GET/POST /getMeetings` - لیست جلسات
-- `GET/POST /end` - پایان جلسه
-- `GET/POST /getRecordings` - لیست ضبطها
-- `GET/POST /deleteRecordings` - حذف ضبط
-- `GET/POST /publishRecordings` - انتشار ضبط
-- `GET/POST /updateRecordings` - بهروزرسانی ضبط
-
-**Authentication:** نیازمند `checksum` مطابق استاندارد BBB
-
-#### Example (BBB Join)
-```bash
-API_KEY="your-api-key"
-SECRET="your-secret"
-MEETING_ID="test-meeting"
-USER_NAME="Ali"
-
-# ساخت query string
-QUERY="meetingID=${MEETING_ID}&fullName=${USER_NAME}"
-
-# محاسبه checksum
-CHECKSUM=$(echo -n "join${QUERY}${SECRET}" | sha1sum | awk '{print $1}')
-
-# URL نهایی
-URL="https://your-domain.com/${API_KEY}/bigbluebutton/api/join?${QUERY}&checksum=${CHECKSUM}"
-
-echo "Join URL: $URL"
-```
-
----
-
-### 🎓 LTI (Learning Tools Interoperability)
-
-برای یکپارچگی با سیستمهای LMS.
-
-**Base Path:**
-```
-/lti/v1
-```
-
-#### LTI Landing
-```http
-POST /lti/v1
-```
-
-#### LTI API Endpoints
-
-نیازمند هدر `Authorization` خاص LTI:
-
-- `POST /lti/v1/api/room/join` - ورود به روم
-- `POST /lti/v1/api/room/isActive` - بررسی فعال بودن
-- `POST /lti/v1/api/room/end` - پایان روم
-- `POST /lti/v1/api/recording/fetch` - لیست ضبطها
-- `POST /lti/v1/api/recording/download` - دانلود ضبط
-- `POST /lti/v1/api/recording/delete` - حذف ضبط
-
----
-
-## 🛠️ SDKs & Tools
-
-### Official SDKs
-
-#### PHP SDK
-```bash
-composer require mynaparrot/plugnmeet-sdk-php
-```
-
-```php
-roomId = 'test-room';
-$params->metadata->roomTitle = 'کلاس آزمایشی';
-
-$result = $plugnmeet->room->create($params);
-```
-
----
-
-#### JavaScript/Node.js SDK
-```bash
-npm install plugnmeet-sdk-js
-```
-
-```javascript
-const { PlugNmeet } = require('plugnmeet-sdk-js');
-
-const plugnmeet = new PlugNmeet({
- host: 'https://your-domain.com',
- apiKey: 'your-api-key',
- apiSecret: 'your-secret'
-});
-
-// ایجاد روم
-const result = await plugnmeet.room.create({
- roomId: 'test-room',
- metadata: {
- roomTitle: 'کلاس آزمایشی'
- }
-});
-
-// تولید توکن ورود
-const token = await plugnmeet.room.getJoinToken({
- roomId: 'test-room',
- userInfo: {
- userId: 'user-123',
- name: 'علی احمدی',
- isAdmin: false
- }
-});
-```
-
----
-
-### Docker Deployment
-
-```bash
-docker run -d \
- --name plugnmeet-server \
- -p 8080:8080 \
- -v $PWD/config.yaml:/config.yaml \
- mynaparrot/plugnmeet-server \
- --config /config.yaml
-```
-
----
-
-## 📚 Additional Resources
-
-### Documentation
-- 🌐 **Official Website**: https://www.plugnmeet.org
-- 📖 **Full Documentation**: https://www.plugnmeet.org/docs
-- 🔧 **Installation Guide**: https://www.plugnmeet.org/docs/installation
-- 👨💻 **Developer Guide**: https://www.plugnmeet.org/docs/developer-guide
-
-### Community & Support
-- 💬 **Discord**: https://discord.gg/2X2ZaCHu4C
-- 🐛 **GitHub Issues**: https://github.com/mynaparrot/plugNmeet-server/issues
-- 📧 **Email Support**: support@plugnmeet.com
-
-### Source Code
-- 🖥️ **Server**: https://github.com/mynaparrot/plugNmeet-server
-- 🎨 **Client**: https://github.com/mynaparrot/plugNmeet-client
-- 🎬 **Recorder**: https://github.com/mynaparrot/plugNmeet-recorder
-
----
-
-## 📝 Notes & Best Practices
-
-### Performance Tips
-1. ✅ از Redis برای caching استفاده کنید
-2. ✅ برای مقیاسپذیری از Load Balancer استفاده کنید
-3. ✅ ضبطها را در storage خارجی (S3, MinIO) ذخیره کنید
-4. ✅ از CDN برای سرویسدهی فایلهای استاتیک استفاده کنید
-
-### Security Best Practices
-1. 🔒 HTTPS را فعال کنید (الزامی)
-2. 🔒 `apiKey` و `secret` را محرمانه نگه دارید
-3. 🔒 از CORS Policy مناسب استفاده کنید
-4. 🔒 توکنها را با expiration time محدود تولید کنید
-5. 🔒 Webhook signature را همیشه تایید کنید
-
-### Rate Limiting
-- `/auth` endpoints: 100 req/min per IP
-- `/api` endpoints: 1000 req/min per token
-- File uploads: 10 MB/s per user
-
----
-
-## 🎯 Quick Start Checklist
-
-- [ ] LiveKit Server راهاندازی شده
-- [ ] Redis نصب و پیکربندی شده
-- [ ] MySQL/MariaDB آماده است
-- [ ] فایل `config.yaml` تنظیم شده
-- [ ] Plugnmeet Server در حال اجراست
-- [ ] Client UI در دسترس است
-- [ ] Test meeting ایجاد و تست شده
-- [ ] Webhook تنظیم شده (اختیاری)
-- [ ] Recording تست شده (اختیاری)
-
----
-
-## 🎉 نسخه و تاریخچه تغییرات
-
-**نسخه فعلی مستند**: 2.0.0
-**آخرین بهروزرسانی**: نوامبر 2024
-
-برای مشاهده تاریخچه کامل تغییرات به فایل [CHANGELOG.md](./CHANGELOG.md) مراجعه کنید.
-
----
-
-
-
-**ساخته شده با ❤️ توسط [MynaParrot](https://www.mynaparrot.com)**
-
-[Website](https://www.plugnmeet.org) • [GitHub](https://github.com/mynaparrot/plugNmeet-server) • [Discord](https://discord.gg/2X2ZaCHu4C)
-
-
diff --git a/apps/course/services/plugnmeet.py b/apps/course/services/plugnmeet.py
deleted file mode 100644
index c0dc5e1..0000000
--- a/apps/course/services/plugnmeet.py
+++ /dev/null
@@ -1,155 +0,0 @@
-import json
-import hmac
-import hashlib
-from typing import Any, Dict, Optional
-from urllib.parse import urljoin
-
-import requests
-from django.conf import settings
-from django.core.exceptions import ImproperlyConfigured
-
-
-class PlugNMeetError(Exception):
- def __init__(self, message: str, *, status_code: Optional[int] = None, response_data: Optional[Dict[str, Any]] = None):
- super().__init__(message)
- self.status_code = status_code
- self.response_data = response_data or {}
-
-
-class PlugNMeetClient:
- def __init__(self, *, base_url: Optional[str] = None, api_key: Optional[str] = None, api_secret: Optional[str] = None, timeout: Optional[float] = None):
- self.base_url = (base_url or getattr(settings, "PLUGNMEET_SERVER_URL", "")).rstrip("/")
- self.api_key = api_key or getattr(settings, "PLUGNMEET_API_KEY", "")
- self.api_secret = api_secret or getattr(settings, "PLUGNMEET_API_SECRET", "")
- self.timeout = timeout or getattr(settings, "PLUGNMEET_TIMEOUT", 10.0)
-
- if not self.base_url or not self.api_key or not self.api_secret:
- raise ImproperlyConfigured("PlugNMeet integration settings are incomplete.")
-
- def create_room(self, payload: Dict[str, Any]) -> Dict[str, Any]:
- # Convert entire payload keys to camelCase as required by PlugNMeet protocol
- print(f"[PlugNMeet] Creating room with payload: {payload}")
- prepared = self._camelize_dict(payload)
- return self._post("/auth/room/create", prepared)
-
- def get_join_token(self, payload: Dict[str, Any]) -> Dict[str, Any]:
- # Convert entire payload keys to camelCase as required by PlugNMeet protocol
- prepared = self._camelize_dict(payload)
- return self._post("/auth/room/getJoinToken", prepared)
-
- def is_room_active(self, room_id: str) -> Dict[str, Any]:
- return self._post("/auth/room/isRoomActive", {"roomId": room_id})
-
- def end_room(self, room_id: str) -> Dict[str, Any]:
- return self._post("/auth/room/endRoom", {"roomId": room_id})
-
- def get_recording_info(self, record_id: str) -> Dict[str, Any]:
- """Get detailed information about a recording."""
- return self._post("/auth/recording/recordingInfo", {"recordId": record_id})
-
- def get_recording_download_token(self, record_id: str) -> Dict[str, Any]:
- """Get a temporary download token for a recording."""
- return self._post("/auth/recording/getDownloadToken", {"recordId": record_id})
-
- def download_file(self, download_path: str, save_to: str) -> bool:
- """
- Download a file from PlugNMeet server.
-
- Args:
- download_path: The download path (e.g., '/download/recording/token_xxx')
- save_to: Local file path to save the downloaded file
-
- Returns:
- True if download successful, False otherwise
- """
- import logging
- logger = logging.getLogger(__name__)
-
- url = urljoin(f"{self.base_url}/", download_path.lstrip("/"))
- logger.info(f"[PlugNMeet] Downloading file from {url}")
-
- try:
- response = requests.get(url, stream=True, timeout=300) # 5 minute timeout for large files
- response.raise_for_status()
-
- # Write file in chunks
- with open(save_to, 'wb') as f:
- for chunk in response.iter_content(chunk_size=8192):
- if chunk:
- f.write(chunk)
-
- logger.info(f"[PlugNMeet] File downloaded successfully to {save_to}")
- return True
-
- except requests.RequestException as exc:
- logger.error(f"[PlugNMeet] Failed to download file - error={str(exc)}")
- raise PlugNMeetError(f"Failed to download file: {str(exc)}") from exc
-
- def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
- url = urljoin(f"{self.base_url}/", path.lstrip("/"))
- body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
- headers = {
- "Content-Type": "application/json",
- "API-KEY": self.api_key,
- "HASH-SIGNATURE": self._build_signature(body),
- }
-
- import logging
- logger = logging.getLogger(__name__)
- logger.debug(f"[PlugNMeet] POST {path} - Body: {body[:500]}")
-
- try:
- response = requests.post(url, data=body.encode("utf-8"), headers=headers, timeout=self.timeout)
- except requests.RequestException as exc:
- raise PlugNMeetError("Failed to reach PlugNMeet server.") from exc
-
- if response.status_code >= 400:
- response_data = self._safe_json(response)
- error_msg = f"PlugNMeet server returned status {response.status_code}: {response.text}"
- raise PlugNMeetError(
- error_msg,
- status_code=response.status_code,
- response_data=response_data,
- )
-
- data = self._safe_json(response)
- if data is None:
- raise PlugNMeetError("PlugNMeet server returned an invalid response format.")
-
- if isinstance(data, dict) and data.get('status') is False:
- error_message = data.get('msg') or data.get('message') or "PlugNMeet operation failed."
- raise PlugNMeetError(
- error_message,
- status_code=response.status_code,
- response_data=data,
- )
-
- return data
-
- @staticmethod
- def _snake_to_camel(key: str) -> str:
- parts = key.split("_")
- if not parts:
- return key
- return parts[0] + "".join(p.capitalize() or "" for p in parts[1:])
-
- def _camelize_dict(self, obj: Any) -> Any:
- if isinstance(obj, dict):
- return {self._snake_to_camel(k): self._camelize_dict(v) for k, v in obj.items()}
- if isinstance(obj, list):
- return [self._camelize_dict(v) for v in obj]
- return obj
-
- def _build_signature(self, body: str) -> str:
- digest = hmac.new(self.api_secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256)
- return digest.hexdigest()
-
- @staticmethod
- def _safe_json(response: requests.Response) -> Optional[Dict[str, Any]]:
- try:
- return response.json()
- except ValueError:
- return None
-
-
-__all__ = ["PlugNMeetClient", "PlugNMeetError"]
diff --git a/apps/course/services/web_egress.py b/apps/course/services/web_egress.py
deleted file mode 100644
index b1af20a..0000000
--- a/apps/course/services/web_egress.py
+++ /dev/null
@@ -1,192 +0,0 @@
-from __future__ import annotations
-
-import logging
-from typing import Any, Dict, Optional
-
-import requests
-from django.conf import settings
-from django.core.exceptions import ImproperlyConfigured
-from django.utils import timezone
-
-logger = logging.getLogger(__name__)
-
-
-class WebEgressError(Exception):
- def __init__(
- self,
- message: str,
- *,
- status_code: Optional[int] = None,
- response_data: Optional[Dict[str, Any]] = None,
- ):
- super().__init__(message)
- self.status_code = status_code
- self.response_data = response_data or {}
-
-
-class WebEgressClient:
- def __init__(
- self,
- *,
- base_url: Optional[str] = None,
- service_token: Optional[str] = None,
- timeout: Optional[float] = None,
- ):
- self.base_url = (
- base_url
- or getattr(settings, "ONLINE_CLASS_WEB_EGRESS_SERVICE_URL", "")
- ).rstrip("/")
- self.service_token = (
- service_token
- or getattr(settings, "ONLINE_CLASS_WEB_EGRESS_SERVICE_TOKEN", "")
- )
- self.timeout = timeout or getattr(
- settings, "ONLINE_CLASS_WEB_EGRESS_TIMEOUT", 20.0
- )
-
- if not self.base_url:
- raise ImproperlyConfigured(
- "ONLINE_CLASS_WEB_EGRESS_SERVICE_URL must be configured."
- )
-
- def start_recording(self, payload: Dict[str, Any]) -> Dict[str, Any]:
- return self._request("POST", "/api/web-egress/start/", json=payload)
-
- def stop_recording(self, payload: Dict[str, Any]) -> Dict[str, Any]:
- return self._request("POST", "/api/web-egress/stop/", json=payload)
-
- def get_status(self, *, room_id: str, session_id: int) -> Dict[str, Any]:
- return self._request(
- "GET",
- "/api/web-egress/status/",
- params={"room_id": room_id, "session_id": session_id},
- )
-
- def _request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
- headers = kwargs.pop("headers", {})
- headers["Content-Type"] = "application/json"
- if self.service_token:
- headers["Authorization"] = f"Bearer {self.service_token}"
-
- request_url = f"{self.base_url}{path}"
-
- try:
- response = requests.request(
- method,
- request_url,
- headers=headers,
- timeout=self.timeout,
- **kwargs,
- )
- except requests.RequestException as exc:
- logger.exception(
- "[WebEgressClient] Request failed method=%s url=%s error=%s",
- method,
- request_url,
- str(exc),
- )
- raise WebEgressError("Failed to reach WebEgress service.") from exc
-
- data = self._safe_json(response)
- if response.status_code >= 400:
- message = (
- (data or {}).get("message")
- or (data or {}).get("detail")
- or response.text
- or "WebEgress service request failed."
- )
- logger.error(
- "[WebEgressClient] Non-success response method=%s url=%s status=%s message=%s payload=%s",
- method,
- request_url,
- response.status_code,
- message,
- data,
- )
- raise WebEgressError(
- message,
- status_code=response.status_code,
- response_data=data,
- )
-
- if data is None:
- raise WebEgressError("WebEgress service returned an invalid response.")
-
- if isinstance(data, dict) and data.get("status") is False:
- raise WebEgressError(
- data.get("message") or data.get("detail") or "WebEgress failed.",
- status_code=response.status_code,
- response_data=data,
- )
-
- return data
-
- @staticmethod
- def _safe_json(response: requests.Response) -> Optional[Dict[str, Any]]:
- try:
- return response.json()
- except ValueError:
- return None
-
-
-def is_web_egress_recording_status(status_value: str) -> bool:
- return str(status_value or "").strip().lower() in {
- "starting",
- "recording",
- "stopping",
- }
-
-
-def stop_session_web_egress_if_active(session) -> bool:
- """
- Best-effort stop for an active WebEgress recording.
-
- Returns True when a stop request was sent, otherwise False.
- This helper never raises and is safe to call from close-session flows.
- """
- if not getattr(settings, "ONLINE_CLASS_WEB_EGRESS_ENABLED", False):
- return False
-
- if not session or not is_web_egress_recording_status(
- getattr(session, "web_egress_status", "")
- ):
- return False
-
- try:
- client = WebEgressClient()
- client.stop_recording(
- {
- "session_id": session.id,
- "room_id": session.room_id,
- "egress_id": session.web_egress_id,
- }
- )
- except (ImproperlyConfigured, WebEgressError) as exc:
- logger.warning(
- "[WebEgress] Failed to stop active recording during session close - session_id=%s room_id=%s error=%s",
- session.id,
- session.room_id,
- str(exc),
- )
- return False
-
- session.web_egress_status = "stopping"
- if not session.web_egress_stopped_at:
- session.web_egress_stopped_at = timezone.now()
- session.save(
- update_fields=[
- "web_egress_status",
- "web_egress_stopped_at",
- "updated_at",
- ]
- )
- else:
- session.save(update_fields=["web_egress_status", "updated_at"])
-
- logger.info(
- "[WebEgress] Stop requested during session close - session_id=%s room_id=%s egress_id=%s",
- session.id,
- session.room_id,
- session.web_egress_id,
- )
- return True
diff --git a/apps/course/signals.py b/apps/course/signals.py
deleted file mode 100644
index daaae95..0000000
--- a/apps/course/signals.py
+++ /dev/null
@@ -1,529 +0,0 @@
-import logging
-from django.utils import timezone
-from apps.course.models import Course, Participant, LessonCompletion, CourseLiveSession
-from apps.account.notification_service import create_and_send_notification
-from apps.chat.models import RoomMessage
-from apps.course.models.course import Course, extract_text_from_json, get_localized_field
-from apps.course.models.lesson import CourseLesson
-from apps.course.models.live_session import LiveSessionRecording, LiveSessionUser
-from django.db.models import Q
-
-logger = logging.getLogger(__name__)
-
-from django.db.models.signals import post_save, post_delete, pre_save, m2m_changed
-from django.dispatch import receiver
-from django.core.cache import cache
-from django.contrib.auth import get_user_model
-
-UserModel = get_user_model()
-
-
-def sync_course_group_room(instance):
- title_str = extract_text_from_json(instance.title)
- first_professor = instance.professors.first()
-
- if first_professor:
- # Try to get an existing group room; if multiple exist, pick the first.
- try:
- room, created = RoomMessage.objects.get_or_create(
- course=instance,
- room_type=RoomMessage.RoomTypeChoices.GROUP,
- defaults={
- "name": f"{title_str} - Group",
- "description": f"Group chat for course: {title_str}",
- "initiator": first_professor,
- },
- )
- except RoomMessage.MultipleObjectsReturned:
- # Fallback: get the earliest room and update it
- room = RoomMessage.objects.filter(
- course=instance,
- room_type=RoomMessage.RoomTypeChoices.GROUP,
- ).order_by("id").first()
- created = False
- if not created:
- # Update the existing room with latest info
- RoomMessage.objects.filter(id=room.id).update(
- name=f"{title_str} - Group",
- description=f"Group chat for course: {title_str}",
- initiator=first_professor,
- )
- else:
- # No professor yet – just ensure the group room exists without initiator
- RoomMessage.objects.filter(
- course=instance,
- room_type=RoomMessage.RoomTypeChoices.GROUP,
- ).update(
- name=f"{title_str} - Group",
- description=f"Group chat for course: {title_str}",
- )
-
-
-@receiver(post_save, sender=Course)
-def handle_room_message_for_course(sender, instance, created, **kwargs):
- title_str = extract_text_from_json(instance.title)
- first_professor = instance.professors.first()
- if created and not first_professor:
- return
-
- if created: # فقط برای موارد جدید اجرا شود
- RoomMessage.objects.create(
- name=f"{title_str} - Group",
- description=f"Group chat for course: {title_str}",
- initiator=first_professor, # استاد اول بهعنوان سازنده اتاق
- course=instance,
- room_type=RoomMessage.RoomTypeChoices.GROUP
- )
- else: # این بخش در زمان آپدیت دوره اجرا میشود
- # Find the existing group room for this course and update its details
- update_kwargs = {
- 'name': f"{title_str} - Group",
- 'description': f"Group chat for course: {title_str}",
- }
- if first_professor:
- update_kwargs['initiator'] = first_professor
- RoomMessage.objects.filter(
- course=instance,
- room_type=RoomMessage.RoomTypeChoices.GROUP
- ).update(**update_kwargs)
-
-
-@receiver(m2m_changed, sender=Course.professors.through)
-def handle_course_professors_changed(sender, instance, action, pk_set, **kwargs):
- if action not in {"post_add", "post_remove", "post_clear"}:
- return
-
- sync_course_group_room(instance)
-
- for professor in instance.professors.all():
- professor.ensure_professor_profile()
- detail_cache_key = f"professor_detail_{professor.slug}"
- cache.delete(detail_cache_key)
-
-
-@receiver(post_save, sender=Course)
-def ensure_professor_role(sender, instance, **kwargs):
- for professor in instance.professors.all():
- professor.ensure_professor_profile()
-
-@receiver([post_save, post_delete], sender=Course)
-def invalidate_professor_course_cache(sender, instance, **kwargs):
- """
- Clears the cached professor detail page AND their course list
- whenever a course assigned to them is created, updated, or deleted.
- """
- for professor in instance.professors.all():
- detail_cache_key = f"professor_detail_{professor.slug}"
- cache.delete(detail_cache_key)
-
-# Optional: If you update a Professor's profile in the admin directly
-@receiver([post_save, post_delete], sender=UserModel)
-def invalidate_professor_profile_cache(sender, instance, **kwargs):
- if instance.user_type == UserModel.UserType.PROFESSOR:
- cache_key = f"professor_detail_{instance.slug}"
- cache.delete(cache_key)
-
-@receiver(post_save, sender=Course)
-def sync_course_chat_locks(sender, instance, **kwargs):
- """
- Automatically locks/unlocks the related chat rooms when the admin
- toggles the chat locks on the Course page.
- """
- # 1. Update the Group Chat
- RoomMessage.objects.filter(
- course=instance,
- room_type=RoomMessage.RoomTypeChoices.GROUP
- ).update(is_locked=instance.is_group_chat_locked)
-
- # 2. Update the Private Chats between the Professors and Students of this course
- # Get all student IDs enrolled in this course
- student_ids = instance.participants.values_list('student_id', flat=True)
- professor_ids = list(instance.professors.values_list('id', flat=True))
-
- if student_ids and professor_ids:
- RoomMessage.objects.filter(
- room_type=RoomMessage.RoomTypeChoices.PRIVATE
- ).filter(
- # Find rooms where initiator is any professor and recipient is student, OR vice versa
- Q(initiator_id__in=professor_ids, recipient_id__in=student_ids) |
- Q(initiator_id__in=student_ids, recipient_id__in=professor_ids)
- ).update(is_locked=instance.is_professor_chat_locked)
-
-
-def _recalculate_course_lessons_count(course_id):
- Course.recalculate_lessons_count_for_course(course_id)
-
-
-def _sync_quiz_lesson_numbers(course_id):
- if not course_id:
- return
- from apps.quiz.models import Quiz
- from apps.course.models import CourseLesson
-
- active_lessons_count = CourseLesson.objects.filter(
- course_id=course_id,
- is_active=True
- ).filter(
- Q(chapter__isnull=True) | Q(chapter__is_active=True)
- ).count()
-
- # Update quizzes where lesson_number > active_lessons_count to 0
- Quiz.objects.filter(
- course_id=course_id,
- lesson_number__gt=active_lessons_count
- ).update(lesson_number=0)
-
-
-@receiver(pre_save, sender=CourseLesson)
-def capture_previous_course_lesson_state(sender, instance, **kwargs):
- if not instance.pk:
- instance._previous_course_id = None
- instance._previous_is_active = None
- return
-
- previous = sender.objects.filter(pk=instance.pk).values('course_id', 'is_active').first()
- if previous:
- instance._previous_course_id = previous['course_id']
- instance._previous_is_active = previous['is_active']
- else:
- instance._previous_course_id = None
- instance._previous_is_active = None
-
-
-@receiver(post_save, sender=CourseLesson)
-def sync_lessons_count_on_course_lesson_save(sender, instance, **kwargs):
- affected_course_ids = {instance.course_id}
-
- previous_course_id = getattr(instance, '_previous_course_id', None)
- previous_is_active = getattr(instance, '_previous_is_active', None)
- if previous_course_id and previous_course_id != instance.course_id:
- affected_course_ids.add(previous_course_id)
-
- if previous_is_active is not None and previous_is_active != instance.is_active and previous_course_id:
- affected_course_ids.add(previous_course_id)
-
- for course_id in affected_course_ids:
- _recalculate_course_lessons_count(course_id)
- _sync_quiz_lesson_numbers(course_id)
-
-
-@receiver(post_delete, sender=CourseLesson)
-def sync_lessons_count_on_course_lesson_delete(sender, instance, **kwargs):
- _recalculate_course_lessons_count(instance.course_id)
- _sync_quiz_lesson_numbers(instance.course_id)
-
-
-@receiver(post_save, sender=LiveSessionRecording)
-def create_course_lesson_from_recording(sender, instance, **kwargs):
- """
- Automatically creates a CourseChapter (if not exists) and CourseLesson
- whenever a LiveSessionRecording is saved with a valid file.
- """
- if not instance.file:
- return
-
- # To avoid duplicate creation if save is called multiple times
- from apps.course.models.lesson import Lesson, CourseChapter, CourseLesson
- if Lesson.objects.filter(content_file=instance.file.name).exists():
- return
-
- session = instance.session
- course = session.course
-
- # 1. Determine chapter title as recording date (YYYY-MM-DD)
- date_val = instance.created_at or timezone.now()
- recording_date_str = date_val.strftime('%Y-%m-%d')
-
- # 2. Get or create CourseChapter for this date
- from apps.course.models.course import extract_text_from_json
- chapter = None
- for ch in CourseChapter.objects.filter(course=course):
- if extract_text_from_json(ch.title) == recording_date_str:
- chapter = ch
- break
-
- if not chapter:
- chapter = CourseChapter.objects.create(
- course=course,
- title=recording_date_str,
- is_active=True
- )
-
- # 3. Determine lesson title: use custom title or subject (with parts if multiple recordings exist)
- base_title = session.recording_title or session.subject
-
- # Get all recordings for this session, ordered by creation
- recordings = list(LiveSessionRecording.objects.filter(session=session).order_by('created_at', 'id'))
- recordings_count = len(recordings)
-
- if recordings_count <= 1:
- lesson_title = base_title
- else:
- try:
- current_index = recordings.index(instance) + 1
- except ValueError:
- current_index = recordings_count
-
- lesson_title = f"{base_title} part {current_index}"
-
- # Retroactively rename the first lesson if this is a subsequent recording
- if current_index > 1:
- first_recording = recordings[0]
- if first_recording.file:
- first_lesson = Lesson.objects.filter(content_file=first_recording.file.name).first()
- if first_lesson:
- first_lesson_title = f"{base_title} part 1"
- first_lesson.title = first_lesson_title
- first_lesson.save(update_fields=['title'])
-
- # Also update corresponding CourseLesson
- CourseLesson.objects.filter(lesson=first_lesson).update(title=first_lesson_title)
-
- # 4. Calculate duration in minutes
- duration_minutes = 0
- if instance.file_time:
- duration_minutes = int(instance.file_time.total_seconds() / 60)
- if duration_minutes == 0 and instance.file_time.total_seconds() > 0:
- duration_minutes = 1
-
- # 5. Create the Lesson object
- lesson = Lesson.objects.create(
- title=lesson_title,
- content_type="video_file",
- content_file=instance.file,
- duration=duration_minutes
- )
-
- # 6. Create the CourseLesson object linking it to the chapter
- course_lesson = CourseLesson.objects.create(
- course=course,
- chapter=chapter,
- lesson=lesson,
- title=lesson_title,
- is_active=True
- )
-
- logger.info(f"✨ [Signal] Automatically created CourseLesson {course_lesson.id} ({lesson_title}) for course {course.id} from recording {instance.id}")
-
-
-@receiver(pre_save, sender=Participant)
-def store_participant_previous_active(sender, instance, **kwargs):
- if instance.pk:
- try:
- old = Participant.objects.get(pk=instance.pk)
- instance._previous_is_active = old.is_active
- except Participant.DoesNotExist:
- instance._previous_is_active = None
- else:
- instance._previous_is_active = None
-
-
-@receiver(post_save, sender=Participant)
-def notify_course_access_granted(sender, instance, created, **kwargs):
- if instance.is_active:
- was_activated = False
- if created:
- was_activated = True
- elif hasattr(instance, '_previous_is_active') and instance._previous_is_active is False:
- was_activated = True
-
- if was_activated:
- course_title_en = get_localized_field('en', instance.course.title)
- course_title_fa = get_localized_field('fa', instance.course.title)
- create_and_send_notification(
- user=instance.student,
- title_en="Course Access Granted",
- body_en=f"Your access to the course '{course_title_en}' has been activated.",
- title_fa="دسترسی به دوره فعال شد",
- body_fa=f"دسترسی شما به دوره «{course_title_fa}» فعال شد.",
- service='imam-javad',
- data={'type': 'course_access_granted', 'course_id': instance.course.id}
- )
-
-
-@receiver(post_save, sender=LessonCompletion)
-def check_course_completion(sender, instance, created, **kwargs):
- if created:
- student = instance.student
- course_lesson = instance.course_lesson
- course = course_lesson.course
-
- if not course:
- return
-
- total_lessons_count = CourseLesson.objects.filter(course=course, is_active=True).count()
- if total_lessons_count > 0:
- completed_lessons_count = LessonCompletion.objects.filter(
- student=student,
- course_lesson__course=course,
- course_lesson__is_active=True
- ).count()
-
- if completed_lessons_count >= total_lessons_count:
- cache_key = f"notified_course_completed_{student.id}_{course.id}"
- if not cache.get(cache_key):
- cache.set(cache_key, True, timeout=86400 * 30) # 30 days
-
- course_title_en = get_localized_field('en', course.title)
- course_title_fa = get_localized_field('fa', course.title)
- create_and_send_notification(
- user=student,
- title_en="Course Completed",
- body_en=f"Congratulations! You have completed the course '{course_title_en}'.",
- title_fa="دوره به پایان رسید",
- body_fa=f"تبریک! شما دوره «{course_title_fa}» را با موفقیت به پایان رساندید.",
- service='imam-javad',
- data={'type': 'course_completed', 'course_id': course.id}
- )
-
-
-@receiver(pre_save, sender=CourseLiveSession)
-def handle_live_session_pre_save(sender, instance, **kwargs):
- if instance.pk:
- try:
- old = CourseLiveSession.objects.get(pk=instance.pk)
- instance._previous_is_cancelled = old.is_cancelled
- instance._previous_started_at = old.started_at
- instance._previous_ended_at = old.ended_at
-
- # Reset reminder_sent if rescheduled to a future time
- if old.started_at != instance.started_at:
- if instance.started_at > timezone.now():
- instance.reminder_sent = False
- except CourseLiveSession.DoesNotExist:
- instance._previous_is_cancelled = None
- instance._previous_started_at = None
- instance._previous_ended_at = None
- else:
- instance._previous_is_cancelled = None
- instance._previous_started_at = None
- instance._previous_ended_at = None
-
-
-@receiver(post_save, sender=CourseLiveSession)
-def notify_live_session_changes(sender, instance, created, **kwargs):
- # Event 1: LIVE Class Cancelled
- if instance.is_cancelled:
- was_cancelled = False
- if created:
- was_cancelled = True
- elif hasattr(instance, '_previous_is_cancelled') and instance._previous_is_cancelled is False:
- was_cancelled = True
-
- if was_cancelled:
- participants = Participant.objects.filter(course=instance.course, is_active=True).select_related('student')
- course_title_en = get_localized_field('en', instance.course.title)
- course_title_fa = get_localized_field('fa', instance.course.title)
- for p in participants:
- create_and_send_notification(
- user=p.student,
- title_en="LIVE Class Cancelled",
- body_en=f"The live class '{instance.subject}' for '{course_title_en}' has been cancelled.",
- title_fa="کلاس زنده لغو شد",
- body_fa=f"کلاس زنده «{instance.subject}» برای دوره «{course_title_fa}» لغو شده است.",
- service='imam-javad',
- data={'type': 'live_class_cancelled', 'session_id': instance.id}
- )
- return
-
- # Event 2: LIVE Class Rescheduled
- if not created and hasattr(instance, '_previous_started_at') and instance._previous_started_at is not None:
- if instance._previous_started_at != instance.started_at:
- if not instance.is_cancelled:
- participants = Participant.objects.filter(course=instance.course, is_active=True).select_related('student')
- course_title_en = get_localized_field('en', instance.course.title)
- course_title_fa = get_localized_field('fa', instance.course.title)
- for p in participants:
- create_and_send_notification(
- user=p.student,
- title_en="LIVE Class Rescheduled",
- body_en=f"The live class '{instance.subject}' for '{course_title_en}' has been rescheduled to {instance.started_at.strftime('%Y-%m-%d %H:%M')}.",
- title_fa="کلاس زنده تغییر زمان یافت",
- body_fa=f"زمان کلاس زنده «{instance.subject}» برای دوره «{course_title_fa}» به {instance.started_at.strftime('%Y-%m-%d %H:%M')} تغییر یافته است.",
- service='imam-javad',
- data={'type': 'live_class_rescheduled', 'session_id': instance.id}
- )
-
- # Event 6.2: Missed LIVE Sessions
- if instance.ended_at and not instance.is_cancelled:
- was_ended = False
- if created:
- was_ended = True
- elif hasattr(instance, '_previous_ended_at') and not instance._previous_ended_at:
- was_ended = True
-
- if was_ended:
- last_2_sessions = list(CourseLiveSession.objects.filter(
- course=instance.course,
- ended_at__isnull=False,
- is_cancelled=False
- ).order_by('-started_at')[:2])
-
- if len(last_2_sessions) == 2:
- # Find all active enrolled students of the course
- participants = Participant.objects.filter(course=instance.course, is_active=True).select_related('student')
- course_title_en = get_localized_field('en', instance.course.title)
- course_title_fa = get_localized_field('fa', instance.course.title)
- for p in participants:
- student = p.student
-
- # Check if student joined either session
- joined_latest = LiveSessionUser.objects.filter(session=last_2_sessions[0], user=student).exists()
- joined_second_latest = LiveSessionUser.objects.filter(session=last_2_sessions[1], user=student).exists()
-
- if not joined_latest and not joined_second_latest:
- # Prevent duplicate alert using cache
- cache_key = f"notified_missed_consecutive_live_{student.id}_{last_2_sessions[0].id}_{last_2_sessions[1].id}"
- if not cache.get(cache_key):
- cache.set(cache_key, True, timeout=86400 * 30) # 30 days
- create_and_send_notification(
- user=student,
- title_en="We Missed You at the LIVE Class!",
- body_en=f"You missed the last 2 consecutive live broadcasts for '{course_title_en}'. You can check the recordings in the archive.",
- title_fa="جای شما در کلاس زنده خالی بود!",
- body_fa=f"شما ۲ کلاس زنده اخیر دوره «{course_title_fa}» را از دست دادهاید. میتوانید ویدئوی ضبطشده را در بخش آرشیو مشاهده کنید.",
- service='imam-javad',
- data={'type': 'missed_live_sessions', 'course_id': instance.course.id}
- )
-
-
-@receiver(pre_save, sender=LiveSessionRecording)
-def store_recording_previous_file(sender, instance, **kwargs):
- if instance.pk:
- try:
- old = LiveSessionRecording.objects.get(pk=instance.pk)
- instance._previous_file = old.file
- except LiveSessionRecording.DoesNotExist:
- instance._previous_file = None
- else:
- instance._previous_file = None
-
-
-@receiver(post_save, sender=LiveSessionRecording)
-def notify_recording_available(sender, instance, created, **kwargs):
- if instance.file:
- was_uploaded = False
- if created:
- was_uploaded = True
- elif hasattr(instance, '_previous_file') and not instance._previous_file:
- was_uploaded = True
-
- if was_uploaded:
- session = instance.session
- if not session:
- return
- participants = Participant.objects.filter(course=session.course, is_active=True).select_related('student')
- course_title_en = get_localized_field('en', session.course.title)
- course_title_fa = get_localized_field('fa', session.course.title)
- for p in participants:
- create_and_send_notification(
- user=p.student,
- title_en="LIVE Recording Available",
- body_en=f"The recording for '{session.subject}' in '{course_title_en}' is now available.",
- title_fa="ضبط کلاس زنده در دسترس است",
- body_fa=f"ضبط کلاس زنده «{session.subject}» برای دوره «{course_title_fa}» اکنون در آرشیو در دسترس است.",
- service='imam-javad',
- data={'type': 'live_recording_available', 'recording_id': instance.id}
- )
-
-
diff --git a/apps/course/templates/course/add_student_form.html b/apps/course/templates/course/add_student_form.html
deleted file mode 100644
index ecd1d31..0000000
--- a/apps/course/templates/course/add_student_form.html
+++ /dev/null
@@ -1,29 +0,0 @@
-{% extends "admin/base_site.html" %}
-
-{% load i18n unfold %}
-
-{% block breadcrumbs %}{% endblock %}
-
-{% block extrahead %}
- {{ block.super }}
-
- {{ form.media }}
-{% endblock %}
-
-{% block content %}
-
-{% endblock %}
diff --git a/apps/course/tests.py b/apps/course/tests.py
deleted file mode 100644
index 7ce503c..0000000
--- a/apps/course/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/apps/course/tests/__init__.py b/apps/course/tests/__init__.py
deleted file mode 100644
index 8b13789..0000000
--- a/apps/course/tests/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/course/tests/test_course_access_guards.py b/apps/course/tests/test_course_access_guards.py
deleted file mode 100644
index 434737e..0000000
--- a/apps/course/tests/test_course_access_guards.py
+++ /dev/null
@@ -1,133 +0,0 @@
-from django.core.files.uploadedfile import SimpleUploadedFile
-from rest_framework import status
-from rest_framework.test import APITestCase
-
-from apps.account.models import ProfessorUser, StudentUser
-from apps.course.models.course import Course, CourseCategory
-from apps.course.models.lesson import CourseChapter, CourseLesson, Lesson
-from apps.quiz.models.quiz import Quiz, Question
-
-
-class CourseAccessGuardsTests(APITestCase):
- def setUp(self):
- self.professor = ProfessorUser.objects.create(
- email='prof-guard@example.com',
- fullname='Professor Guard',
- experience_years=5,
- )
- self.outsider = StudentUser.objects.create(
- email='outsider@example.com',
- fullname='Outsider Student',
- )
- self.category = CourseCategory.objects.create(
- name='Guard Category',
- slug='guard-category',
- )
- thumbnail = SimpleUploadedFile('guard.jpg', b'filecontent', content_type='image/jpeg')
- self.course = Course.objects.create(
- title='Guard Course',
- slug='guard-course',
- category=self.category,
- professor=self.professor,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=False,
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=0,
- description='Description',
- short_description='Short description',
- status=Course.StatusChoices.ONGOING,
- is_free=True,
- )
- self.chapter = CourseChapter.objects.create(
- course=self.course,
- title='Chapter 1',
- priority=1,
- is_active=True,
- )
- lesson = Lesson.objects.create(
- title='Lesson 1',
- content_type=Lesson.ContentTypeChoices.VIDEO_FILE,
- duration=5,
- )
- self.course_lesson = CourseLesson.objects.create(
- course=self.course,
- chapter=self.chapter,
- lesson=lesson,
- priority=1,
- is_active=True,
- )
- self.quiz = Quiz.objects.create(
- lesson=self.course_lesson,
- course=self.course,
- title='Guard Quiz',
- description='Quiz description',
- each_question_timing=30,
- status=True,
- )
- self.question = Question.objects.create(
- quiz=self.quiz,
- question='What is 2+2?',
- option1='4',
- option2='3',
- option3='2',
- option4='1',
- correct_answer=1,
- priority=1,
- )
-
- def test_lesson_list_marks_lessons_and_quizzes_inactive_without_course_access(self):
- self.client.force_authenticate(user=self.outsider)
-
- response = self.client.get(f'/api/courses/{self.course.slug}/lessons/')
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- lesson_data = response.data['results'][0]
- self.assertFalse(lesson_data['permission'])
- self.assertFalse(lesson_data['is_active'])
- self.assertFalse(lesson_data['quizs'][0]['permission'])
-
- def test_lesson_list_v2_marks_chapter_inactive_without_course_access(self):
- self.client.force_authenticate(user=self.outsider)
-
- response = self.client.get(f'/api/courses/v2/{self.course.slug}/lessons/')
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- chapter_data = response.data['results'][0]
- self.assertFalse(chapter_data['is_active'])
- self.assertFalse(chapter_data['lessons'][0]['is_active'])
-
- def test_quiz_detail_rejects_users_without_course_access(self):
- self.client.force_authenticate(user=self.outsider)
-
- response = self.client.get(f'/api/quiz/{self.quiz.id}/')
-
- self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
- def test_quiz_submit_rejects_users_without_course_access(self):
- self.client.force_authenticate(user=self.outsider)
-
- payload = {
- 'quiz': self.quiz.id,
- 'started_at': '2026-06-02T10:00:00Z',
- 'ended_at': '2026-06-02T10:01:00Z',
- 'total_timing': 60,
- 'question_score': 1,
- 'timing_score': 1,
- 'total_score': 2,
- 'answers': [
- {
- 'question': self.question.id,
- 'option_num': 1,
- 'at_time': '2026-06-02T10:00:30Z',
- 'answer_timing': 30,
- }
- ],
- }
-
- response = self.client.post('/api/quiz/submit-quiz/', payload, format='json')
-
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertIn('quiz', response.data)
diff --git a/apps/course/tests/test_lessons_count_sync.py b/apps/course/tests/test_lessons_count_sync.py
deleted file mode 100644
index 468002a..0000000
--- a/apps/course/tests/test_lessons_count_sync.py
+++ /dev/null
@@ -1,174 +0,0 @@
-from io import StringIO
-
-from django.core.files.uploadedfile import SimpleUploadedFile
-from django.core.management import call_command
-from django.test import TestCase
-
-from apps.account.models import ProfessorUser
-from apps.course.models.course import Course, CourseCategory
-from apps.course.models.lesson import CourseChapter, CourseLesson, Lesson
-
-
-class LessonsCountSyncTests(TestCase):
- def setUp(self):
- self.professor = ProfessorUser.objects.create(
- email='prof-sync@example.com',
- fullname='Professor Sync',
- experience_years=5,
- )
- self.category = CourseCategory.objects.create(
- name='Sync Category',
- slug='sync-category',
- )
-
- def _create_course(self, slug, title, lessons_count=0):
- thumbnail = SimpleUploadedFile(f'{slug}.jpg', b'filecontent', content_type='image/jpeg')
- return Course.objects.create(
- title=title,
- slug=slug,
- category=self.category,
- professor=self.professor,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=False,
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=lessons_count,
- description='Description',
- short_description='Short description',
- status=Course.StatusChoices.ONGOING,
- is_free=True,
- )
-
- def _create_chapter(self, course, title='Chapter', priority=1):
- return CourseChapter.objects.create(
- course=course,
- title=title,
- priority=priority,
- is_active=True,
- )
-
- def _create_course_lesson(self, course, chapter=None, title='Lesson', is_active=True, priority=1):
- if chapter is None:
- chapter = self._create_chapter(course, title=f'{course.title} Chapter', priority=priority)
-
- lesson = Lesson.objects.create(
- title=title,
- content_type=Lesson.ContentTypeChoices.VIDEO_FILE,
- duration=5,
- )
- return CourseLesson.objects.create(
- course=course,
- chapter=chapter,
- lesson=lesson,
- priority=priority,
- is_active=is_active,
- )
-
- def test_creating_active_course_lesson_updates_lessons_count(self):
- course = self._create_course('create-active-course', 'Create Active Course')
-
- self._create_course_lesson(course, title='Lesson 1')
-
- course.refresh_from_db()
- self.assertEqual(course.lessons_count, 1)
-
- def test_deleting_active_course_lesson_updates_lessons_count(self):
- course = self._create_course('delete-active-course', 'Delete Active Course')
- course_lesson = self._create_course_lesson(course, title='Lesson 1')
-
- course.refresh_from_db()
- self.assertEqual(course.lessons_count, 1)
-
- course_lesson.delete()
-
- course.refresh_from_db()
- self.assertEqual(course.lessons_count, 0)
-
- def test_toggling_is_active_updates_lessons_count(self):
- course = self._create_course('toggle-active-course', 'Toggle Active Course')
- course_lesson = self._create_course_lesson(course, title='Lesson 1', is_active=False)
-
- course.refresh_from_db()
- self.assertEqual(course.lessons_count, 0)
-
- course_lesson.is_active = True
- course_lesson.save()
-
- course.refresh_from_db()
- self.assertEqual(course.lessons_count, 1)
-
- course_lesson.is_active = False
- course_lesson.save()
-
- course.refresh_from_db()
- self.assertEqual(course.lessons_count, 0)
-
- def test_moving_course_lesson_updates_old_and_new_course_counts(self):
- old_course = self._create_course('old-course', 'Old Course')
- new_course = self._create_course('new-course', 'New Course')
- old_chapter = self._create_chapter(old_course, title='Old Chapter', priority=1)
- new_chapter = self._create_chapter(new_course, title='New Chapter', priority=1)
- course_lesson = self._create_course_lesson(
- old_course,
- chapter=old_chapter,
- title='Lesson 1',
- is_active=True,
- priority=1,
- )
-
- old_course.refresh_from_db()
- new_course.refresh_from_db()
- self.assertEqual(old_course.lessons_count, 1)
- self.assertEqual(new_course.lessons_count, 0)
-
- course_lesson.chapter = new_chapter
- course_lesson.save()
-
- old_course.refresh_from_db()
- new_course.refresh_from_db()
- self.assertEqual(old_course.lessons_count, 0)
- self.assertEqual(new_course.lessons_count, 1)
-
- def test_inactive_lessons_are_not_counted(self):
- course = self._create_course('inactive-course', 'Inactive Course')
-
- self._create_course_lesson(course, title='Lesson 1', is_active=False)
- self._create_course_lesson(course, title='Lesson 2', is_active=True, priority=2)
-
- course.refresh_from_db()
- self.assertEqual(course.lessons_count, 1)
-
- def test_sync_course_lessons_count_command_updates_mismatched_courses(self):
- first_course = self._create_course('sync-first-course', 'Sync First Course', lessons_count=99)
- second_course = self._create_course('sync-second-course', 'Sync Second Course', lessons_count=7)
-
- self._create_course_lesson(first_course, title='Lesson 1', is_active=True)
- self._create_course_lesson(first_course, title='Lesson 2', is_active=False, priority=2)
- self._create_course_lesson(second_course, title='Lesson 3', is_active=True)
- self._create_course_lesson(second_course, title='Lesson 4', is_active=True, priority=2)
-
- Course.objects.filter(pk=first_course.pk).update(lessons_count=99)
- Course.objects.filter(pk=second_course.pk).update(lessons_count=7)
-
- stdout = StringIO()
- call_command('sync_course_lessons_count', stdout=stdout)
-
- first_course.refresh_from_db()
- second_course.refresh_from_db()
- self.assertEqual(first_course.lessons_count, 1)
- self.assertEqual(second_course.lessons_count, 2)
- self.assertIn('Sync complete. 2 course(s) updated.', stdout.getvalue())
-
- def test_sync_course_lessons_count_command_dry_run_does_not_persist_changes(self):
- course = self._create_course('sync-dry-course', 'Sync Dry Course', lessons_count=5)
- self._create_course_lesson(course, title='Lesson 1', is_active=True)
- Course.objects.filter(pk=course.pk).update(lessons_count=5)
-
- stdout = StringIO()
- call_command('sync_course_lessons_count', '--dry-run', stdout=stdout)
-
- course.refresh_from_db()
- self.assertEqual(course.lessons_count, 5)
- self.assertIn('Dry run complete. 1 course(s) would be updated.', stdout.getvalue())
diff --git a/apps/course/tests/test_live_session_api.py b/apps/course/tests/test_live_session_api.py
deleted file mode 100644
index c3186b8..0000000
--- a/apps/course/tests/test_live_session_api.py
+++ /dev/null
@@ -1,273 +0,0 @@
-import tempfile
-from unittest import mock
-
-from django.core.files.uploadedfile import SimpleUploadedFile
-from django.test import override_settings
-from django.urls import reverse
-from django.utils import timezone
-from dj_language.models import Language
-from rest_framework import status
-from rest_framework.test import APITestCase
-
-from apps.account.models import ProfessorUser, StudentUser
-from apps.course.models import (
- Course,
- CourseCategory,
- CourseLiveSession,
- Participant,
-)
-from apps.course.views.course import get_course_slug_value
-
-
-@override_settings(
- PLUGNMEET_SERVER_URL='https://meet.example.com',
- PLUGNMEET_API_KEY='test-key',
- PLUGNMEET_API_SECRET='test-secret',
- MEDIA_ROOT=tempfile.gettempdir(),
- ONLINE_CLASS_FRONTEND_DOMAIN='http://testserver',
-)
-class CourseLiveSessionAPITests(APITestCase):
- def setUp(self):
- Language.objects.update_or_create(
- id=69,
- defaults={
- 'code': 'en',
- 'name': 'English',
- 'status': True,
- 'countries': [],
- },
- )
- self.professor = ProfessorUser.objects.create(
- email='prof@example.com',
- fullname='Professor Sample',
- experience_years=5,
- )
- self.student = StudentUser.objects.create(
- email='student@example.com',
- fullname='Student Sample',
- )
- self.category = CourseCategory.objects.create(name='Category', slug='category')
- thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
- self.course = Course.objects.create(
- title='Sample Course',
- slug='sample-course',
- category=self.category,
- professor=self.professor,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=True,
- online_link='https://example.com/live',
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=2,
- description='Description',
- short_description='Short',
- status=Course.StatusChoices.ONGOING,
- is_free=True,
- )
- professor_avatar = SimpleUploadedFile('prof-avatar.jpg', b'avatar', content_type='image/jpeg')
- self.professor.avatar = professor_avatar
- self.professor.save(update_fields=['avatar'])
- student_avatar = SimpleUploadedFile('student-avatar.jpg', b'avatar', content_type='image/jpeg')
- self.student.avatar = student_avatar
- self.student.save(update_fields=['avatar'])
-
- @mock.patch('apps.course.views.live_session.PlugNMeetClient')
- def test_professor_can_create_room(self, mock_client_cls):
- mock_client = mock_client_cls.return_value
- mock_client.create_room.return_value = {'status': 'success'}
-
- self.client.force_authenticate(user=self.professor)
- url = reverse('course-live-session-room-create', kwargs={'slug': self.course.slug})
- payload = {
- 'room_id': 'custom-room-id',
- 'subject': 'Algebra Session',
- }
- response = self.client.post(url, payload, format='json')
-
- self.assertEqual(response.status_code, status.HTTP_201_CREATED)
- mock_client.create_room.assert_called_once()
- self.assertTrue(
- CourseLiveSession.objects.filter(course=self.course, room_id='custom-room-id').exists()
- )
-
- @mock.patch('apps.course.views.live_session.PlugNMeetClient')
- def test_professor_receives_admin_token(self, mock_client_cls):
- mock_client = mock_client_cls.return_value
- mock_client.get_join_token.return_value = {'token': 'abc123'}
-
- session = CourseLiveSession.objects.create(
- course=self.course,
- subject='Session',
- started_at=timezone.now(),
- room_id='room-123',
- )
-
- self.client.force_authenticate(user=self.professor)
- url = reverse('course-live-session-token')
- response = self.client.post(url, {'room_id': session.room_id}, format='json')
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- args, _ = mock_client.get_join_token.call_args
- payload = args[0]
- self.assertTrue(payload['user_info']['is_admin'])
- profile_pic = payload['user_info']['user_metadata'].get('profilePic')
- self.assertEqual(profile_pic, f"http://testserver{self.professor.avatar.url}")
- self.assertEqual(response.data['token'], 'abc123')
-
- @mock.patch('apps.course.views.live_session.PlugNMeetClient')
- def test_student_participant_receives_limited_token(self, mock_client_cls):
- mock_client = mock_client_cls.return_value
- mock_client.get_join_token.return_value = {'token': 'student-token'}
-
- session = CourseLiveSession.objects.create(
- course=self.course,
- subject='Session',
- started_at=timezone.now(),
- room_id='room-456',
- )
- Participant.objects.create(course=self.course, student=self.student)
-
- self.client.force_authenticate(user=self.student)
- url = reverse('course-live-session-token')
- response = self.client.post(url, {'room_id': session.room_id}, format='json')
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- args, _ = mock_client.get_join_token.call_args
- payload = args[0]
- self.assertFalse(payload['user_info']['is_admin'])
- metadata = payload['user_info']['user_metadata']
- self.assertIn('lock_microphone', metadata['lock_settings'])
- self.assertEqual(metadata.get('profilePic'), f"http://testserver{self.student.avatar.url}")
- self.assertEqual(response.data['token'], 'student-token')
-
- def test_student_without_access_cannot_get_token(self):
- session = CourseLiveSession.objects.create(
- course=self.course,
- subject='Session',
- started_at=timezone.now(),
- room_id='room-789',
- )
-
- self.client.force_authenticate(user=self.student)
- url = reverse('course-live-session-token')
- response = self.client.post(url, {'room_id': session.room_id}, format='json')
-
- self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
- @mock.patch('apps.course.views.course.PlugNMeetClient')
- def test_validate_metadata_includes_active_room_for_student(self, mock_client_cls):
- mock_client = mock_client_cls.return_value
- mock_client.is_room_active.return_value = {'status': True, 'msg': 'room is active', 'isActive': True}
- mock_client.get_join_token.return_value = {'token': 'joined-student-token'}
-
- session = CourseLiveSession.objects.create(
- course=self.course,
- subject='Session Live',
- started_at=timezone.now(),
- room_id='room-live-1',
- )
- Participant.objects.create(course=self.course, student=self.student)
-
- self.client.force_authenticate(user=self.student)
- url = reverse(
- 'course-online-validate',
- kwargs={'slug': get_course_slug_value(self.course)},
- )
- response = self.client.get(url)
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- metadata = response.data['metadata']
- self.assertTrue(metadata['is_online'])
- self.assertEqual(metadata['active_room_id'], session.room_id)
- self.assertTrue(metadata['can_join_live_session'])
- self.assertEqual(metadata['live_session']['room_id'], session.room_id)
- self.assertIsNotNone(metadata['live_session']['started_at'])
- self.assertEqual(
- response.data['redirect_path'],
- 'http://testserver/?access_token=joined-student-token'
- )
- self.assertEqual(
- metadata['redirect_path'],
- 'http://testserver/?access_token=joined-student-token'
- )
-
- @mock.patch('apps.course.views.course.PlugNMeetClient')
- def test_validate_metadata_for_professor_hides_creation_when_online(self, mock_client_cls):
- mock_client = mock_client_cls.return_value
- mock_client.is_room_active.return_value = {'status': True, 'msg': 'room is active', 'isActive': True}
- mock_client.get_join_token.return_value = {'token': 'joined-prof-token'}
-
- CourseLiveSession.objects.create(
- course=self.course,
- subject='Session Live',
- started_at=timezone.now(),
- room_id='room-live-2',
- )
-
- self.client.force_authenticate(user=self.professor)
- url = reverse(
- 'course-online-validate',
- kwargs={'slug': get_course_slug_value(self.course)},
- )
- response = self.client.get(url)
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- metadata = response.data['metadata']
- self.assertFalse(metadata['can_create_live_session'])
- self.assertEqual(
- response.data['redirect_path'],
- 'http://testserver/?access_token=joined-prof-token'
- )
-
- @mock.patch('apps.course.views.course.PlugNMeetClient')
- def test_validate_returns_direct_access_token_for_professor_when_class_is_not_online(
- self,
- mock_client_cls,
- ):
- mock_client = mock_client_cls.return_value
- mock_client.create_room.return_value = {'status': 'success'}
- mock_client.get_join_token.return_value = {'token': 'teacher-access-token'}
- self.client.force_authenticate(user=self.professor)
-
- url = reverse(
- 'course-online-validate',
- kwargs={'slug': get_course_slug_value(self.course)},
- )
- response = self.client.get(url)
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- metadata = response.data['metadata']
- self.assertFalse(metadata['is_online'])
- self.assertTrue(metadata['can_create_live_session'])
- self.assertEqual(
- response.data['redirect_path'],
- 'http://testserver/?access_token=teacher-access-token',
- )
- self.assertEqual(metadata['redirect_path'], response.data['redirect_path'])
- self.assertTrue(
- CourseLiveSession.objects.filter(course=self.course, ended_at__isnull=True).exists()
- )
-
- @mock.patch(
- 'apps.course.views.course.CourseOnlineClassTokenValidateAPIView._generate_entry_token',
- return_value='waiting-token',
- )
- def test_validate_returns_waiting_redirect_when_class_is_not_online(self, _mock_generate_entry_token):
- Participant.objects.create(course=self.course, student=self.student)
- self.client.force_authenticate(user=self.student)
-
- url = reverse(
- 'course-online-validate',
- kwargs={'slug': get_course_slug_value(self.course)},
- )
- response = self.client.get(url)
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- metadata = response.data['metadata']
- self.assertFalse(metadata['is_online'])
- self.assertFalse(metadata['can_join_live_session'])
- self.assertEqual(response.data['redirect_path'], 'http://testserver/?token=waiting-token&slug=sample-course')
- self.assertIn('&slug=sample-course', response.data['redirect_path'])
- self.assertEqual(metadata['redirect_path'], response.data['redirect_path'])
diff --git a/apps/course/tests/test_live_session_subjects.py b/apps/course/tests/test_live_session_subjects.py
deleted file mode 100644
index 2bfd229..0000000
--- a/apps/course/tests/test_live_session_subjects.py
+++ /dev/null
@@ -1,133 +0,0 @@
-import tempfile
-from unittest import mock
-
-from django.core.files.uploadedfile import SimpleUploadedFile
-from django.test import override_settings
-from django.urls import reverse
-from django.utils import timezone
-from dj_language.models import Language
-from rest_framework import status
-from rest_framework.test import APITestCase
-
-from apps.account.models import ProfessorUser
-from apps.course.models import Course, CourseCategory, CourseLiveSession
-from apps.course.views.course import get_course_slug_value
-
-
-@override_settings(
- PLUGNMEET_SERVER_URL='https://meet.example.com',
- PLUGNMEET_API_KEY='test-key',
- PLUGNMEET_API_SECRET='test-secret',
- MEDIA_ROOT=tempfile.gettempdir(),
- ONLINE_CLASS_FRONTEND_DOMAIN='http://testserver',
-)
-class CourseLiveSessionSubjectTests(APITestCase):
- def setUp(self):
- Language.objects.update_or_create(
- id=69,
- defaults={
- 'code': 'en',
- 'name': 'English',
- 'status': True,
- 'countries': [],
- },
- )
- self.professor = ProfessorUser.objects.create(
- email='subject-prof@example.com',
- fullname='Subject Professor',
- experience_years=5,
- )
- self.category = CourseCategory.objects.create(name='Category', slug='category')
- thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
- self.course = Course.objects.create(
- title=[{'title': 'Sample Course', 'language_code': 'en'}],
- slug=[{'title': 'sample-course', 'language_code': 'en'}],
- category=self.category,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=True,
- online_link='https://example.com/live',
- level=[{'title': 'beginner', 'language_code': 'en'}],
- duration=10,
- lessons_count=2,
- description=[{'title': 'Description', 'language_code': 'en'}],
- short_description=[{'title': 'Short', 'language_code': 'en'}],
- status=[{'title': 'ongoing', 'language_code': 'en'}],
- is_free=True,
- )
- self.course.professors.add(self.professor)
-
- @mock.patch('apps.course.views.live_session.PlugNMeetClient')
- def test_room_create_uses_incremental_subject(self, mock_client_cls):
- mock_client_cls.return_value.create_room.return_value = {'status': 'success'}
- CourseLiveSession.objects.create(
- course=self.course,
- room_id='room-old',
- subject='Live Session 1',
- started_at=timezone.now(),
- ended_at=timezone.now(),
- )
-
- self.client.force_authenticate(user=self.professor)
- url = reverse(
- 'course-live-session-room-create',
- kwargs={'slug': get_course_slug_value(self.course)},
- )
- response = self.client.post(url, {}, format='json')
-
- self.assertEqual(response.status_code, status.HTTP_201_CREATED)
- session = CourseLiveSession.objects.filter(
- course=self.course,
- ended_at__isnull=True,
- ).latest('id')
- self.assertEqual(session.subject, 'Live Session 2')
-
- @mock.patch('apps.course.views.course.PlugNMeetClient')
- def test_validate_professor_create_uses_incremental_subject(self, mock_client_cls):
- mock_client = mock_client_cls.return_value
- mock_client.create_room.return_value = {'status': 'success'}
- mock_client.get_join_token.return_value = {'token': 'teacher-access-token'}
- CourseLiveSession.objects.create(
- course=self.course,
- room_id='room-old',
- subject='Live Session 1',
- started_at=timezone.now(),
- ended_at=timezone.now(),
- )
-
- self.client.force_authenticate(user=self.professor)
- url = reverse(
- 'course-online-validate',
- kwargs={'slug': get_course_slug_value(self.course)},
- )
- response = self.client.get(url)
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- session = CourseLiveSession.objects.filter(
- course=self.course,
- ended_at__isnull=True,
- ).latest('id')
- self.assertEqual(session.subject, 'Live Session 2')
-
- def test_set_recording_title_updates_session_subject(self):
- session = CourseLiveSession.objects.create(
- course=self.course,
- room_id='room-active',
- subject='Live Session 1',
- started_at=timezone.now(),
- )
-
- response = self.client.post(
- reverse('course-live-session-set-recording-title'),
- {
- 'room_id': session.room_id,
- 'title': 'machine learning',
- },
- format='json',
- )
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- session.refresh_from_db()
- self.assertEqual(session.recording_title, 'machine learning')
- self.assertEqual(session.subject, 'machine learning')
diff --git a/apps/course/tests/test_multiple_roles_api.py b/apps/course/tests/test_multiple_roles_api.py
deleted file mode 100644
index 9eacac5..0000000
--- a/apps/course/tests/test_multiple_roles_api.py
+++ /dev/null
@@ -1,254 +0,0 @@
-"""
-تستهای API برای سیستم نقشهای چندگانه
-"""
-from django.test import TestCase
-from django.urls import reverse
-from rest_framework.test import APIClient
-from rest_framework import status
-from django.contrib.auth.models import Group
-from decimal import Decimal
-from apps.account.models import User
-from apps.course.models import Course, CourseCategory, Participant
-from apps.transaction.models import TransactionParticipant
-
-
-class MultipleRolesAPITestCase(TestCase):
- def setUp(self):
- """راهاندازی دادههای تست"""
- # ایجاد گروهها
- Group.objects.create(name="Professor Group")
- Group.objects.create(name="Student Group")
- Group.objects.create(name="Client Group")
-
- # ایجاد کاربر
- self.user = User.objects.create_user(
- email='test@example.com',
- fullname='Test User',
- password='testpass123'
- )
-
- # ایجاد دستهبندی دوره
- self.category = CourseCategory.objects.create(
- name='Test Category',
- slug='test-category'
- )
-
- # راهاندازی API client
- self.client = APIClient()
- self.client.force_authenticate(user=self.user)
-
- def test_user_profile_basic_functionality(self):
- """تست عملکرد اصلی profile کاربر"""
- # اضافه کردن نقشها
- self.user.add_role('professor')
- self.user.add_role('student')
-
- # تست متدهای جدید User model
- self.assertTrue(self.user.has_role('professor'))
- self.assertTrue(self.user.has_role('student'))
-
- roles = self.user.get_all_roles()
- self.assertIn('professor', roles)
- self.assertIn('student', roles)
-
- # نقش اصلی باید professor باشد (اولویت بالاتر)
- self.assertEqual(self.user.primary_role, User.UserType.PROFESSOR)
-
- def test_course_access_for_professor(self):
- """تست دسترسی استاد به دوره خودش"""
- # کاربر استاد میشود و دوره میسازد
- self.user.add_role('professor')
-
- course = Course.objects.create(
- title='Test Course',
- slug='test-course',
- category=self.category,
- professor=self.user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Test description'
- )
-
- # تست serializer
- from apps.course.serializers import CourseDetailSerializer
-
- # شبیهسازی request context
- from django.test import RequestFactory
- factory = RequestFactory()
- request = factory.get('/')
- request.user = self.user
-
- serializer = CourseDetailSerializer(course, context={'request': request})
- data = serializer.data
-
- # استاد باید دسترسی داشته باشد
- self.assertTrue(data['access'])
-
- def test_course_enrollment_preserves_professor_role(self):
- """تست اینکه ثبتنام در دوره نقش professor را حفظ میکند"""
- # کاربر استاد میشود
- self.user.add_role('professor')
-
- # کاربر دیگری دوره میسازد
- other_user = User.objects.create_user(
- email='other@example.com',
- fullname='Other User',
- password='testpass123'
- )
- other_user.add_role('professor')
-
- course = Course.objects.create(
- title='Test Course',
- slug='test-course',
- category=self.category,
- professor=other_user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Test description',
- is_free=True
- )
-
- # شبیهسازی transaction
- transaction_data = {
- 'participant_infos': [{'email': self.user.email}]
- }
-
- # شبیهسازی منطق transaction
- if not self.user.has_role('student'):
- self.user.add_role('student')
-
- Participant.objects.create(
- student=self.user,
- course=course
- )
-
- # بررسی اینکه هر دو نقش حفظ شدهاند
- self.assertTrue(self.user.has_role('professor'))
- self.assertTrue(self.user.has_role('student'))
-
- # بررسی اینکه کاربر میتواند دوره خودش را مدیریت کند
- own_course = Course.objects.create(
- title='Own Course',
- slug='own-course',
- category=self.category,
- professor=self.user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Own course description'
- )
-
- self.assertTrue(self.user.can_manage_course(own_course))
- self.assertFalse(self.user.can_manage_course(course)) # دوره دیگری
-
- def test_course_access_for_professor_student(self):
- """تست دسترسی دوره برای کاربری که هم استاد و هم دانشآموز است"""
- # کاربر استاد میشود
- self.user.add_role('professor')
-
- # دوره خودش
- own_course = Course.objects.create(
- title='Own Course',
- slug='own-course',
- category=self.category,
- professor=self.user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Own course description'
- )
-
- # دوره دیگری
- other_user = User.objects.create_user(
- email='other@example.com',
- fullname='Other User',
- password='testpass123'
- )
- other_user.add_role('professor')
-
- other_course = Course.objects.create(
- title='Other Course',
- slug='other-course',
- category=self.category,
- professor=other_user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Other course description'
- )
-
- # کاربر در دوره دیگری شرکت میکند
- self.user.add_role('student')
- Participant.objects.create(
- student=self.user,
- course=other_course
- )
-
- # تست دسترسیها
- from apps.course.serializers import CourseDetailSerializer
- from django.test import RequestFactory
-
- factory = RequestFactory()
- request = factory.get('/')
- request.user = self.user
-
- # دسترسی به دوره خودش
- serializer = CourseDetailSerializer(own_course, context={'request': request})
- data = serializer.data
- self.assertTrue(data['access'])
-
- # دسترسی به دوره دیگری (به عنوان participant)
- serializer = CourseDetailSerializer(other_course, context={'request': request})
- data = serializer.data
- self.assertTrue(data['access'])
-
- def test_backward_compatibility(self):
- """تست سازگاری با کدهای قدیمی"""
- # property قدیمی باید همچنان کار کند
- self.user.add_role('student')
- self.assertEqual(self.user.user_type_based_on_groups, User.UserType.STUDENT)
-
- self.user.add_role('professor')
- self.assertEqual(self.user.user_type_based_on_groups, User.UserType.PROFESSOR)
-
- # user_type field باید بروزرسانی شود
- self.assertEqual(self.user.user_type, User.UserType.PROFESSOR)
-
- def test_course_detail_serializer_uses_rub_price_for_russian_language(self):
- """برای زبان روسی باید قیمت روبلی برگردانده شود، نه دلار"""
- self.user.add_role('professor')
-
- course = Course.objects.create(
- title=[
- {"title": "English Title", "language_code": "en"},
- {"title": "Русский заголовок", "language_code": "ru"},
- ],
- slug='localized-course',
- category=self.category,
- professor=self.user,
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Test description',
- is_free=False,
- price=Decimal('100.00'),
- price_rub=Decimal('7500.00'),
- discount_percentage=10,
- )
-
- from apps.course.serializers import CourseDetailSerializer
- from django.test import RequestFactory
-
- factory = RequestFactory()
- request = factory.get('/', HTTP_ACCEPT_LANGUAGE='ru')
- request.user = self.user
- request.LANGUAGE_CODE = 'ru'
-
- serializer = CourseDetailSerializer(course, context={'request': request})
- data = serializer.data
-
- self.assertEqual(data['currency'], 'RUB')
- self.assertEqual(data['price'], '7500.00')
- self.assertEqual(data['final_price'], '6750.00')
diff --git a/apps/course/tests/test_my_courses_api.py b/apps/course/tests/test_my_courses_api.py
deleted file mode 100644
index 11a95e1..0000000
--- a/apps/course/tests/test_my_courses_api.py
+++ /dev/null
@@ -1,145 +0,0 @@
-from django.core.files.uploadedfile import SimpleUploadedFile
-from django.core.files.uploadedfile import SimpleUploadedFile
-from django.urls import reverse
-from rest_framework import status
-from rest_framework.test import APITestCase
-
-from apps.account.models import ProfessorUser, StudentUser
-from apps.course.models.course import Course, CourseCategory
-from apps.course.models.lesson import CourseChapter, CourseLesson, Lesson, LessonCompletion
-from apps.course.models.participant import Participant
-
-
-class MyCoursesAPITests(APITestCase):
- def setUp(self):
- self.professor = ProfessorUser.objects.create(
- email='prof@example.com',
- fullname='Professor Example',
- experience_years=5,
- )
- self.student = StudentUser.objects.create(
- email='student@example.com',
- fullname='Student Example',
- )
- self.category = CourseCategory.objects.create(
- name='Category',
- slug='category',
- )
-
- def _create_course(self, slug, title):
- thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
- return Course.objects.create(
- title=title,
- slug=slug,
- category=self.category,
- professor=self.professor,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=False,
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=0,
- description='Description',
- short_description='Short description',
- status=Course.StatusChoices.ONGOING,
- is_free=True,
- )
-
- def _attach_lessons(self, course, lesson_specs):
- chapter = CourseChapter.objects.create(
- course=course,
- title=f'{course.title} Chapter',
- priority=1,
- is_active=True,
- )
-
- created_lessons = []
- for index, spec in enumerate(lesson_specs, start=1):
- lesson = Lesson.objects.create(
- title=spec['title'],
- content_type=Lesson.ContentTypeChoices.VIDEO_FILE,
- duration=5,
- )
- course_lesson = CourseLesson.objects.create(
- course=course,
- chapter=chapter,
- lesson=lesson,
- priority=index,
- is_active=spec.get('is_active', True),
- )
- created_lessons.append(course_lesson)
-
- return created_lessons
-
- def test_completed_filter_returns_only_courses_with_all_active_lessons_completed(self):
- completed_course = self._create_course('completed-course', 'Completed Course')
- partial_course = self._create_course('partial-course', 'Partial Course')
- empty_course = self._create_course('empty-course', 'Empty Course')
-
- completed_lessons = self._attach_lessons(
- completed_course,
- [
- {'title': 'Completed Lesson 1'},
- {'title': 'Completed Lesson 2'},
- {'title': 'Inactive Lesson', 'is_active': False},
- ],
- )
- partial_lessons = self._attach_lessons(
- partial_course,
- [
- {'title': 'Partial Lesson 1'},
- {'title': 'Partial Lesson 2'},
- ],
- )
-
- Participant.objects.create(student=self.student, course=completed_course)
- Participant.objects.create(student=self.student, course=partial_course)
- Participant.objects.create(student=self.student, course=empty_course)
-
- LessonCompletion.objects.create(student=self.student, course_lesson=completed_lessons[0])
- LessonCompletion.objects.create(student=self.student, course_lesson=completed_lessons[1])
- LessonCompletion.objects.create(student=self.student, course_lesson=partial_lessons[0])
-
- self.client.force_authenticate(user=self.student)
- url = reverse('course-my-courses-list')
- response = self.client.get(url, {'completed': 'true'}, format='json')
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(response.data['count'], 1)
- self.assertEqual({item['slug'] for item in response.data['results']}, {completed_course.slug})
-
- def test_my_courses_without_completed_filter_returns_completed_and_incomplete_courses(self):
- completed_course = self._create_course('all-completed-course', 'All Completed Course')
- partial_course = self._create_course('all-partial-course', 'All Partial Course')
-
- completed_lessons = self._attach_lessons(
- completed_course,
- [
- {'title': 'Completed Lesson 1'},
- ],
- )
- partial_lessons = self._attach_lessons(
- partial_course,
- [
- {'title': 'Partial Lesson 1'},
- {'title': 'Partial Lesson 2'},
- ],
- )
-
- Participant.objects.create(student=self.student, course=completed_course)
- Participant.objects.create(student=self.student, course=partial_course)
-
- LessonCompletion.objects.create(student=self.student, course_lesson=completed_lessons[0])
- LessonCompletion.objects.create(student=self.student, course_lesson=partial_lessons[0])
-
- self.client.force_authenticate(user=self.student)
- url = reverse('course-my-courses-list')
- response = self.client.get(url, format='json')
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(response.data['count'], 2)
- self.assertEqual(
- {item['slug'] for item in response.data['results']},
- {completed_course.slug, partial_course.slug},
- )
diff --git a/apps/course/tests/test_professor_api.py b/apps/course/tests/test_professor_api.py
deleted file mode 100644
index 72bd79d..0000000
--- a/apps/course/tests/test_professor_api.py
+++ /dev/null
@@ -1,113 +0,0 @@
-from django.core.files.uploadedfile import SimpleUploadedFile
-from django.urls import reverse
-from rest_framework.test import APITestCase
-
-from apps.account.models import ProfessorUser
-from apps.course.models import Course, CourseCategory, CourseLesson, Lesson
-
-
-class TestProfessorAPI(APITestCase):
- def setUp(self):
- self.professor = ProfessorUser.objects.create(
- email='professor@example.com',
- fullname='استاد نمونه',
- experience_years=7,
- )
- self.category = CourseCategory.objects.create(name='General', slug='general')
- thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
- self.course = Course.objects.create(
- title='Test Course',
- slug='test-course',
- category=self.category,
- professor=self.professor,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=True,
- online_link='https://example.com/classroom',
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=1,
- description='Sample description',
- short_description='Short description',
- status=Course.StatusChoices.ONGOING,
- is_free=True,
- )
- lesson = Lesson.objects.create(
- title='Lesson 1',
- content_type=Lesson.ContentTypeChoices.VIDEO_FILE,
- duration=5,
- )
- CourseLesson.objects.create(course=self.course, lesson=lesson, priority=1, is_active=True)
- self.professor.refresh_from_db()
-
- def test_professor_list_api(self):
- url = reverse('course-professor-list')
- response = self.client.get(url)
- self.assertEqual(response.status_code, 200)
- self.assertEqual(response.data['count'], 1)
- item = response.data['results'][0]
- self.assertEqual(item['slug'], self.professor.slug)
- self.assertEqual(item['fullname'], self.professor.fullname)
- self.assertEqual(item['experience_years'], 7)
- self.assertEqual(item['course_count'], 1)
- self.assertEqual(item['lesson_count'], 1)
-
- def test_professor_detail_api(self):
- url = reverse('course-professor-detail', kwargs={'slug': self.professor.slug})
- response = self.client.get(url)
- self.assertEqual(response.status_code, 200)
- data = response.data
- self.assertEqual(data['slug'], self.professor.slug)
- self.assertEqual(data['fullname'], self.professor.fullname)
- self.assertEqual(data['experience_years'], 7)
- self.assertEqual(data['course_count'], 1)
- self.assertEqual(data['lesson_count'], 1)
-
- def test_professor_courses_api(self):
- url = reverse('course-professor-course-list', kwargs={'slug': self.professor.slug})
- response = self.client.get(url)
- self.assertEqual(response.status_code, 200)
- self.assertEqual(response.data['count'], 1)
- course_data = response.data['results'][0]
- self.assertEqual(course_data['id'], self.course.id)
- self.assertEqual(course_data['title'], self.course.title)
-
- def test_professor_slug_generated_without_fullname(self):
- professor = ProfessorUser.objects.create(
- email='slugless@example.com',
- fullname='',
- )
- self.assertTrue(professor.slug)
-
- def test_course_creation_promotes_professor_user(self):
- professor = ProfessorUser.objects.create(
- email='pending@example.com',
- fullname='کاربر موقت',
- )
- professor.user_type = ProfessorUser.UserType.CLIENT
- professor.slug = None
- professor.save(update_fields=['user_type', 'slug'])
-
- thumbnail = SimpleUploadedFile('thumb2.jpg', b'filecontent', content_type='image/jpeg')
- Course.objects.create(
- title='Auto Promote Course',
- slug='auto-promote-course',
- category=self.category,
- professor=professor,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video2',
- is_online=False,
- level=Course.LevelChoices.BEGINNER,
- duration=5,
- lessons_count=0,
- description='Test',
- short_description='Test',
- status=Course.StatusChoices.REGISTERING,
- is_free=True,
- )
-
- professor.refresh_from_db()
- self.assertEqual(professor.user_type, ProfessorUser.UserType.PROFESSOR)
- self.assertTrue(professor.slug)
diff --git a/apps/course/tests/test_webhook_live_session.py b/apps/course/tests/test_webhook_live_session.py
deleted file mode 100644
index 56ff461..0000000
--- a/apps/course/tests/test_webhook_live_session.py
+++ /dev/null
@@ -1,201 +0,0 @@
-import hashlib
-import hmac
-import json
-import tempfile
-from datetime import timedelta
-from unittest import mock
-
-from django.core.management import call_command
-from django.core.files.uploadedfile import SimpleUploadedFile
-from django.test import override_settings
-from django.urls import reverse
-from django.utils import timezone
-from dj_language.models import Language
-from rest_framework import status
-from rest_framework.test import APITestCase
-
-from apps.account.models import ProfessorUser, StudentUser
-from apps.course.models import (
- Course,
- CourseCategory,
- CourseLiveSession,
- LiveSessionUser,
-)
-
-
-@override_settings(
- PLUGNMEET_API_SECRET='test-secret',
- MEDIA_ROOT=tempfile.gettempdir(),
-)
-class PlugNMeetWebhookLiveSessionTests(APITestCase):
- def setUp(self):
- Language.objects.update_or_create(
- id=69,
- defaults={
- 'name': 'English',
- 'code': 'en',
- 'status': True,
- 'countries': ['US'],
- },
- )
- self.professor = ProfessorUser.objects.create(
- email='webhook-prof@example.com',
- fullname='Webhook Professor',
- experience_years=8,
- )
- self.student = StudentUser.objects.create(
- email='webhook-student@example.com',
- fullname='Webhook Student',
- )
- self.category = CourseCategory.objects.create(
- name='Webhook Category',
- slug='webhook-category',
- )
- thumbnail = SimpleUploadedFile(
- 'thumb.jpg',
- b'filecontent',
- content_type='image/jpeg',
- )
- self.course = Course.objects.create(
- title='Webhook Course',
- slug='webhook-course',
- category=self.category,
- professor=self.professor,
- thumbnail=thumbnail,
- video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
- video_link='https://example.com/video',
- is_online=True,
- online_link='https://example.com/live',
- level=Course.LevelChoices.BEGINNER,
- duration=10,
- lessons_count=1,
- description='Description',
- short_description='Short',
- status=Course.StatusChoices.ONGOING,
- is_free=True,
- )
- self.session = CourseLiveSession.objects.create(
- course=self.course,
- room_id='room-16-1781160705',
- subject='Webhook Course Live Session',
- started_at=timezone.now(),
- )
- LiveSessionUser.objects.create(
- session=self.session,
- user=self.student,
- role='participant',
- entered_at=timezone.now(),
- is_online=True,
- )
-
- def _post_webhook(self, payload: dict):
- body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
- signature = hmac.new(
- b'test-secret',
- body,
- hashlib.sha256,
- ).hexdigest()
- url = reverse('plugnmeet-webhook')
- return self.client.post(
- url,
- data=body,
- content_type='application/webhook+json',
- HTTP_HASH_TOKEN=signature,
- )
-
- def test_room_finished_with_room_name_closes_live_session(self):
- response = self._post_webhook(
- {
- 'event': 'room_finished',
- 'room': {
- 'name': self.session.room_id,
- },
- }
- )
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.session.refresh_from_db()
- self.assertIsNotNone(self.session.ended_at)
- self.assertFalse(
- LiveSessionUser.objects.get(session=self.session, user=self.student).is_online
- )
-
- def test_session_ended_event_closes_live_session(self):
- second_session = CourseLiveSession.objects.create(
- course=self.course,
- room_id='room-16-1781160999',
- subject='Second Live Session',
- started_at=timezone.now(),
- )
-
- response = self._post_webhook(
- {
- 'event': 'session_ended',
- 'room': {
- 'name': second_session.room_id,
- },
- }
- )
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- second_session.refresh_from_db()
- self.assertIsNotNone(second_session.ended_at)
-
- def test_last_moderator_leave_schedules_auto_close(self):
- LiveSessionUser.objects.create(
- session=self.session,
- user=self.professor,
- role='moderator',
- entered_at=timezone.now(),
- is_online=True,
- )
-
- response = self._post_webhook(
- {
- 'event': 'participant_left',
- 'room': {
- 'name': self.session.room_id,
- },
- 'participant': {
- 'identity': str(self.professor.id),
- },
- }
- )
-
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.session.refresh_from_db()
- self.assertIsNotNone(self.session.last_moderator_left_at)
- self.assertIsNotNone(self.session.auto_close_after_moderator_exit_at)
-
- @mock.patch('apps.course.management.commands.auto_close_professorless_live_sessions.PlugNMeetClient')
- def test_auto_close_command_closes_professorless_session(self, mock_client_cls):
- LiveSessionUser.objects.create(
- session=self.session,
- user=self.professor,
- role='moderator',
- entered_at=timezone.now() - timedelta(minutes=20),
- exited_at=timezone.now() - timedelta(minutes=15),
- is_online=False,
- )
- self.session.last_moderator_left_at = timezone.now() - timedelta(minutes=15)
- self.session.auto_close_after_moderator_exit_at = timezone.now() - timedelta(minutes=5)
- self.session.save(
- update_fields=[
- 'last_moderator_left_at',
- 'auto_close_after_moderator_exit_at',
- 'updated_at',
- ]
- )
-
- mock_client = mock_client_cls.return_value
- mock_client.end_room.return_value = {'status': True, 'msg': 'room ended successfully'}
-
- call_command('auto_close_professorless_live_sessions')
-
- self.session.refresh_from_db()
- self.assertIsNotNone(self.session.ended_at)
- self.assertIsNone(self.session.last_moderator_left_at)
- self.assertIsNone(self.session.auto_close_after_moderator_exit_at)
- self.assertFalse(
- LiveSessionUser.objects.get(session=self.session, user=self.student).is_online
- )
diff --git a/apps/course/token-join-guide.md b/apps/course/token-join-guide.md
deleted file mode 100644
index 7386951..0000000
--- a/apps/course/token-join-guide.md
+++ /dev/null
@@ -1,312 +0,0 @@
-# راهنمای گرفتن توکن و ورود کلاینت به کلاسهای plugNmeet
-
-این راهنما خلاصه میکند که برای سناریوی استاد/دانشجو چگونه از سرویس plugNmeet توکن بگیریم و کلاینت فرانتاند (`client/`) با آن وارد کلاس شود.
-
-## پیشنیازها
-- آدرس سرویس: `window.PLUG_N_MEET_SERVER_URL = "https://meet.imamjavad.online"` (در `config.js`).
-- `api_key` و `secret` از فایل پیکربندی بکاند (`services/plugnmeet-server/config.yaml`).
-- بدنهٔ درخواستها باید با پروتکل JSON متناظر با پیامهای پروتوباف (`plugnmeet-protocol`) ارسال شود؛ سرور طبق `HandleAuthHeaderCheck` هدرهای امنیتی را بررسی میکند.
-
-## گام ۱: ایجاد یا فعال بودن اتاق
-
-### API Endpoint برای Django Backend:
-```
-POST /api/courses//online/room/create/
-```
-
-### بدنه درخواست از فرانت به Django:
-```json
-{
- "subject": "کلاس جبر فصل ۱" // اختیاری - عنوان روم
-}
-```
-
-**⚠️ نکات مهم:**
-- **فرانت نباید `metadata` ارسال کند!**
-- بکاند Django (در `apps/course/views/live_session.py`) بهطور خودکار تنظیمات امنیتی را اعمال میکند
-- این تضمین میکند که تنظیمات امنیتی بهصورت متمرکز و یکسان اعمال شود
-
-### بدنه درخواست از Django به PlugNMeet (خودکار):
-بکاند Django این بدنه را خودش به PlugNMeet ارسال میکند:
-
-```json
-{
- "room_id": "algebra-1402",
- "metadata": {
- "room_title": "کلاس جبر فصل ۱",
- "default_lock_settings": {
- "lock_microphone": true, // 🔒 قفل - فقط میزبان میتواند باز کند
- "lock_webcam": true, // 🔒 قفل - فقط میزبان میتواند باز کند
- "lock_screen_sharing": true // 🔒 قفل - فقط میزبان میتواند باز کند
- },
- "room_features": {
- "mute_on_start": true, // 🔇 همه با میک خاموش وارد میشوند
- "waiting_room_features": {
- "is_active": false
- }
- }
- }
-}
-```
-
-> **چرا بکاند این کار را میکند؟**
-> - ✅ **امنیت متمرکز**: تنظیمات امنیتی در یک جا کنترل میشود
-> - ✅ **جلوگیری از دستکاری**: فرانت نمیتواند تنظیمات را تغییر دهد
-> - ✅ **یکپارچگی**: همه کلاسها با تنظیمات یکسان ساخته میشوند
-> - 🔒 طبق تابع `AssignLockSettingsToUser` در `pkg/models/user_lock.go` این مقادیر برای کاربران غیر-admin اعمال میشود
-
-## گام ۲: گرفتن توکن ورود
-
-### API Endpoint برای Django Backend:
-```
-POST /api/courses/online/room/token/
-```
-
-### درخواست از فرانت به Django:
-```
-Headers:
- Authorization: Token
- Content-Type: application/json
-
-Body:
-{
- "course_slug": "algebra-10"
-}
-```
-
-**⚠️ نکات مهم:**
-- **فرانت فقط `course_slug` ارسال میکند!**
-- بکاند Django از `Authorization` header کاربر را شناسایی میکند
-- بکاند خودش live session فعال دوره را پیدا میکند:
- ```python
- # 1. پیدا کردن دوره
- course = Course.objects.get(slug=course_slug)
-
- # 2. پیدا کردن live session فعال
- session = CourseLiveSession.objects.get(
- course=course,
- ended_at__isnull=True # session هایی که هنوز به پایان نرسیدهاند
- )
-
- # 3. گرفتن room_id
- room_id = session.room_id
- ```
-- بکاند خودش همه اطلاعات کاربر را میسازد:
- - `user_id` از `request.user`
- - `name` از `user.get_full_name()` یا `user.email`
- - `is_admin` از `user.can_manage_course(course)`
- - `profilePic` از `user.avatar`
- - `lock_settings` برای غیر-admin
-
-### بدنه درخواست از Django به PlugNMeet (خودکار):
-
-بکاند Django این payload را خودش میسازد و به PlugNMeet میفرستد:
-
-**برای استاد:**
-```json
-{
- "room_id": "algebra-1402",
- "user_info": {
- "user_id": "10", // 🔐 از request.user
- "name": "استاد نمونه", // 🔐 از user.get_full_name()
- "is_admin": true, // 🔐 از user.can_manage_course()
- "user_metadata": {
- "is_hidden": false,
- "profilePic": "https://..." // 🔐 از user.avatar
- }
- }
-}
-```
-
-**برای دانشجو:**
-```json
-{
- "room_id": "algebra-1402",
- "user_info": {
- "user_id": "27", // 🔐 از request.user
- "name": "دانشجو نمونه", // 🔐 از user.get_full_name()
- "is_admin": false, // 🔐 از user.can_manage_course()
- "user_metadata": {
- "profilePic": "https://...", // 🔐 از user.avatar
- "lock_settings": { // 🔒 خودکار برای غیر-admin
- "lock_microphone": true,
- "lock_screen_sharing": true,
- "lock_webcam": true
- }
- }
- }
-}
-```
-
-### نحوه کار بکاند Django:
-```python
-# 1. شناسایی کاربر از token
-user = request.user # از Authorization header
-
-# 2. پیدا کردن دوره و session فعال
-course = Course.objects.get(slug=course_slug)
-session = CourseLiveSession.objects.get(course=course, ended_at__isnull=True)
-room_id = session.room_id
-
-# 3. تشخیص نقش
-is_admin = user.can_manage_course(course) # استاد یا مالک دوره
-
-# 4. ساخت user_info
-user_info = {
- 'user_id': str(user.id),
- 'name': user.get_full_name() or user.email,
- 'is_admin': is_admin,
-}
-
-# 4. اضافه کردن profilePic
-profile_pic = request.build_absolute_uri(user.avatar.url)
-user_metadata['profilePic'] = profile_pic
-
-# 5. اضافه کردن lock_settings برای غیر-admin
-if not is_admin:
- user_metadata['lock_settings'] = {
- 'lock_microphone': True,
- 'lock_screen_sharing': True,
- 'lock_webcam': True,
- }
-```
-
-### ارسال به PlugNMeet:
-بکاند Django با هدرهای امنیتی به PlugNMeet ارسال میکند:
-- `API-KEY`: از settings
-- `HASH-SIGNATURE`: `HMAC_SHA256(body, secret)`
-- این توکن JWT اختصاصی plugNmeet است که در `GeneratePNMJoinToken` ساخته میشود
-- `is_admin: true` باعث میشود در `GetPNMJoinToken` کاربر به عنوان presenter با تمام دسترسیها ثبت شود
-- `lock_settings` باعث میشود در فرانتاند PlugNMeet دکمههای میکروفون/وبکم غیرفعال شوند
-
-### پاسخ Django به فرانت:
-```json
-{
- "room_id": "algebra-1402",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
- "plugnmeet": {
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
- "expires": 300,
- ...
- }
-}
-```
-
-فرانت با این `token` میتواند کاربر را به PlugNMeet وارد کند:
-```
-https://meet.imamjavad.online/?access_token=
-```
-
-## گام ۳: ورود کلاینت با توکن
-۱. توکن را در URL یا کوکی قرار دهید؛ کلاینت مقدار را از `access_token` در کوئریاسترینگ یا از کوکی `pnm_access_token` میخواند (`getAccessToken` در `client/src/helpers/utils.ts`).
-۲. آدرس ورود: `https://meet.imamjavad.online/?access_token=`.
-۳. اپلیکیشن React موجود در `client/src/components/app/index.tsx` پس از بارگذاری:
- - درخواست `POST /api/verifyToken` را با هدر `Authorization: ` میفرستد (`HandleVerifyToken`).
- - اگر توکن معتبر باشد، لیست آدرسهای NATS و موضوعات لازم را میگیرد و اتصال را آغاز میکند (`startNatsConn`).
-۴. پس از اتصال، وضعیت کاربر و اتاق در Redux ذخیره میشود (`sessionSlice`). اگر کاربر ادمین باشد، تمام امکانات بدون محدودیت فعال است؛ در غیر این صورت مقدارهای `lock_settings` تعیین میکنند چه دکمههایی فعال باشند.
-
-## کنترل حالت صحبت/شنیدن برای استاد و دانشجو
-
-### استاد (Moderator/Host):
-- ✅ در توکن `is_admin: true` ارسال میشود
-- ✅ بکاند Django در `apps/course/views/live_session.py` این را تشخیص میدهد:
- ```python
- is_admin = user.can_manage_course(course) # استاد یا مالک دوره
- ```
-- ✅ سرور PlugNMeet در `GetPNMJoinToken` رول presenter را فعال میکند
-- ✅ **هیچ قفلی** روی میکروفون، وبکم یا اشتراک صفحه اعمال نمیشود
-- 🎤 استاد میتواند بلافاصله صحبت کند و به دانشجو **اجازه صحبت** دهد
-
-### دانشجو (Participant):
-- 🔒 در توکن `is_admin: false` ارسال میشود
-- 🔒 بکاند Django خودکار lock_settings را اضافه میکند:
- ```python
- if not is_admin:
- user_metadata['lock_settings'] = {
- 'lock_microphone': True,
- 'lock_screen_sharing': True,
- 'lock_webcam': True,
- }
- ```
-- 🔇 دکمههای میکروفون، وبکم و اشتراک صفحه **غیرفعال** هستند
-- 👂 فقط میتواند **گوش دهد** تا میزبان اجازه دهد
-- این منطق در `joinModal.tsx` با متغیر `isMicLock` پیادهسازی شده است
-
-### نحوه دادن اجازه به دانشجو:
-- میزبان باید از داخل کلاس از طریق UI کنترل کند
-- یا از API `/api/updateLockSettings` یا `switchPresenter` استفاده کند
-
-## نکات تکمیلی
-
-### توکنها و انقضا:
-- توکنها زمان انقضای مفهومی دارند (`client.token_validity` در YAML)
-- در صورت نزدیک شدن به انقضا، کلاینت خودکار با `REQ_RENEW_PNM_TOKEN` درخواست تمدید میدهد
-
-### Authorization:
-- برای درخواستهای بعدی به `/api/...` همان هدر `Authorization` را ست کنید
-- کلاینت این کار را در `helpers/api/plugNmeetAPI.ts` انجام میدهد
-
-### مدیریت دسترسیها:
-- اگر میخواهید دانشجو را به صحبتکننده ارتقا دهید: `/api/updateLockSettings` یا `switchPresenter`
-- این کار فقط توسط **میزبان** امکانپذیر است
-
-## 🔐 جمعبندی امنیت
-
-### ❌ چیزهایی که فرانت نباید انجام دهد:
-
-#### موقع ساخت روم:
-- ❌ ارسال `metadata`
-- ❌ ارسال `default_lock_settings`
-- ❌ ارسال `room_features`
-
-#### موقع گرفتن توکن:
-- ❌ ارسال `room_id` (بکاند خودش از session فعال میگیرد)
-- ❌ ارسال `user_info`
-- ❌ ارسال `is_admin`
-- ❌ ارسال `lock_settings`
-- ❌ ارسال `user_id` یا `name`
-
-### ✅ چیزهایی که فرانت فقط ارسال میکند:
-
-#### موقع ساخت روم:
-```json
-{
- "room_id": "algebra-1402", // اختیاری
- "subject": "کلاس جبر" // اختیاری
-}
-```
-
-#### موقع گرفتن توکن:
-```json
-{
- "course_slug": "algebra-10" // فقط این!
-}
-```
-+ `Authorization: Token ` در header
-
-### ✅ چیزهایی که بکاند Django خودش انجام میدهد:
-
-#### برای همه درخواستها:
-- ✅ شناسایی کاربر از `Authorization` header
-- ✅ بررسی دسترسی با `user.can_manage_course()` یا `Participant.objects.filter()`
-
-#### موقع ساخت روم:
-- ✅ تعیین `default_lock_settings` (همه `true`)
-- ✅ تعیین `room_features.mute_on_start: true`
-- ✅ ساخت `metadata` کامل برای PlugNMeet
-
-#### موقع گرفتن توکن:
-- ✅ پیدا کردن live session فعال از `course_slug`
-- ✅ گرفتن `room_id` از session
-- ✅ ساخت `user_id` از `request.user.id`
-- ✅ ساخت `name` از `user.get_full_name()` یا `user.email`
-- ✅ تشخیص `is_admin` از `user.can_manage_course(course)`
-- ✅ گرفتن `profilePic` از `user.avatar`
-- ✅ اضافه کردن `lock_settings` برای غیر-admin
-- ✅ ساخت `user_info` کامل برای PlugNMeet
-
-**نتیجه:**
-- 🔒 **امنیت کامل**: فرانت نمیتواند هیچ تنظیمات امنیتی را دستکاری کند
-- ✅ **متمرکز**: همه logic در بکاند Django است
-- 🎯 **ساده**: فرانت فقط `course_slug` و `Authorization` header ارسال میکند
-- 🔐 **قابل کنترل**: بکاند تعیین میکند کدام session فعال است
diff --git a/apps/course/urls.py b/apps/course/urls.py
deleted file mode 100644
index b4cc817..0000000
--- a/apps/course/urls.py
+++ /dev/null
@@ -1,58 +0,0 @@
-
-from django.urls import path, re_path, include
-from rest_framework.routers import DefaultRouter, SimpleRouter
-from . import views
-
-router = SimpleRouter()
-router.register(r'admin/courses', views.AdminCourseViewSet, basename='admin-courses')
-router.register(r'admin/chapters', views.AdminChapterViewSet, basename='admin-chapters')
-router.register(r'admin/course-lessons', views.AdminCourseLessonViewSet, basename='admin-course-lessons')
-router.register(r'admin/course-attachments', views.AdminCourseAttachmentViewSet, basename='admin-course-attachments')
-router.register(r'admin/course-glossaries', views.AdminCourseGlossaryViewSet, basename='admin-course-glossaries')
-router.register(r'admin/live-sessions', views.AdminLiveSessionViewSet, basename='admin-live-sessions')
-
-# Base model admin routes for the Repository Page
-router.register(r'admin/categories', views.AdminCourseCategoryViewSet, basename='admin-categories')
-router.register(r'admin/lessons', views.AdminLessonViewSet, basename='admin-lessons-base')
-router.register(r'admin/attachments', views.AdminAttachmentViewSet, basename='admin-attachments-base')
-router.register(r'admin/glossaries', views.AdminGlossaryViewSet, basename='admin-glossaries-base')
-
-# Hide admin viewsets from swagger
-for prefix, viewset, basename in router.registry:
- viewset.swagger_schema = None
-
-urlpatterns = [
- path('', include(router.urls)),
- path('categories/', views.CourseCategoryAPIView.as_view(), name='course-categories'),
- path('', views.CourseListAPIView.as_view(), name='course-list'),
- path('my-courses/', views.MyCourseListAPIView.as_view(), name='course-my-courses-list'),
- path('lesson/completion/', views.LessonCompletionToggleAPIView.as_view(), name='lesson-completion'),
- path('professors/', views.ProfessorListAPIView.as_view(), name='course-professor-list'),
- re_path(r'professors/(?P[\w-]+)/courses/$', views.ProfessorCourseListAPIView.as_view(), name='course-professor-course-list'),
- re_path(r'professors/(?P[\w-]+)/$', views.ProfessorDetailAPIView.as_view(), name='course-professor-detail'),
- path('/online/token/', views.CourseOnlineClassTokenAPIView.as_view(), name='course-online-token'),
- re_path(r'(?P[\w-]+)/online/validate/$', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-validate'),
- path('online/token/validate/', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-token-validate'),
- re_path(r'(?P[\w-]+)/online/room/create/$', views.CourseLiveSessionRoomCreateAPIView.as_view(), name='course-live-session-room-create'),
- path('online/room/token/', views.CourseLiveSessionTokenAPIView.as_view(), name='course-live-session-token'),
- path('online/room/recording/', views.CourseLiveSessionRecordingAPIView.as_view(), name='course-live-session-recording'),
- path('online/room/recording/web-egress/callback/', views.CourseLiveSessionWebEgressCallbackAPIView.as_view(), name='course-live-session-web-egress-callback'),
- path('online/room/set-recording-title/', views.CourseLiveSessionSetRecordingTitleAPIView.as_view(), name='course-live-session-set-recording-title'),
- path('live-sessions//recorded-file/', views.CourseLiveSessionRecordedFileAPIView.as_view(), name='course-live-session-recorded-file'),
-
- # PlugNMeet webhook endpoint
- path('plugnmeet/webhook/', views.PlugNMeetWebhookAPIView.as_view(), name='plugnmeet-webhook'),
-
- re_path(r'(?P[\w-]+)/$', views.CourseDetailAPIView.as_view(), name='course-detail'),
- re_path(r'(?P[\w-]+)/attachments/$', views.AttachmentListAPIView.as_view(), name='course-attachment-list'),
- re_path(r'(?P[\w-]+)/glossaries/$', views.GlossaryListAPIView.as_view(), name='course-glossary-list'),
- re_path(r'(?P[\w-]+)/lessons/$', views.LessonListView.as_view(), name='course-lesson-list'),
- re_path(r'v2/(?P[\w-]+)/lessons/$', views.LessonListV2APIView.as_view(), name='course-lesson-list-v2'),
- path('lesson//', views.LessonDetailView.as_view(), name='lesson-detail'),
-
- re_path(r'(?P[\w-]+)/participants/$', views.CourseParticipantsView.as_view(), name='course-participant-list'),
-
-
- # path('/participant/join/', views.ParticipantCreateView.as_view(), name='course-participant-join'),
-
-]
diff --git a/apps/course/views/__init__.py b/apps/course/views/__init__.py
deleted file mode 100644
index 6fc4005..0000000
--- a/apps/course/views/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from .course import *
-from .lesson import *
-from .participant import *
-from .professor import *
-from .live_session import *
-from .webhook import *
-from .admin import *
diff --git a/apps/course/views/admin.py b/apps/course/views/admin.py
deleted file mode 100644
index cf18141..0000000
--- a/apps/course/views/admin.py
+++ /dev/null
@@ -1,827 +0,0 @@
-import logging
-import re
-from copy import deepcopy
-
-from django.db import transaction
-from django.db.models import Count, Q
-from django.utils import timezone
-from rest_framework import serializers, viewsets
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.authentication import TokenAuthentication
-from rest_framework.decorators import action
-from rest_framework.response import Response
-from rest_framework.exceptions import PermissionDenied
-from utils.pagination import StandardResultsSetPagination
-from apps.account.permissions import IsPanelUser, IsSuperAdminOrReadOnlyForProfessor
-from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError
-from apps.course.services.web_egress import stop_session_web_egress_if_active
-from apps.course.models import (
- Course,
- CourseCategory,
- CourseChapter,
- CourseLesson,
- Lesson,
- CourseAttachment,
- Attachment,
- CourseGlossary,
- Glossary,
- CourseLiveSession,
- LiveSessionUser,
- LiveSessionRecording
-)
-from apps.course.models.course import extract_text_from_json
-from apps.quiz.models import Quiz, Question
-from apps.course.serializers.admin import (
- AdminCourseCategorySerializer,
- AdminCourseListSerializer,
- AdminCourseDetailSerializer,
- AdminCourseChapterSerializer,
- AdminCourseLessonSerializer,
- AdminCourseAttachmentSerializer,
- AdminCourseGlossarySerializer,
- AdminLessonSerializer,
- AdminGlossarySerializer,
- AdminAttachmentSerializer,
- AdminLiveSessionListSerializer,
- AdminLiveSessionDetailSerializer
-)
-from utils import FileFieldSerializer
-
-logger = logging.getLogger(__name__)
-
-
-def is_professor(request):
- return getattr(request.user, 'user_type', None) == 'professor'
-
-
-class AdminCourseViewSet(viewsets.ModelViewSet):
- """
- Admin CRUD ViewSet for Course management.
- Professors can only access/modify their own courses.
- """
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- pagination_class = StandardResultsSetPagination
-
- def get_serializer_class(self):
- if self.action == 'list':
- return AdminCourseListSerializer
- return AdminCourseDetailSerializer
-
- def get_queryset(self):
- queryset = Course.objects.all().select_related('category').prefetch_related('professors')
-
- # Professors can only see their own courses
- if is_professor(self.request):
- queryset = queryset.filter(professors=self.request.user)
-
- # Search Query
- search_query = self.request.query_params.get('search', None)
- has_search = False
- if search_query:
- has_search = True
- from django.db.models import Case, When, Value, IntegerField
- queryset = queryset.filter(
- Q(title__icontains=search_query) |
- Q(description__icontains=search_query) |
- Q(short_description__icontains=search_query)
- ).annotate(
- search_relevance=Case(
- When(title__icontains=search_query, then=Value(2)),
- default=Value(1),
- output_field=IntegerField()
- )
- )
-
- # Filters
- category_id = self.request.query_params.get('category', None)
- if category_id:
- queryset = queryset.filter(category_id=category_id)
-
- professor_ids = self.request.query_params.getlist('professor')
- if professor_ids:
- queryset = queryset.filter(professors__in=professor_ids).distinct()
-
- level = self.request.query_params.get('level', None)
- if level:
- queryset = queryset.filter(level__contains=[{"title": level}])
-
- status_param = self.request.query_params.get('status', None)
- if status_param:
- queryset = queryset.filter(status__contains=[{"title": status_param}])
-
- is_free_param = self.request.query_params.get('is_free', None)
- if is_free_param is not None:
- if is_free_param.lower() == 'true':
- queryset = queryset.filter(is_free=True)
- elif is_free_param.lower() == 'false':
- queryset = queryset.filter(is_free=False)
-
- student_id = self.request.query_params.get('student_id', None)
- if student_id:
- queryset = queryset.filter(participants__student_id=student_id)
-
- if has_search:
- return queryset.order_by('-search_relevance', '-id')
- return queryset.order_by('-id')
-
- def perform_create(self, serializer):
- # If the logged-in user is a professor, force the professor field to themselves
- if is_professor(self.request):
- serializer.save(professor=self.request.user)
- else:
- serializer.save()
-
- def perform_update(self, serializer):
- # Prevent professors from reassigning a course to another professor
- if is_professor(self.request):
- instance = self.get_object()
- if not instance.professors.filter(id=self.request.user.id).exists():
- raise PermissionDenied("You can only edit your own courses.")
- serializer.save(professor=self.request.user)
- else:
- serializer.save()
-
- def _build_cloned_title(self, source_course, requested_title=None):
- custom_title = str(requested_title or "").strip()
- if custom_title:
- return [{"language_code": "en", "title": custom_title}]
-
- source_titles = deepcopy(source_course.title or [])
- source_title_text = extract_text_from_json(source_titles).strip() or f"Course {source_course.pk}"
- base_title = re.sub(r"\s*\(copied(?:#\d+)?\)$", "", source_title_text).strip() or source_title_text
- suffix = "(copied)"
-
- if isinstance(source_titles, list) and source_titles:
- localized_titles = []
- for item in source_titles:
- if isinstance(item, dict):
- localized_item = deepcopy(item)
- for key in ("title", "text", "value", "name"):
- if key in localized_item and localized_item.get(key):
- cleaned_value = re.sub(r"\s*\(copied(?:#\d+)?\)$", "", str(localized_item[key])).strip()
- localized_item[key] = f"{cleaned_value}{suffix}"
- break
- else:
- localized_item["title"] = f"{base_title}{suffix}"
- localized_titles.append(localized_item)
- elif item:
- cleaned_item = re.sub(r"\s*\(copied(?:#\d+)?\)$", "", str(item)).strip()
- localized_titles.append(f"{cleaned_item}{suffix}")
-
- if localized_titles:
- return localized_titles
-
- return [{"language_code": "en", "title": f"{base_title}{suffix}"}]
-
- @action(detail=True, methods=['post'])
- def clone(self, request, pk=None):
- source_course = self.get_object()
- requested_title = request.data.get('title')
-
- with transaction.atomic():
- cloned_course = Course.objects.create(
- title=self._build_cloned_title(source_course, requested_title),
- category=source_course.category,
- thumbnail=source_course.thumbnail.name if source_course.thumbnail else None,
- video_type=source_course.video_type,
- video_file=source_course.video_file.name if source_course.video_file else None,
- video_link=source_course.video_link,
- is_online=source_course.is_online,
- online_link=source_course.online_link,
- level=deepcopy(source_course.level),
- duration=source_course.duration,
- lessons_count=source_course.lessons_count,
- description=deepcopy(source_course.description),
- short_description=deepcopy(source_course.short_description),
- status=deepcopy(source_course.status),
- is_free=source_course.is_free,
- price=source_course.price,
- price_rub=source_course.price_rub,
- discount_percentage=source_course.discount_percentage,
- is_group_chat_locked=source_course.is_group_chat_locked,
- is_professor_chat_locked=source_course.is_professor_chat_locked,
- timing=deepcopy(source_course.timing),
- features=deepcopy(source_course.features),
- )
- cloned_course.professors.set(source_course.professors.all())
-
- chapter_map = {}
- source_chapters = source_course.chapters.all().order_by('priority', 'id')
- for chapter in source_chapters:
- cloned_chapter = CourseChapter.objects.create(
- course=cloned_course,
- title=deepcopy(chapter.title),
- priority=chapter.priority,
- is_active=chapter.is_active,
- )
- chapter_map[chapter.id] = cloned_chapter
-
- lesson_map = {}
- source_lessons = source_course.lessons.select_related('lesson', 'chapter').all().order_by('priority', 'id')
- for course_lesson in source_lessons:
- base_lesson = course_lesson.lesson
- cloned_lesson = Lesson.objects.create(
- title=deepcopy(base_lesson.title),
- content_type=deepcopy(base_lesson.content_type),
- content_file=base_lesson.content_file.name if base_lesson and base_lesson.content_file else None,
- video_link=base_lesson.video_link if base_lesson else None,
- duration=base_lesson.duration if base_lesson else 0,
- )
- cloned_course_lesson = CourseLesson.objects.create(
- course=cloned_course,
- chapter=chapter_map.get(course_lesson.chapter_id),
- lesson=cloned_lesson,
- title=deepcopy(course_lesson.title),
- priority=course_lesson.priority,
- is_active=course_lesson.is_active,
- )
- lesson_map[course_lesson.id] = cloned_course_lesson
-
- source_attachments = source_course.attachments.select_related('attachment').all()
- for course_attachment in source_attachments:
- base_attachment = course_attachment.attachment
- cloned_attachment = Attachment.objects.create(
- title=deepcopy(base_attachment.title),
- file=base_attachment.file.name if base_attachment and base_attachment.file else None,
- file_size=base_attachment.file_size if base_attachment else None,
- )
- CourseAttachment.objects.create(
- course=cloned_course,
- attachment=cloned_attachment,
- )
-
- source_glossaries = source_course.glossaries.select_related('glossary').all()
- for course_glossary in source_glossaries:
- base_glossary = course_glossary.glossary
- cloned_glossary = Glossary.objects.create(
- title=base_glossary.title,
- description=deepcopy(base_glossary.description),
- )
- CourseGlossary.objects.create(
- course=cloned_course,
- glossary=cloned_glossary,
- )
-
- source_quizzes = source_course.quizzes.prefetch_related('questions').all()
- for quiz in source_quizzes:
- cloned_quiz = Quiz.objects.create(
- lesson=lesson_map.get(quiz.lesson_id),
- course=cloned_course,
- lesson_number=quiz.lesson_number,
- title=deepcopy(quiz.title),
- description=deepcopy(quiz.description),
- each_question_timing=quiz.each_question_timing,
- status=quiz.status,
- )
- for question in quiz.questions.all():
- Question.objects.create(
- quiz=cloned_quiz,
- question=deepcopy(question.question),
- option1=deepcopy(question.option1),
- option2=deepcopy(question.option2),
- option3=deepcopy(question.option3),
- option4=deepcopy(question.option4),
- correct_answer=question.correct_answer,
- priority=question.priority,
- )
-
- cloned_course.recalculate_lessons_count()
- cloned_course.refresh_from_db()
-
- serializer = self.get_serializer(cloned_course)
- return Response(serializer.data, status=201)
-
-
-class AdminChapterViewSet(viewsets.ModelViewSet):
- """
- Admin CRUD ViewSet for Course Chapters.
- Professors can only access chapters of their own courses.
- """
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- serializer_class = AdminCourseChapterSerializer
- pagination_class = None
-
- def get_queryset(self):
- queryset = CourseChapter.objects.all()
-
- # Professors can only see chapters of their own courses
- if is_professor(self.request):
- queryset = queryset.filter(course__professors=self.request.user)
-
- course_id = self.request.query_params.get('course', None)
- if course_id:
- queryset = queryset.filter(course_id=course_id)
- return queryset.order_by('priority', 'id')
-
- @action(detail=False, methods=['post'])
- def reorder(self, request):
- """
- Reorders chapters in bulk.
- Format: {"chapter_ids": [id1, id2, ...]}
- """
- chapter_ids = request.data.get('chapter_ids', [])
- if not chapter_ids:
- return Response({'error': 'chapter_ids list is required'}, status=400)
-
- from django.db import transaction
- with transaction.atomic():
- for idx, c_id in enumerate(chapter_ids):
- CourseChapter.objects.filter(id=c_id).update(priority=idx + 1)
-
- return Response({'status': 'success'})
-
-
-class AdminCourseLessonViewSet(viewsets.ModelViewSet):
- """
- Admin CRUD ViewSet for Lessons associated with a Course/Chapter.
- Professors can only access lessons in their own courses.
- """
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- serializer_class = AdminCourseLessonSerializer
- pagination_class = None
-
- def get_queryset(self):
- queryset = CourseLesson.objects.all().select_related('lesson', 'chapter')
-
- # Professors can only see lessons of their own courses
- if is_professor(self.request):
- queryset = queryset.filter(course__professors=self.request.user)
-
- course_id = self.request.query_params.get('course', None)
- if course_id:
- queryset = queryset.filter(course_id=course_id)
-
- chapter_id = self.request.query_params.get('chapter', None)
- if chapter_id:
- queryset = queryset.filter(chapter_id=chapter_id)
-
- return queryset.order_by('priority', 'id')
-
- def perform_destroy(self, instance):
- # Prevent orphans: delete the base Lesson object along with the junction model
- lesson = instance.lesson
- instance.delete()
- if lesson:
- lesson.delete()
-
- @action(detail=False, methods=['post'])
- def reorder(self, request):
- """
- Reorders lessons in bulk.
- Format: {"lesson_ids": [id1, id2, ...]}
- """
- lesson_ids = request.data.get('lesson_ids', [])
- if not lesson_ids:
- return Response({'error': 'lesson_ids list is required'}, status=400)
-
- from django.db import transaction
- with transaction.atomic():
- for idx, l_id in enumerate(lesson_ids):
- CourseLesson.objects.filter(id=l_id).update(priority=idx + 1)
-
- return Response({'status': 'success'})
-
-
-class AdminCourseAttachmentViewSet(viewsets.ModelViewSet):
- """
- Admin CRUD ViewSet for Course Attachments.
- Professors can only access attachments of their own courses.
- """
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- serializer_class = AdminCourseAttachmentSerializer
- pagination_class = None
-
- def get_queryset(self):
- queryset = CourseAttachment.objects.all().select_related('attachment')
-
- # Professors can only see attachments of their own courses
- if is_professor(self.request):
- queryset = queryset.filter(course__professors=self.request.user)
-
- course_id = self.request.query_params.get('course', None)
- if course_id:
- queryset = queryset.filter(course_id=course_id)
- return queryset.order_by('-id')
-
- def create(self, request, *args, **kwargs):
- attachment_ids = request.data.get('attachment_ids')
- new_attachments = request.data.get('new_attachments')
- course_id = request.data.get('course')
-
- if not course_id or (not attachment_ids and not new_attachments):
- return super().create(request, *args, **kwargs)
-
- if is_professor(request):
- course = Course.objects.filter(id=course_id, professors=request.user).first()
- else:
- course = Course.objects.filter(id=course_id).first()
-
- if not course:
- raise PermissionDenied("Course not found or access denied.")
-
- created_links = []
- seen_attachment_ids = set()
- file_field = FileFieldSerializer()
- file_field.bind('file', None)
-
- with transaction.atomic():
- for attachment_id in attachment_ids or []:
- try:
- parsed_id = int(attachment_id)
- except (TypeError, ValueError):
- continue
-
- if parsed_id in seen_attachment_ids:
- continue
- seen_attachment_ids.add(parsed_id)
-
- attachment = Attachment.objects.filter(pk=parsed_id).first()
- if not attachment:
- continue
-
- course_attachment, created = CourseAttachment.objects.get_or_create(
- course=course,
- attachment=attachment,
- )
- if created:
- created_links.append(course_attachment)
-
- for item in new_attachments or []:
- title = item.get('title')
- file_data = item.get('file')
- if not title or not file_data:
- continue
-
- try:
- normalized_file = file_field.to_internal_value(file_data)
- except serializers.ValidationError as exc:
- raise serializers.ValidationError({
- 'new_attachments': [exc.detail]
- })
-
- attachment = Attachment.objects.create(
- title=title,
- file=normalized_file,
- )
- created_links.append(
- CourseAttachment.objects.create(
- course=course,
- attachment=attachment,
- )
- )
-
- serializer = self.get_serializer(created_links, many=True)
- return Response(serializer.data, status=201)
-
- def perform_destroy(self, instance):
- # Prevent orphans: delete base Attachment along with junction model
- attachment = instance.attachment
- instance.delete()
- if attachment:
- attachment.delete()
-
-
-class AdminCourseGlossaryViewSet(viewsets.ModelViewSet):
- """
- Admin CRUD ViewSet for Course Glossary terms.
- Professors can only access glossaries of their own courses.
- """
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- serializer_class = AdminCourseGlossarySerializer
- pagination_class = None
-
- def get_queryset(self):
- queryset = CourseGlossary.objects.all().select_related('glossary')
-
- # Professors can only see glossaries of their own courses
- if is_professor(self.request):
- queryset = queryset.filter(course__professors=self.request.user)
-
- course_id = self.request.query_params.get('course', None)
- if course_id:
- queryset = queryset.filter(course_id=course_id)
- return queryset.order_by('-id')
-
- def create(self, request, *args, **kwargs):
- glossary_ids = request.data.get('glossary_ids')
- new_glossaries = request.data.get('new_glossaries')
- course_id = request.data.get('course')
-
- if not course_id or (not glossary_ids and not new_glossaries):
- return super().create(request, *args, **kwargs)
-
- if is_professor(request):
- course = Course.objects.filter(id=course_id, professors=request.user).first()
- else:
- course = Course.objects.filter(id=course_id).first()
-
- if not course:
- raise PermissionDenied("Course not found or access denied.")
-
- created_links = []
- seen_glossary_ids = set()
-
- with transaction.atomic():
- for glossary_id in glossary_ids or []:
- try:
- parsed_id = int(glossary_id)
- except (TypeError, ValueError):
- continue
-
- if parsed_id in seen_glossary_ids:
- continue
- seen_glossary_ids.add(parsed_id)
-
- glossary = Glossary.objects.filter(pk=parsed_id).first()
- if not glossary:
- continue
-
- course_glossary, created = CourseGlossary.objects.get_or_create(
- course=course,
- glossary=glossary,
- )
- if created:
- created_links.append(course_glossary)
-
- for item in new_glossaries or []:
- title = item.get('title')
- description = item.get('description')
- if not title or not description:
- continue
-
- glossary = Glossary.objects.create(
- title=title,
- description=description,
- )
- created_links.append(
- CourseGlossary.objects.create(
- course=course,
- glossary=glossary,
- )
- )
-
- serializer = self.get_serializer(created_links, many=True)
- return Response(serializer.data, status=201)
-
- def perform_destroy(self, instance):
- # Prevent orphans: delete base Glossary along with junction model
- glossary = instance.glossary
- instance.delete()
- if glossary:
- glossary.delete()
-
-
-class AdminCourseCategoryViewSet(viewsets.ModelViewSet):
- """
- Admin CRUD ViewSet for Course Categories.
- Professors have read-only access.
- """
- permission_classes = [IsAuthenticated, IsSuperAdminOrReadOnlyForProfessor]
- authentication_classes = [TokenAuthentication]
- serializer_class = AdminCourseCategorySerializer
- pagination_class = StandardResultsSetPagination
-
- def get_queryset(self):
- queryset = CourseCategory.objects.all().order_by('-id')
- search = self.request.query_params.get('search', None)
- if search:
- queryset = queryset.filter(Q(name__icontains=search) | Q(slug__icontains=search))
- return queryset
-
-
-class AdminLessonViewSet(viewsets.ModelViewSet):
- """
- Admin CRUD ViewSet for base Lesson (Repository).
- Professors can only see lessons linked to their own courses.
- """
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- serializer_class = AdminLessonSerializer
- pagination_class = StandardResultsSetPagination
-
- def get_queryset(self):
- queryset = Lesson.objects.all().order_by('-id')
-
- search = self.request.query_params.get('search', None)
- if search:
- queryset = queryset.filter(title__icontains=search)
-
- content_type = self.request.query_params.get('content_type', None)
- if content_type:
- queryset = queryset.filter(content_type__contains=[{"title": content_type}])
-
- return queryset
-
-
-class AdminGlossaryViewSet(viewsets.ModelViewSet):
- """
- Admin CRUD ViewSet for base Glossary (Repository).
- Professors can only see glossaries linked to their own courses.
- """
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- serializer_class = AdminGlossarySerializer
- pagination_class = StandardResultsSetPagination
-
- def get_queryset(self):
- queryset = Glossary.objects.all().order_by('-id')
-
- search = self.request.query_params.get('search', None)
- if search:
- queryset = queryset.filter(Q(title__icontains=search) | Q(description__icontains=search))
- return queryset
-
-
-class AdminAttachmentViewSet(viewsets.ModelViewSet):
- """
- Admin CRUD ViewSet for base Attachment (Repository).
- Professors can only see attachments linked to their own courses.
- """
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- serializer_class = AdminAttachmentSerializer
- pagination_class = StandardResultsSetPagination
-
- def get_queryset(self):
- queryset = Attachment.objects.all().order_by('-id')
-
- search = self.request.query_params.get('search', None)
- if search:
- queryset = queryset.filter(title__icontains=search)
-
- file_type = self.request.query_params.get('type', None)
- if file_type:
- image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']
- video_extensions = ['.mp4', '.webm', '.ogg', '.mkv', '.mov', '.avi']
- audio_extensions = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac']
-
- if file_type == 'image':
- q_objs = Q()
- for ext in image_extensions:
- q_objs |= Q(file__iendswith=ext)
- queryset = queryset.filter(q_objs)
- elif file_type == 'video':
- q_objs = Q()
- for ext in video_extensions:
- q_objs |= Q(file__iendswith=ext)
- queryset = queryset.filter(q_objs)
- elif file_type == 'audio':
- q_objs = Q()
- for ext in audio_extensions:
- q_objs |= Q(file__iendswith=ext)
- queryset = queryset.filter(q_objs)
- elif file_type == 'other':
- q_objs = Q()
- for ext in image_extensions + video_extensions + audio_extensions:
- q_objs |= Q(file__iendswith=ext)
- queryset = queryset.exclude(q_objs)
-
- return queryset
-
-
-class AdminLiveSessionViewSet(viewsets.ModelViewSet):
- """
- Admin CRUD ViewSet for Course Live Sessions.
- Professors can only access live sessions of their own courses.
- """
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- pagination_class = StandardResultsSetPagination
-
- def get_serializer_class(self):
- if self.action == 'list':
- return AdminLiveSessionListSerializer
- return AdminLiveSessionDetailSerializer
-
- def get_queryset(self):
- queryset = CourseLiveSession.objects.all().select_related('course').annotate(
- media_count=Count('recordings', distinct=True)
- )
-
- # Professors can only see live sessions of their own courses
- if is_professor(self.request):
- queryset = queryset.filter(course__professors=self.request.user)
-
- search = self.request.query_params.get('search', None)
- if search:
- queryset = queryset.filter(
- Q(subject__icontains=search) |
- Q(room_id__icontains=search) |
- Q(course__title__icontains=search)
- )
-
- course_id = self.request.query_params.get('course', None)
- if course_id:
- queryset = queryset.filter(course_id=course_id)
-
- professor_ids = self.request.query_params.getlist('professor')
- if professor_ids:
- queryset = queryset.filter(course__professors__in=professor_ids).distinct()
-
- time_range = self.request.query_params.get('time_range', None)
- if time_range:
- from datetime import timedelta
- now = timezone.now()
- if time_range == '3_days':
- queryset = queryset.filter(started_at__gte=now - timedelta(days=3))
- elif time_range == 'week':
- queryset = queryset.filter(started_at__gte=now - timedelta(days=7))
- elif time_range == 'month':
- queryset = queryset.filter(started_at__gte=now - timedelta(days=30))
- elif time_range == 'year':
- queryset = queryset.filter(started_at__gte=now - timedelta(days=365))
-
- started_after = self.request.query_params.get('started_after', None)
- if started_after:
- queryset = queryset.filter(started_at__gte=started_after)
-
- started_before = self.request.query_params.get('started_before', None)
- if started_before:
- queryset = queryset.filter(started_at__lte=started_before)
-
- ended_after = self.request.query_params.get('ended_after', None)
- if ended_after:
- queryset = queryset.filter(ended_at__gte=ended_after)
-
- ended_before = self.request.query_params.get('ended_before', None)
- if ended_before:
- queryset = queryset.filter(ended_at__lte=ended_before)
-
- return queryset.order_by('-started_at')
-
- def list(self, request, *args, **kwargs):
- queryset = self.filter_queryset(self.get_queryset())
- page = self.paginate_queryset(queryset)
-
- if page is not None:
- self._sync_page_live_sessions(page)
- serializer = self.get_serializer(page, many=True)
- return self.get_paginated_response(serializer.data)
-
- sessions = list(queryset)
- self._sync_page_live_sessions(sessions)
- serializer = self.get_serializer(sessions, many=True)
- return Response(serializer.data)
-
- def _sync_page_live_sessions(self, sessions):
- for session in sessions:
- if session.ended_at or not session.room_id:
- continue
-
- if self._is_room_still_active(session.room_id):
- continue
-
- stop_session_web_egress_if_active(session)
- now = timezone.now()
- session.ended_at = now
- session.save(update_fields=['ended_at', 'updated_at'])
-
- LiveSessionUser.objects.filter(
- session=session,
- is_online=True,
- ).update(is_online=False, exited_at=now, updated_at=now)
-
- logger.info(
- f"[Admin Live Sessions] Auto-closed stale session - session_id={session.id} room_id={session.room_id}"
- )
-
- @staticmethod
- def _is_room_still_active(room_id: str) -> bool:
- try:
- client = PlugNMeetClient()
- response = client.is_room_active(room_id)
- 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', '')
- response_status = response.get('status', False)
-
- if (
- response_status
- and 'active' in response_msg.lower()
- and 'not' not in response_msg.lower()
- ):
- return True
-
- return is_active
- except PlugNMeetError as e:
- error_msg = str(e).lower()
- if 'not found' in error_msg or 'does not exist' in error_msg:
- return False
- logger.warning(
- f"[Admin Live Sessions] PlugNMeet API error while checking room {room_id}: {e}"
- )
- return False
- except Exception as e:
- logger.warning(
- f"[Admin Live Sessions] Unexpected error while checking room {room_id}: {type(e).__name__}: {e}"
- )
- return False
diff --git a/apps/course/views/course.py b/apps/course/views/course.py
deleted file mode 100644
index ceee2a6..0000000
--- a/apps/course/views/course.py
+++ /dev/null
@@ -1,1207 +0,0 @@
-import logging
-from typing import Optional
-
-from django.conf import settings
-from django.contrib.auth import get_user_model
-from django.core.exceptions import ImproperlyConfigured
-from django.db.models import Count, Q, F
-from django.shortcuts import get_object_or_404
-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
-from rest_framework.generics import GenericAPIView, ListAPIView, RetrieveAPIView
-from rest_framework.permissions import AllowAny, IsAuthenticated
-from rest_framework.response import Response
-
-from utils.pagination import StandardResultsSetPagination
-
-logger = logging.getLogger(__name__)
-
-
-from apps.course.serializers import (
- CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer,
- CourseAttachmentSerializer, CourseGlossarySerializer, MyCourseListSerializer,
- OnlineClassTokenCreateSerializer, OnlineClassTokenVerifySerializer
-)
-from apps.course.models import (
- Course,
- CourseAttachment,
- CourseCategory,
- CourseGlossary,
- CourseLiveSession,
- LiveSessionUser,
- Participant,
-)
-from apps.course.models.course import extract_text_from_json
-from apps.course.doc import *
-from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError
-from apps.course.services.web_egress import stop_session_web_egress_if_active
-from apps.account.serializers import UserProfileSerializer
-from utils.exceptions import AppAPIException
-from utils.redis import OnlineClassTokenManager
-import time
-
-
-UserModel = get_user_model()
-
-
-def get_course_slug_value(course: Course) -> str:
- slug_data = course.slug
- if isinstance(slug_data, list) and slug_data:
- first_item = slug_data[0]
- if isinstance(first_item, dict):
- first_title = first_item.get('title')
- if first_title:
- return str(first_title)
- if isinstance(slug_data, str):
- return slug_data
- return str(slug_data or '')
-
-
-def get_online_class_frontend_base() -> str:
- base = getattr(
- settings,
- "ONLINE_CLASS_FRONTEND_DOMAIN",
- getattr(settings, "SITE_DOMAIN", ""),
- ).rstrip("/")
- if base and not base.startswith(('http://', 'https://')):
- base = f"https://{base}"
- return base
-
-
-class CourseCategoryAPIView(ListAPIView):
- queryset = CourseCategory.objects.all().order_by('-id')
- serializer_class = CourseCategorySerializer
- pagination_class = StandardResultsSetPagination
- permission_classes = [AllowAny]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- operation_description=doc_course_category(),
- tags=["Imam-Javad - Course"]
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
-
-
-
-from utils.pagination import StandardResultsSetPagination
-
-class CourseListAPIView(ListAPIView):
- serializer_class = CourseListSerializer
- filter_backends = [SearchFilter]
- search_fields = ['title', 'category__name', 'professors__fullname']
- pagination_class = StandardResultsSetPagination
- permission_classes = [AllowAny]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- tags=['Imam-Javad - Course'],
- operation_description=doc_course_list(),
- manual_parameters=[
- openapi.Parameter(
- 'search', openapi.IN_QUERY,
- description="Search by course title, category name, or professor's full name",
- type=openapi.TYPE_STRING,
- ),
- openapi.Parameter(
- 'category_slug', openapi.IN_QUERY,
- description="Category of the Course",
- type=openapi.TYPE_STRING,
- # enum=[category.slug for category in CourseCategory.objects.all()]
- ),
- openapi.Parameter(
- 'status', openapi.IN_QUERY,
- type=openapi.TYPE_STRING,
- description="""Status =>
- Upcoming (visible but registration not allowed)---Предстоящие
- Registering (registration is open)---регистрация
- Ongoing (course has started, registration closed)---Впроцессе
- Finished (course has ended)---закончился
- """,
- enum=[status for status in ['upcoming', 'registering', 'ongoing', 'finished']]
- ),
- openapi.Parameter(
- 'is_free', openapi.IN_QUERY,
- description="Ценообразование is_free ",
- type=openapi.TYPE_BOOLEAN,
- ),
- openapi.Parameter(
- 'is_online', openapi.IN_QUERY,
- description="Статус участия is_online ",
- type=openapi.TYPE_BOOLEAN,
- ),
- ])
- def get(self, request, *args, **kwargs):
- return self.list(request, *args, **kwargs)
-
- def get_queryset(self):
- """
- Optimized queryset with select_related for ForeignKey relationships and filtering
- """
- queryset = Course.objects.select_related(
- 'category',
- ).prefetch_related(
- 'professors',
- ).exclude(
- status__contains=[{"title": Course.StatusChoices.INACTIVE}]
- ).order_by('-created_at', '-id')
-
- request = self.request
- filters = request.query_params
-
- # Handle category_slug with multiple values separated by commas
- if category_slugs := filters.get('category_slug'):
- category_slugs_list = category_slugs.split(',')
- category_q = Q()
- for slug_item in category_slugs_list:
- category_q |= Q(category__slug__contains=[{"title": slug_item}])
- queryset = queryset.filter(category_q)
-
- # Handle status with multiple values separated by commas
- if statuses := filters.get('status'):
- statuses_list = statuses.split(',')
- status_q = Q()
- for status_item in statuses_list:
- status_q |= Q(status__contains=[{"title": status_item}])
- queryset = queryset.filter(status_q)
-
- if is_free := filters.get('is_free'):
- is_free = is_free.lower() == 'true'
- queryset = queryset.filter(
- Q(is_free=is_free) | Q(price_rub=0) if is_free else Q(is_free=False, price_rub__gt=0)
- )
- if is_online := filters.get('is_online'):
- is_online = is_online.lower() == 'true'
- queryset = queryset.filter(is_online=is_online)
-
- return queryset
-
-
-
-
-
-class CourseDetailAPIView(RetrieveAPIView):
- serializer_class = CourseDetailSerializer
- lookup_field = "slug"
- permission_classes = [AllowAny]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- tags=["Imam-Javad - Course"],
- operation_description="Get detailed information about a specific course",
- responses={
- 200: openapi.Response(
- description="Course details",
- schema=CourseDetailSerializer()
- )
- }
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def get_queryset(self):
- """
- Optimized queryset with select_related and prefetch_related for all relationships
- """
- return Course.objects.select_related(
- 'category',
- ).prefetch_related(
- 'professors',
- 'chapters__lessons__lesson',
- 'chapters__lessons__completions',
- 'attachments__attachment',
- 'glossaries__glossary',
- 'participants__student',
- 'room_messages'
- )
-
- def get_object(self):
- queryset = self.filter_queryset(self.get_queryset())
- lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
- slug_value = self.kwargs[lookup_url_kwarg]
- obj = queryset.filter(slug__contains=[{"title": slug_value}]).first()
- if not obj:
- raise NotFound("Course not found")
- self.check_object_permissions(self.request, obj)
- return obj
-
- @swagger_auto_schema(
- operation_description=doc_course_detail(),
- tags=['Imam-Javad - Course'],
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
-from rest_framework.authentication import TokenAuthentication
-class MyCourseListAPIView(ListAPIView):
- serializer_class = MyCourseListSerializer
- permission_classes = [IsAuthenticated]
- pagination_class = StandardResultsSetPagination
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(manual_parameters=[
- openapi.Parameter(
- 'completed', openapi.IN_QUERY,
- description="мои курсы completed true",
- type=openapi.TYPE_BOOLEAN,
- ),
- openapi.Parameter(
- 'certificate', openapi.IN_QUERY,
- type=openapi.TYPE_BOOLEAN,
- ),
- ],
- operation_description=doc_courses_my_courses(),
- operation_summary="Home",
- tags=['Imam-Javad - Course']
-
- )
- def get(self, request, *args, **kwargs):
- print(f'--> my-course-> {request}/ {kwargs}')
- return super().get(request, *args, **kwargs)
-
- def get_queryset(self):
- """
- Optimized queryset for user's courses (as student and professor) with select_related and prefetch_related
- """
- queryset = Course.objects.select_related(
- 'category',
- ).prefetch_related(
- 'professors',
- 'chapters__lessons__lesson',
- 'chapters__lessons__completions',
- 'participants__student'
- ).exclude(status__contains=[{"title": Course.StatusChoices.INACTIVE}])
-
- request = self.request
- filters = request.query_params
- user = self.request.user
-
- # Include courses where user is a student OR one of the professors
- qs = queryset.filter(Q(participants__student=user) | Q(professors=user)).distinct()
-
- completed_param = filters.get('completed')
- if completed_param is not None:
- completed_only = completed_param.lower() == 'true'
- # Only active chapters/lessons should participate in completion checks.
- total_lessons_filter = Q(
- chapters__is_active=True,
- chapters__lessons__is_active=True,
- )
- completed_lessons_filter = Q(
- chapters__is_active=True,
- chapters__lessons__is_active=True,
- chapters__lessons__completions__student=user,
- )
-
- qs = qs.annotate(
- total_lessons=Count(
- 'chapters__lessons',
- filter=total_lessons_filter,
- distinct=True,
- ),
- completed_lessons=Count(
- 'chapters__lessons__completions',
- filter=completed_lessons_filter,
- distinct=True,
- ),
- )
-
- if completed_only:
- # A course is "completed" only when every active lesson is completed.
- # Courses with no active lessons should not be marked as completed.
- qs = qs.filter(
- total_lessons__gt=0,
- total_lessons=F('completed_lessons'),
- )
- else:
- qs = qs.filter(total_lessons__gt=F('completed_lessons'))
-
- if 'completed' not in filters:
- certificate = filters.get('certificate', '').lower() == 'true'
- if certificate:
- qs = qs.exclude(
- course_certificates__student=user,
- course_certificates__status__in=['pending', 'approved']
- )
-
- return qs
-
-
-
-
-class AttachmentListAPIView(ListAPIView):
- serializer_class = CourseAttachmentSerializer
- pagination_class = StandardResultsSetPagination
- permission_classes = [AllowAny]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- tags=['Imam-Javad - Course'],
- manual_parameters=[
- openapi.Parameter(
- 'slug', openapi.IN_PATH,
- description="Slug of the Course",
- type=openapi.TYPE_STRING,
- required=True
- )
- ],
- operation_description="Retrieve a list of attachments for a given course by its slug."
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def get_queryset(self):
- """
- Optimized queryset with select_related for attachment relationship
- """
- course_slug = self.kwargs.get('slug')
- course = Course.objects.filter(slug__contains=[{"title": course_slug}]).first()
- if not course:
- raise NotFound("Course not found")
- return CourseAttachment.objects.select_related(
- 'course',
- 'attachment'
- ).filter(course=course)
-
-
-
-
-class GlossaryListAPIView(ListAPIView):
- serializer_class = CourseGlossarySerializer
- filter_backends = [SearchFilter]
- search_fields = ['glossary__title', 'glossary__description']
- pagination_class = StandardResultsSetPagination
- permission_classes = [AllowAny]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- operation_description="Get glossary terms for a specific course",
- tags=["Imam-Javad - Course"],
- manual_parameters=[
- openapi.Parameter(
- 'slug', openapi.IN_PATH,
- description="Course slug",
- type=openapi.TYPE_STRING,
- required=True
- ),
- openapi.Parameter(
- 'search', openapi.IN_QUERY,
- description="Search in glossary title or description",
- type=openapi.TYPE_STRING,
- required=False
- )
- ],
- responses={
- 200: openapi.Response(
- description="List of glossary terms",
- schema=CourseGlossarySerializer(many=True)
- )
- }
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def get_queryset(self):
- """
- Optimized queryset with select_related for glossary relationship
- """
- course_slug = self.kwargs.get('slug')
- course = Course.objects.filter(slug__contains=[{"title": course_slug}]).first()
- if not course:
- raise NotFound("Course not found")
-
- return CourseGlossary.objects.select_related(
- 'course',
- 'glossary'
- ).filter(course=course)
-
-
-
-class CourseOnlineClassTokenAPIView(GenericAPIView):
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- serializer_class = OnlineClassTokenCreateSerializer
-
- @swagger_auto_schema(
- tags=['Imam-Javad - Course'],
- operation_description="Generate a temporary entry token for an online class.",
- request_body=OnlineClassTokenCreateSerializer,
- responses={
- status.HTTP_201_CREATED: openapi.Response(
- description="Token generated successfully.",
- examples={
- "application/json": {
- "token": "abc123xyz789...",
- "url": "https://imamjavad.online/join-class?token=abc123xyz789...&slug=python-basics",
- "expires_in": 300,
- }
- }
- )
- }
- )
- def post(self, request, pk, *args, **kwargs):
- serializer = self.get_serializer(data=request.data or {})
- serializer.is_valid(raise_exception=True)
-
- course = get_object_or_404(Course, pk=pk)
- if not course.is_online:
- raise AppAPIException({'message': "Course is not marked as online."}, status_code=status.HTTP_400_BAD_REQUEST)
-
- if not self._user_has_access(request.user, course):
- raise AppAPIException({'message': "You do not have access to this course."}, status_code=status.HTTP_403_FORBIDDEN)
-
- manager = OnlineClassTokenManager()
- user_token, _ = Token.objects.get_or_create(user=request.user)
- identifier = f"{request.user.id}:{user_token.key[:8]}"
- token = manager.generate_token(course_id=course.id, user_identifier=identifier)
-
- manager.store_token(token, {
- 'course_id': course.id,
- 'user_id': request.user.id,
- 'user_token': user_token.key,
- 'course_slug': get_course_slug_value(course),
- 'extra': {
- 'professor_in_class': False,
- },
- })
-
- # ساخت URL ثابت با token و course slug
- frontend_base = get_online_class_frontend_base()
- course_slug = get_course_slug_value(course)
- entry_url = f"{frontend_base}/?token={token}&slug={course_slug}"
-
- return Response({
- 'token': token,
- 'url': entry_url,
- 'expires_in': getattr(settings, 'ONLINE_CLASS_TOKEN_TTL', 300),
- }, status=status.HTTP_201_CREATED)
-
- @staticmethod
- def _user_has_access(user, course: Course) -> bool:
- if user.is_staff or course.professors.filter(id=user.id).exists():
- return True
- return Participant.objects.filter(course=course, student=user).exists()
-
-
-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
-
- @swagger_auto_schema(
- tags=['Imam-Javad - Course'],
- operation_description="Get course and user data for authenticated user.",
- manual_parameters=[
- openapi.Parameter(
- 'slug', openapi.IN_PATH,
- description="Course Slug",
- type=openapi.TYPE_STRING,
- required=True
- )
- ],
- responses={
- status.HTTP_200_OK: openapi.Response(
- description="Course data retrieved.",
- examples={
- "application/json": {
- "course": {"id": 1, "title": "Sample Course"},
- "user": {"id": 10, "fullname": "John Doe"},
- "metadata": {
- "status": "ongoing",
- "has_started": True,
- "professor_in_class": False,
- "validated_at": "2024-01-01T10:00:00Z"
- }
- }
- }
- )
- }
- )
- 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 = queryset.filter(slug__contains=[{"title": slug}]).first()
- if not course:
- raise NotFound("Course not found")
- 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)
-
- course_data = CourseDetailSerializer(course, context={'request': request}).data
- user_data = UserProfileSerializer(user, context={'request': request}).data
- metadata = self._build_metadata(
- course,
- {'user_id': user.id if user.is_authenticated else None, 'extra': {}, 'generated_at': timezone.now().isoformat()},
- user=user,
- )
- redirect_path = self._resolve_redirect_path(request, course, user, metadata)
- metadata['redirect_path'] = redirect_path
-
- 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,
- 'metadata': metadata,
- 'redirect_path': redirect_path,
- }, status=status.HTTP_200_OK)
-
- @swagger_auto_schema(
- tags=['Imam-Javad - Course'],
- operation_description="Validate an online class entry token and return course/user data.",
- request_body=OnlineClassTokenVerifySerializer,
- responses={
- status.HTTP_200_OK: openapi.Response(
- description="Token validated.",
- examples={
- "application/json": {
- "course": {"id": 1, "title": "Sample Course"},
- "user": {"id": 10, "fullname": "John Doe"},
- "metadata": {
- "status": "ongoing",
- "has_started": True,
- "professor_in_class": False,
- "validated_at": "2024-01-01T10:00:00Z"
- }
- }
- }
- )
- }
- )
- def post(self, request, *args, **kwargs):
- 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)
- 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:
- 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:
- 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)
-
- 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)
-
- 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)
- redirect_path = self._resolve_redirect_path(request, course, user, metadata, token_value=token_value)
- metadata['redirect_path'] = redirect_path
-
- 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,
- 'user': user_data,
- 'metadata': metadata,
- 'redirect_path': redirect_path,
- }, status=status.HTTP_200_OK)
-
- def _build_metadata(self, course: Course, payload: dict, user=None) -> dict:
- status_value = extract_text_from_json(course.status)
- has_started = status_value in [Course.StatusChoices.ONGOING, Course.StatusChoices.FINISHED]
- timing_data = course.timing if isinstance(course.timing, dict) else {}
-
- user = user or UserModel.objects.filter(pk=payload.get('user_id')).first()
- user_id = getattr(user, 'id', None)
- can_manage = bool(user and user.can_manage_course(course))
-
- live_context = self._build_live_session_context(course)
- can_join_live_session = live_context['is_online'] and self._user_can_join_live_session(user, course)
-
- logger.debug(f"[Online Validate Metadata] user_id={user_id} course={course.slug} can_manage={can_manage} is_online={live_context['is_online']} can_join={can_join_live_session}")
-
- metadata = {
- 'status': status_value,
- 'has_started': has_started,
- 'has_finished': status_value == Course.StatusChoices.FINISHED,
- 'professor_in_class': payload.get('extra', {}).get('professor_in_class', False),
- 'can_create_live_session': can_manage and not live_context['is_online'],
- 'can_join_live_session': can_join_live_session,
- 'scheduled_times': timing_data,
- 'generated_at': payload.get('generated_at'),
- 'validated_at': timezone.now().isoformat(),
- 'redirect_path': None,
- }
-
- metadata.update(live_context)
- return metadata
-
- def _resolve_redirect_path(self, request, course: Course, user, metadata: dict, token_value: str = None) -> Optional[str]:
- if not user or not user.is_authenticated:
- return None
-
- if metadata.get('is_online') and metadata.get('can_join_live_session'):
- redirect_path = self._build_live_redirect_path(request, course, user)
- if redirect_path:
- return redirect_path
-
- if metadata.get('can_create_live_session'):
- redirect_path = self._build_professor_direct_redirect_path(course, user)
- if redirect_path:
- return redirect_path
-
- if self._user_has_course_access(user, course):
- return self._build_waiting_redirect_path(course, user, token_value=token_value)
-
- return None
-
- def _build_waiting_redirect_path(self, course: Course, user, token_value: str = None) -> str:
- course_slug = get_course_slug_value(course)
- token = token_value or self._generate_entry_token(course, user)
- frontend_base = get_online_class_frontend_base()
- return f"{frontend_base}/?token={token}&slug={course_slug}"
-
- def _build_professor_direct_redirect_path(self, course: Course, user) -> Optional[str]:
- try:
- session = self._ensure_professor_live_session(course)
- redirect_path = self._build_live_redirect_path_for_room(
- course=course,
- user=user,
- room_id=session.room_id,
- )
- if redirect_path:
- return redirect_path
- except (ImproperlyConfigured, PlugNMeetError, Exception) as exc:
- logger.warning(
- "[Online Validate] Failed to create professor live session - course=%s user_id=%s error=%s",
- course.slug,
- user.id,
- str(exc),
- )
- return None
- return None
-
- def _ensure_professor_live_session(self, course: Course) -> CourseLiveSession:
- session = (
- CourseLiveSession.objects.filter(course=course, ended_at__isnull=True)
- .order_by('-started_at', '-id')
- .first()
- )
- if session and session.room_id:
- return session
-
- subject = self._get_live_session_subject(course)
- room_id = f"room-{course.id}-{int(time.time())}"
- session = CourseLiveSession.objects.create(
- course=course,
- room_id=room_id,
- subject=subject,
- started_at=timezone.now(),
- )
-
- client = PlugNMeetClient()
- client.create_room({
- 'room_id': room_id,
- 'empty_timeout': 90,
- 'metadata': self._build_live_room_metadata(subject),
- })
- return session
-
- @staticmethod
- def _get_live_session_subject(course: Course) -> str:
- next_session_number = course.live_sessions.count() + 1
- return f"Live Session {next_session_number}"
-
- def _build_live_room_metadata(self, subject: str) -> dict:
- return {
- 'room_title': subject,
- 'webhook_url': self._get_plugnmeet_webhook_url(),
- 'default_lock_settings': {
- 'lock_microphone': True,
- 'lock_webcam': True,
- 'lock_screen_sharing': True,
- 'lock_whiteboard': True,
- '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': False,
- 'enable_auto_cloud_recording': False,
- 'only_record_admin_webcams': False,
- },
- },
- }
-
- @staticmethod
- def _get_plugnmeet_webhook_url() -> str:
- base = getattr(settings, 'SITE_DOMAIN', '').rstrip('/')
- if not base:
- raise ImproperlyConfigured('SITE_DOMAIN must be configured for PlugNMeet webhook delivery.')
- if not base.startswith(('http://', 'https://')):
- base = f"https://{base}"
- return f"{base}/api/courses/plugnmeet/webhook/"
-
- def _build_live_redirect_path(self, request, course: Course, user) -> Optional[str]:
- session = (
- CourseLiveSession.objects.filter(course=course, ended_at__isnull=True)
- .order_by('-started_at', '-id')
- .first()
- )
- if not session or not session.room_id:
- return None
-
- return self._build_live_redirect_path_for_room(
- course=course,
- user=user,
- room_id=session.room_id,
- request=request,
- )
-
- def _build_live_redirect_path_for_room(self, course: Course, user, room_id: str, request=None) -> Optional[str]:
- if not room_id:
- return None
-
- is_admin = bool(user.can_manage_course(course))
- user_info = {
- 'user_id': str(user.id),
- 'name': user.get_full_name() or user.email or user.username or f"user-{user.id}",
- 'is_admin': is_admin,
- }
-
- user_metadata = {}
- profile_pic = self._build_profile_url(request, user) if request is not None else None
- if profile_pic:
- user_metadata['profilePic'] = profile_pic
-
- if not is_admin:
- user_metadata['lock_settings'] = {
- 'lock_microphone': True,
- 'lock_screen_sharing': True,
- 'lock_webcam': True,
- 'lock_whiteboard': True,
- 'lock_shared_notepad': False,
- 'lock_chat': False,
- 'lock_chat_send_message': False,
- 'lock_chat_file_share': False,
- 'lock_private_chat': False,
- }
- else:
- user_metadata['is_hidden'] = False
-
- if user_metadata:
- user_info['user_metadata'] = user_metadata
-
- try:
- client = PlugNMeetClient()
- response = client.get_join_token({
- 'room_id': room_id,
- 'user_info': user_info,
- })
- except (PlugNMeetError, Exception) as exc:
- logger.warning(
- "[Online Validate] Failed to generate direct access token - course=%s user_id=%s error=%s",
- course.slug,
- user.id,
- str(exc),
- )
- return None
-
- access_token = response.get('token')
- if not access_token:
- return None
-
- self._register_fail_safe_live_session_user(
- course=course,
- room_id=room_id,
- user=user,
- is_admin=is_admin,
- )
-
- frontend_base = get_online_class_frontend_base()
- return f"{frontend_base}/?access_token={access_token}"
-
- @staticmethod
- def _register_fail_safe_live_session_user(
- course: Course,
- room_id: str,
- user,
- is_admin: bool,
- ) -> None:
- try:
- session = (
- CourseLiveSession.objects.filter(
- course=course,
- room_id=room_id,
- ended_at__isnull=True,
- )
- .order_by('-started_at', '-id')
- .first()
- )
- if not session:
- logger.warning(
- "[Online Validate] Fail-safe registration skipped - no active session found for course=%s room_id=%s user_id=%s",
- course.slug,
- room_id,
- user.id,
- )
- return
-
- role = 'moderator' if is_admin else 'participant'
- LiveSessionUser.objects.update_or_create(
- session=session,
- user=user,
- defaults={
- 'role': role,
- 'is_online': True,
- 'exited_at': None,
- 'entered_at': timezone.now(),
- },
- )
-
- if is_admin:
- session.last_moderator_left_at = None
- session.auto_close_after_moderator_exit_at = None
- session.save(
- update_fields=[
- 'last_moderator_left_at',
- 'auto_close_after_moderator_exit_at',
- 'updated_at',
- ]
- )
-
- logger.info(
- "[Online Validate] Fail-safe registered user entry - session_id=%s room_id=%s user_id=%s role=%s",
- session.id,
- room_id,
- user.id,
- role,
- )
- except Exception as exc:
- logger.error(
- "[Online Validate] Failed to register fail-safe user entry - course=%s room_id=%s user_id=%s error=%s",
- course.slug,
- room_id,
- user.id,
- str(exc),
- )
-
- def _generate_entry_token(self, course: Course, user) -> str:
- manager = OnlineClassTokenManager()
- user_token, _ = Token.objects.get_or_create(user=user)
- identifier = f"{user.id}:{user_token.key[:8]}"
- token = manager.generate_token(course_id=course.id, user_identifier=identifier)
- manager.store_token(token, {
- 'course_id': course.id,
- 'user_id': user.id,
- 'user_token': user_token.key,
- 'course_slug': get_course_slug_value(course),
- 'extra': {
- 'professor_in_class': False,
- },
- })
- return token
-
- @staticmethod
- def _user_has_course_access(user, course: Course) -> bool:
- if not user or not user.is_authenticated:
- return False
- if user.is_staff or user.can_manage_course(course):
- return True
- return Participant.objects.filter(course=course, student=user).exists()
-
- @staticmethod
- def _build_profile_url(request, user):
- avatar = getattr(user, 'avatar', None)
- if avatar and getattr(avatar, 'url', None):
- return request.build_absolute_uri(avatar.url)
- return None
-
- 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')
- .first()
- )
-
- if not latest_session:
- logger.debug(f"[Live Session Context] No session found for course={course.slug}")
- return {
- 'is_online': False,
- 'live_session': None,
- 'active_room_id': None,
- 'livesession_started_at': None,
- 'livesession_ended_at': None,
- }
-
- started_at = latest_session.started_at
- ended_at = latest_session.ended_at
- is_online = bool(started_at and not ended_at)
-
- # 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,
- 'subject': latest_session.subject,
- 'started_at': self._format_datetime(started_at),
- 'ended_at': self._format_datetime(ended_at),
- }
-
- 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,
- 'livesession_started_at': live_session_data['started_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
- def _user_can_join_live_session(user, course: Course) -> bool:
- if not user or not user.is_authenticated:
- return False
-
- if user.is_staff or user.is_superuser or user.can_manage_course(course):
- return True
-
- return Participant.objects.filter(
- course=course,
- student_id=user.id,
- is_active=True
- ).exists()
-
- @staticmethod
- def _format_datetime(value):
- if not value:
- return None
- if isinstance(value, str):
- return value
- if timezone.is_naive(value):
- value = timezone.make_aware(value, timezone.get_current_timezone())
- return timezone.localtime(value).isoformat()
-
- # DEPRECATED: This polling approach is inefficient and has been replaced by webhook integration
- # def _sync_room_status_with_plugnmeet(self, course: Course):
- # """
- # Check if active live session's room is still active in PlugNMeet.
- # If room is inactive, close the session and all related user entries.
- #
- # DEPRECATED: This should be replaced by webhook integration.
- # PlugNMeet now sends webhooks when rooms end, eliminating the need for polling.
- # """
- # active_session = CourseLiveSession.objects.filter(
- # course=course,
- # ended_at__isnull=True
- # ).first()
- #
- # if not active_session or not active_session.room_id:
- # return
- #
- # try:
- # client = PlugNMeetClient()
- # response = client.is_room_active(active_session.room_id)
- # is_active = response.get('isActive', False)
- #
- # if not is_active:
- # logger.info(f"[Room Sync] Room inactive in PlugNMeet - room_id={active_session.room_id} session_id={active_session.id}")
- # self._close_live_session(active_session)
- # else:
- # logger.debug(f"[Room Sync] Room still active - room_id={active_session.room_id} session_id={active_session.id}")
- #
- # except (PlugNMeetError, Exception) as e:
- # logger.warning(f"[Room Sync] Failed to check room status - room_id={active_session.room_id} error={str(e)}")
-
- 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.
- """
- stop_session_web_egress_if_active(session)
- now = timezone.now()
-
- session.ended_at = now
- session.save(update_fields=['ended_at', 'updated_at'])
- logger.info(f"[Room Sync] Session closed - session_id={session.id} room_id={session.room_id} ended_at={now}")
-
- updated_count = LiveSessionUser.objects.filter(
- session=session,
- is_online=True,
- exited_at__isnull=True
- ).update(
- is_online=False,
- exited_at=now,
- updated_at=now
- )
-
- if updated_count > 0:
- logger.info(f"[Room Sync] User sessions closed - session_id={session.id} count={updated_count}")
diff --git a/apps/course/views/lesson.py b/apps/course/views/lesson.py
deleted file mode 100644
index a5ab621..0000000
--- a/apps/course/views/lesson.py
+++ /dev/null
@@ -1,379 +0,0 @@
-from rest_framework.generics import ListAPIView, RetrieveAPIView, GenericAPIView
-from rest_framework.permissions import IsAuthenticated, AllowAny
-from rest_framework.authentication import TokenAuthentication
-
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
-from django.shortcuts import get_object_or_404
-from rest_framework import status
-from rest_framework.response import Response
-from apps.course.models import CourseChapter
-from apps.course.serializers import (
- CourseLessonSerializer,
- CourseChapterSerializer
-)
-from apps.course.models import Course, CourseLesson, LessonCompletion
-from apps.course.access import user_has_course_access
-from apps.course.doc import *
-from utils.exceptions import AppAPIException
-from utils.pagination import StandardResultsSetPagination
-from rest_framework.permissions import IsAuthenticated
-
-
-
-class LessonListView(ListAPIView):
- serializer_class = CourseLessonSerializer
- pagination_class = StandardResultsSetPagination
- permission_classes = [AllowAny]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- operation_description=doc_courses_lesson(),
- tags=['Imam-Javad - Course'],
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def get_queryset(self):
- """
- Optimized queryset with select_related and prefetch_related for lesson relationships
- """
- course_slug = self.kwargs.get('slug')
- course = get_object_or_404(Course, slug__contains=[{"title": course_slug}])
-
- return CourseLesson.objects.select_related(
- 'chapter', # 👇 Route through chapter
- 'lesson'
- ).prefetch_related(
- 'completions',
- 'quizzes'
- ).filter(
- chapter__course=course, # 👇 Query via chapter
- is_active=True,
- chapter__is_active=True
- ).order_by('chapter__priority', 'priority') # 👇 Sort by chapter first
-
-
-
-
-class LessonDetailView(RetrieveAPIView):
- serializer_class = CourseLessonSerializer
- permission_classes = [AllowAny]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- operation_description="Get detailed lesson information with navigation data",
- tags=["Imam-Javad - Course"],
- manual_parameters=[
- openapi.Parameter(
- 'id', openapi.IN_PATH,
- description="Lesson ID",
- type=openapi.TYPE_INTEGER,
- required=True
- )
- ],
- responses={
- 200: openapi.Response(
- description="Lesson details with navigation information",
- schema=CourseLessonSerializer()
- )
- }
- )
- def get(self, request, *args, **kwargs):
- lesson_id = self.kwargs.get('id')
- course_lesson = get_object_or_404(
- CourseLesson.objects.select_related('chapter__course', 'lesson'),
- id=lesson_id,
- is_active=True,
- chapter__is_active=True
- )
-
- course = course_lesson.chapter.course
-
- # Get all lessons in order
- lessons = CourseLesson.objects.select_related(
- 'lesson', 'chapter'
- ).filter(
- chapter__course=course,
- is_active=True,
- chapter__is_active=True
- ).order_by('chapter__priority', 'priority')
-
- total_lessons = lessons.count()
- # Convert to list to find index
- lesson_ids = list(lessons.values_list('id', flat=True))
- try:
- current_index = lesson_ids.index(course_lesson.id)
- current_lesson_number = current_index + 1
-
- next_lesson_id = lesson_ids[current_index + 1] if current_index + 1 < len(lesson_ids) else None
- previous_lesson_id = lesson_ids[current_index - 1] if current_index > 0 else None
-
- except ValueError:
- current_lesson_number = 1
- next_lesson_id = None
- previous_lesson_id = None
-
- lesson_data = self.get_serializer(course_lesson).data
- lesson_data['total_lessons'] = total_lessons
- lesson_data['current_lesson_number'] = current_lesson_number
- lesson_data['next_lesson_id'] = next_lesson_id
- lesson_data['previous_lesson_id'] = previous_lesson_id
- lesson_data['can_go_next'] = next_lesson_id is not None
-
- return Response(lesson_data)
-
-
-
-class LessonCompletionToggleAPIView(GenericAPIView):
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- operation_description="Toggle lesson completion status (for single ID) or mark multiple lessons as completed (for list of IDs)",
- tags=["Imam-Javad - Course"],
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=['lesson_id'],
- properties={
- 'lesson_id': openapi.Schema(
- type=openapi.TYPE_INTEGER,
- description='ID of the lesson to toggle (integer), or list of IDs to mark completed (array of integers)'
- ),
- },
- ),
- responses={
- 201: 'Lesson marked as COMPLETED.',
- 200: 'Lesson marked as INCOMPLETE (Unchecked) or Bulk operation completed.',
- 400: 'Lesson ID is required or invalid.',
- 404: 'Lesson not found.',
- }
- )
- def post(self, request):
- student = request.user
- lesson_id_data = request.data.get('lesson_id')
-
- if not lesson_id_data:
- return Response({'error': 'Lesson ID is required.'}, status=status.HTTP_400_BAD_REQUEST)
-
- # List Case:
- if isinstance(lesson_id_data, list):
- try:
- lesson_ids = [int(lid) for lid in lesson_id_data]
- except (ValueError, TypeError):
- return Response({'error': 'Invalid lesson ID in list.'}, status=status.HTTP_400_BAD_REQUEST)
-
- course_lessons = CourseLesson.objects.filter(id__in=lesson_ids)
- found_ids = set(course_lessons.values_list('id', flat=True))
-
- if any(not user_has_course_access(student, course_lesson.course) for course_lesson in course_lessons):
- return Response({'error': 'You do not have access to one or more lessons.'}, status=status.HTTP_403_FORBIDDEN)
-
- existing_completions = LessonCompletion.objects.filter(
- student=student,
- course_lesson_id__in=found_ids
- ).values_list('course_lesson_id', flat=True)
- existing_completed_set = set(existing_completions)
-
- completions_to_create = []
- for cl in course_lessons:
- if cl.id not in existing_completed_set:
- completions_to_create.append(
- LessonCompletion(student=student, course_lesson=cl)
- )
-
- if completions_to_create:
- LessonCompletion.objects.bulk_create(completions_to_create)
-
- return Response(
- {
- 'message': f'Completed {len(completions_to_create)} new lessons.',
- 'completed_ids': list(found_ids)
- },
- status=status.HTTP_200_OK
- )
-
- # Single Integer Case:
- else:
- try:
- lesson_id = int(lesson_id_data)
- except (ValueError, TypeError):
- return Response({'error': 'Invalid lesson ID.'}, status=status.HTTP_400_BAD_REQUEST)
-
- try:
- course_lesson = CourseLesson.objects.get(id=lesson_id)
- except CourseLesson.DoesNotExist:
- return Response({'error': 'Lesson not found.'}, status=status.HTTP_404_NOT_FOUND)
-
- if not user_has_course_access(student, course_lesson.course):
- return Response({'error': 'You do not have access to this lesson.'}, status=status.HTTP_403_FORBIDDEN)
-
- # TOGGLE LOGIC
- completion = LessonCompletion.objects.filter(student=student, course_lesson=course_lesson).first()
-
- if completion:
- completion.delete()
- return Response(
- {'message': 'Lesson marked as incomplete.', 'is_completed': False},
- status=status.HTTP_200_OK
- )
- else:
- LessonCompletion.objects.create(student=student, course_lesson=course_lesson)
- return Response(
- {'message': 'Lesson completed successfully.', 'is_completed': True},
- status=status.HTTP_201_CREATED
- )
-
-from django.db.models import Prefetch
-class LessonListV2APIView(ListAPIView):
- """
- V2 API: Returns lessons grouped by Chapters
- """
- serializer_class = CourseChapterSerializer
- pagination_class = StandardResultsSetPagination
- permission_classes = [AllowAny]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- operation_description=doc_courses_lesson_v2(),
- tags=['Imam-Javad - Course (V2)'],
- manual_parameters=[
- openapi.Parameter(
- 'slug', openapi.IN_PATH,
- description="Course slug",
- type=openapi.TYPE_STRING,
- required=True
- ),
- openapi.Parameter(
- 'page', openapi.IN_QUERY,
- description="Page number for chapter pagination",
- type=openapi.TYPE_INTEGER,
- required=False
- ),
- ],
- responses={
- 200: openapi.Response(
- description="Paginated list of active chapters with nested active lessons",
- schema=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'count': openapi.Schema(type=openapi.TYPE_INTEGER),
- 'next': openapi.Schema(type=openapi.TYPE_STRING, format='uri', nullable=True),
- 'previous': openapi.Schema(type=openapi.TYPE_STRING, format='uri', nullable=True),
- 'results': openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'id': openapi.Schema(type=openapi.TYPE_INTEGER),
- 'title': openapi.Schema(type=openapi.TYPE_STRING),
- 'priority': openapi.Schema(type=openapi.TYPE_INTEGER),
- 'is_active': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'lessons': openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'id': openapi.Schema(type=openapi.TYPE_INTEGER),
- 'title': openapi.Schema(type=openapi.TYPE_STRING),
- 'priority': openapi.Schema(type=openapi.TYPE_INTEGER),
- 'is_active': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'permission': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'duration': openapi.Schema(type=openapi.TYPE_INTEGER, nullable=True),
- 'content_type': openapi.Schema(type=openapi.TYPE_STRING, nullable=True),
- 'content_file': openapi.Schema(type=openapi.TYPE_STRING, nullable=True),
- 'video_link': openapi.Schema(type=openapi.TYPE_STRING, nullable=True),
- 'is_complated': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'quizs': openapi.Schema(
- type=openapi.TYPE_ARRAY,
- nullable=True,
- items=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'id': openapi.Schema(type=openapi.TYPE_INTEGER),
- 'title': openapi.Schema(type=openapi.TYPE_STRING),
- 'description': openapi.Schema(type=openapi.TYPE_STRING, nullable=True),
- 'permission': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'each_question_timing': openapi.Schema(type=openapi.TYPE_INTEGER, nullable=True),
- 'is_complated': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- }
- )
- ),
- }
- )
- ),
- }
- )
- ),
- 'independent_quizzes': openapi.Schema(
- type=openapi.TYPE_ARRAY,
- nullable=True,
- items=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'id': openapi.Schema(type=openapi.TYPE_INTEGER),
- 'title': openapi.Schema(type=openapi.TYPE_STRING),
- 'description': openapi.Schema(type=openapi.TYPE_STRING, nullable=True),
- 'permission': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- 'each_question_timing': openapi.Schema(type=openapi.TYPE_INTEGER, nullable=True),
- 'is_complated': openapi.Schema(type=openapi.TYPE_BOOLEAN),
- }
- )
- ),
- }
- )
- ),
- 404: openapi.Response(description="Course not found"),
- }
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def get_queryset(self):
- course_slug = self.kwargs.get('slug')
- course = get_object_or_404(Course, slug__contains=[{"title": course_slug}])
-
- # We query the Chapters, and prefetch the lessons to avoid N+1 query problems
- return CourseChapter.objects.prefetch_related(
- Prefetch(
- 'lessons',
- queryset=CourseLesson.objects.select_related('lesson').filter(is_active=True).order_by('priority')
- )
- ).filter(
- course=course,
- is_active=True
- ).order_by('priority', 'id')
-
- def list(self, request, *args, **kwargs):
- queryset = self.filter_queryset(self.get_queryset())
- page = self.paginate_queryset(queryset)
-
- serializer = self.get_serializer(page if page is not None else queryset, many=True)
-
- # Get independent quizzes belonging to this course with lesson_number = 0
- course_slug = self.kwargs.get('slug')
- course = get_object_or_404(Course, slug__contains=[{"title": course_slug}])
-
- from apps.quiz.models import Quiz
- from apps.quiz.serializers import QuizListSerializer
-
- independent_quizzes_qs = Quiz.objects.filter(
- course=course,
- lesson_number=0,
- status=True
- )
- independent_quizzes_data = QuizListSerializer(
- independent_quizzes_qs,
- many=True,
- context=self.get_serializer_context()
- ).data
-
- if page is not None:
- response = self.get_paginated_response(serializer.data)
- response.data['independent_quizzes'] = independent_quizzes_data
- return response
-
- return Response({
- "independent_quizzes": independent_quizzes_data,
- "results": serializer.data
- })
diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py
deleted file mode 100644
index 0b3ae02..0000000
--- a/apps/course/views/live_session.py
+++ /dev/null
@@ -1,1299 +0,0 @@
-import logging
-from datetime import timedelta
-
-from django.core.exceptions import ImproperlyConfigured
-from django.shortcuts import get_object_or_404
-from django.utils import timezone
-
-from rest_framework import status
-from rest_framework.generics import GenericAPIView
-from rest_framework.permissions import IsAuthenticated, AllowAny
-from rest_framework.authentication import TokenAuthentication
-from rest_framework.response import Response
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
-import time
-import jwt
-from apps.course.models import Course, CourseLiveSession, Participant, LiveSessionRecording, LiveSessionUser
-from apps.course.models.lesson import Lesson, CourseLesson
-from apps.course.serializers import LiveSessionRoomCreateSerializer, LiveSessionTokenSerializer, LiveSessionRecordedFileSerializer, LiveSessionRecordingSerializer, LiveSessionRecordingControlSerializer
-from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError
-from apps.course.services.web_egress import WebEgressClient, WebEgressError, stop_session_web_egress_if_active
-from utils.exceptions import AppAPIException
-from django.conf import settings
-logger = logging.getLogger(__name__)
-
-
-def get_live_session_subject(course):
- next_session_number = course.live_sessions.count() + 1
- return f"Live Session {next_session_number}"
-
-
-def get_plugnmeet_webhook_url() -> str:
- base = getattr(settings, 'SITE_DOMAIN', '').rstrip('/')
- if not base:
- raise ImproperlyConfigured('SITE_DOMAIN must be configured for PlugNMeet webhook delivery.')
- return f"{base}/api/courses/plugnmeet/webhook/"
-
-
-def get_online_class_frontend_base() -> str:
- base = getattr(
- settings,
- "ONLINE_CLASS_FRONTEND_DOMAIN",
- getattr(settings, "SITE_DOMAIN", ""),
- ).rstrip("/")
- if base and not base.startswith(("http://", "https://")):
- base = f"https://{base}"
- return base
-
-
-def get_web_egress_callback_url() -> str:
- base = getattr(settings, "SITE_DOMAIN", "").rstrip("/")
- if not base:
- raise ImproperlyConfigured(
- "SITE_DOMAIN must be configured for WebEgress callback delivery."
- )
- return f"{base}/api/courses/online/room/recording/web-egress/callback/"
-
-
-def decode_plugnmeet_access_token(raw_token: str) -> dict:
- try:
- return jwt.decode(
- raw_token,
- settings.PLUGNMEET_API_SECRET,
- algorithms=["HS256"],
- )
- except jwt.PyJWTError as exc:
- raise AppAPIException(
- {"message": "Invalid or expired meeting access token."},
- status_code=status.HTTP_401_UNAUTHORIZED,
- ) from exc
-
-
-def extract_meeting_token_from_request(request) -> str:
- auth_header = request.headers.get("Authorization", "").strip()
- if auth_header.lower().startswith("bearer "):
- return auth_header[7:].strip()
- if auth_header.lower().startswith("token "):
- return auth_header[6:].strip()
- if auth_header:
- return auth_header
- token = request.data.get("access_token") or request.query_params.get("access_token")
- if token:
- return token
- raise AppAPIException(
- {"message": "Meeting access token is required."},
- status_code=status.HTTP_401_UNAUTHORIZED,
- )
-
-
-def get_session_from_meeting_token(request, *, require_admin: bool = False):
- meeting_token = extract_meeting_token_from_request(request)
- claims = decode_plugnmeet_access_token(meeting_token)
-
- room_id = claims.get("room_id")
- if not room_id:
- raise AppAPIException(
- {"message": "Meeting token is missing room information."},
- status_code=status.HTTP_400_BAD_REQUEST,
- )
-
- session = get_object_or_404(CourseLiveSession, room_id=room_id)
- if require_admin and not claims.get("is_admin"):
- raise AppAPIException(
- {"message": "Only moderators can control recording."},
- status_code=status.HTTP_403_FORBIDDEN,
- )
-
- return session, claims
-
-
-def is_web_egress_recording_status(status_value: str) -> bool:
- return status_value in {
- CourseLiveSession.WEB_EGRESS_STATUS_STARTING,
- CourseLiveSession.WEB_EGRESS_STATUS_RECORDING,
- }
-
-
-def build_web_egress_title(session: CourseLiveSession) -> str:
- return session.recording_title or f"{session.subject} - Recording"
-
-
-def sync_session_recording_titles(session: CourseLiveSession) -> None:
- base_title = session.recording_title or session.subject
- recordings = list(
- LiveSessionRecording.objects.filter(session=session).order_by(
- "created_at",
- "id",
- )
- )
- total = len(recordings)
- if not total:
- return
-
- for index, recording in enumerate(recordings, start=1):
- title = base_title if total == 1 else f"{base_title} part {index}"
-
- if recording.title != title:
- recording.title = title
- recording.save(update_fields=["title", "updated_at"])
-
- if not recording.file:
- continue
-
- lesson = Lesson.objects.filter(content_file=recording.file.name).first()
- if lesson and lesson.title != title:
- lesson.title = title
- lesson.save(update_fields=["title", "updated_at"])
- CourseLesson.objects.filter(lesson=lesson).update(
- title=title,
- updated_at=timezone.now(),
- )
-
-
-class CourseLiveSessionRoomCreateAPIView(GenericAPIView):
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- serializer_class = LiveSessionRoomCreateSerializer
-
- @swagger_auto_schema(
- operation_description="Create a live session room for a course",
- tags=["Imam-Javad - Course"],
- manual_parameters=[
- openapi.Parameter(
- 'slug', openapi.IN_PATH,
- description="Course slug",
- type=openapi.TYPE_STRING,
- required=True
- )
- ],
- responses={
- 201: openapi.Response(
- description="Live session room created successfully"
- )
- }
- )
-
- def post(self, request, slug, *args, **kwargs):
- # 1. Standard Permissions Logic
- course = get_object_or_404(Course, slug__contains=[{"title": slug}])
- if not request.user.can_manage_course(course):
- raise AppAPIException({'message': 'Permission denied'}, status_code=403)
-
- # 2. Setup ID and Metadata
- subject = get_live_session_subject(course)
-
- # 3. Database Logic - Check FIRST before calling PlugNMeet
- session = CourseLiveSession.objects.filter(
- course=course, ended_at__isnull=True
- ).first()
-
- needs_room_creation = False
-
- if session:
- room_id = session.room_id
- logger.info(f"[LiveSession Create] Found active session - session_id={session.id} room_id={room_id}")
- else:
- # No active session, always create a new one with a dynamic room_id
- timestamp = int(time.time())
- room_id = f"room-{course.id}-{timestamp}"
-
- 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,
- 'empty_timeout': 90,
- '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
- }
- }
-
- 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
- }, status=201)
-
-
- # def post(self, request, slug, *args, **kwargs):
- # logger.info(f"[LiveSession Create] Request from user_id={request.user.id} for course={slug}")
-
- # data = dict(request.data or {})
- # if 'metadata' in data:
- # logger.warning("[LiveSession Create] 'metadata' provided by client will be ignored for security reasons.")
- # data.pop('metadata', None)
-
- # serializer = self.get_serializer(data=data)
- # serializer.is_valid(raise_exception=True)
-
- # course = get_object_or_404(Course, slug=slug)
-
- # if not request.user.can_manage_course(course):
- # logger.warning(f"[LiveSession Create] Permission denied - user_id={request.user.id} course={slug}")
- # raise AppAPIException({'message': 'You do not have permission to create a live session for this course.'}, status_code=status.HTTP_403_FORBIDDEN)
-
- # logger.info(f"[LiveSession Create] Permission granted for user_id={request.user.id} course={slug}")
-
- # subject = serializer.validated_data.get('subject') or f"{course.title} Live Session"
- # room_id = self._build_room_id(course)
- # metadata = self._build_metadata(subject)
-
- # payload = {
- # 'room_id': room_id,
- # 'metadata': metadata,
- # }
-
- # logger.info(f"[LiveSession Create] Calling PlugNMeet API - room_id={room_id} course={slug}")
-
- # try:
- # client = PlugNMeetClient()
- # plugnmeet_response = client.create_room(payload)
- # logger.info(f"[LiveSession Create] PlugNMeet room created successfully - room_id={room_id}")
- # except ImproperlyConfigured as exc:
- # logger.error(f"[LiveSession Create] Configuration error - {str(exc)}")
- # raise AppAPIException({'message': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
- # except PlugNMeetError as exc:
- # logger.error(f"[LiveSession Create] PlugNMeet API error - room_id={room_id} error={str(exc)}")
- # detail = exc.response_data or {'message': str(exc)}
- # status_code = exc.status_code or status.HTTP_502_BAD_GATEWAY
- # raise AppAPIException(detail, status_code=status_code)
-
- # session, created = CourseLiveSession.objects.get_or_create(
- # course=course,
- # room_id=room_id,
- # defaults={
- # 'subject': subject,
- # 'started_at': timezone.now(),
- # },
- # )
-
- # if created:
- # logger.info(f"[LiveSession Create] New session created - session_id={session.id} room_id={room_id} course={slug}")
- # else:
- # logger.info(f"[LiveSession Create] Existing session reactivated - session_id={session.id} room_id={room_id} course={slug}")
- # updates = {}
- # if session.subject != subject:
- # session.subject = subject
- # updates['subject'] = subject
- # if session.room_id != room_id:
- # session.room_id = room_id
- # updates['room_id'] = room_id
- # if session.started_at is None:
- # session.started_at = timezone.now()
- # updates['started_at'] = session.started_at
- # if updates:
- # session.save(update_fields=list(updates.keys()))
- # logger.info(f"[LiveSession Create] Session updated - session_id={session.id} fields={list(updates.keys())}")
-
- # logger.info(f"[LiveSession Create] Success - session_id={session.id} room_id={room_id} course={slug} user_id={request.user.id}")
-
- # return Response({
- # 'session': {
- # 'id': session.id,
- # 'room_id': session.room_id,
- # 'subject': session.subject,
- # 'started_at': session.started_at,
- # },
- # 'plugnmeet': plugnmeet_response,
- # }, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
-
- @staticmethod
- def _build_room_id(course: Course) -> str:
- return f"room-{course.id}-imamjavad"
-
- def _build_metadata(self, subject: str) -> dict:
- # Build secured, centralized metadata. Client overrides are NOT allowed.
- return {
- 'room_title': subject,
- 'webhook_url': get_plugnmeet_webhook_url(),
- 'default_lock_settings': {
- 'lock_microphone': True,
- 'lock_webcam': True,
- 'lock_screen_sharing': True,
- 'lock_whiteboard': True,
- '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': False,
- 'enable_auto_cloud_recording': False,
- 'only_record_admin_webcams': False,
- },
- },
- }
-
- def _deep_update(self, base: dict, overrides: dict) -> dict:
- for key, value in overrides.items():
- if isinstance(value, dict) and isinstance(base.get(key), dict):
- base[key] = self._deep_update(base.get(key, {}), value)
- else:
- base[key] = value
- return base
-
-
-class CourseLiveSessionTokenAPIView(GenericAPIView):
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- serializer_class = LiveSessionTokenSerializer
-
- @swagger_auto_schema(
- operation_description="Generate access token for live session",
- tags=["Imam-Javad - Course"],
- responses={
- 200: openapi.Response(
- description="Live session token generated successfully"
- )
- }
- )
- def post(self, request, *args, **kwargs):
- serializer = self.get_serializer(data=request.data)
- serializer.is_valid(raise_exception=True)
-
- course_slug = serializer.validated_data['course_slug']
- user = request.user
-
- logger.info(f"[LiveSession Token] Request from user_id={user.id} for course={course_slug}")
-
- try:
- course = Course.objects.filter(slug__contains=[{"title": course_slug}]).first()
- if not course:
- raise Course.DoesNotExist
- except Course.DoesNotExist:
- logger.warning(f"[LiveSession Token] Course not found - course={course_slug} user_id={user.id}")
- raise AppAPIException({'message': 'Course not found.'}, status_code=status.HTTP_404_NOT_FOUND)
-
- if not course.is_online:
- logger.warning(f"[LiveSession Token] Course not configured for online - course={course_slug} user_id={user.id}")
- raise AppAPIException({'message': 'Course is not configured for online sessions.'}, status_code=status.HTTP_400_BAD_REQUEST)
-
- try:
- session = CourseLiveSession.objects.select_related('course').get(
- course=course,
- ended_at__isnull=True
- )
- logger.info(f"[LiveSession Token] Active session found 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)
-
- # 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)
-
- user_info = {
- 'user_id': str(user.id),
- 'name': user.get_full_name() or user.email or user.username or f"user-{user.id}",
- 'is_admin': is_admin,
- }
-
- user_metadata = {}
- profile_pic = self._build_profile_url(request, user)
- logger.info(f"[LiveSession Token] Profile pic URL - user_id={user.id} url={profile_pic}")
- if profile_pic:
- user_metadata['profilePic'] = profile_pic
-
- if not is_admin:
- user_metadata['lock_settings'] = {
- 'lock_microphone': True,
- 'lock_screen_sharing': True,
- 'lock_webcam': True,
- 'lock_whiteboard': True,
- 'lock_shared_notepad': False,
- 'lock_chat': False,
- 'lock_chat_send_message': False,
- 'lock_chat_file_share': False,
- 'lock_private_chat': False,
- }
- else:
- user_metadata['is_hidden'] = False
-
- if user_metadata:
- user_info['user_metadata'] = user_metadata
-
- payload = {
- 'room_id': room_id,
- 'user_info': user_info,
- }
-
- logger.info(f"[LiveSession Token] Requesting token from PlugNMeet - room_id={room_id} user_id={user.id} role={user_role}")
-
- try:
- client = PlugNMeetClient()
- plugnmeet_response = client.get_join_token(payload)
- logger.info(f"[LiveSession Token] Token generated successfully - room_id={room_id} user_id={user.id}")
-
- # Fail-safe participant registration in database
- try:
- role = 'moderator' if is_admin else 'participant'
- LiveSessionUser.objects.update_or_create(
- session=session,
- user=user,
- defaults={
- 'role': role,
- 'is_online': True,
- 'exited_at': None,
- 'entered_at': timezone.now()
- }
- )
- logger.info(f"[LiveSession Token] Fail-safe registered user entry - session_id={session.id} user_id={user.id}")
- except Exception as e:
- logger.error(f"[LiveSession Token] Failed to register fail-safe user entry: {e}")
- except ImproperlyConfigured as exc:
- logger.error(f"[LiveSession Token] Configuration error - {str(exc)}")
- raise AppAPIException({'message': str(exc)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
- except PlugNMeetError as exc:
- logger.error(f"[LiveSession Token] PlugNMeet API error - room_id={room_id} user_id={user.id} error={str(exc)}")
- detail = exc.response_data or {'message': str(exc)}
- status_code = exc.status_code or status.HTTP_502_BAD_GATEWAY
- raise AppAPIException(detail, status_code=status_code)
-
- logger.info(f"[LiveSession Token] Success - room_id={room_id} user_id={user.id} role={user_role} course={course_slug}")
-
- return Response({
- 'room_id': room_id,
- 'token': plugnmeet_response.get('token'),
- 'plugnmeet': plugnmeet_response,
- })
-
- 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': True,
- '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': False,
- '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
- stop_session_web_egress_if_active(session)
- now = timezone.now()
- session.ended_at = now
- session.save(update_fields=['ended_at', 'updated_at'])
-
- # Also set all users offline
- try:
- updated_count = LiveSessionUser.objects.filter(
- session=session,
- is_online=True
- ).update(is_online=False, exited_at=now, updated_at=now)
- logger.info(f"[Room Verify] Set {updated_count} users offline for session {session.id}")
- except Exception as e:
- logger.error(f"[Room Verify] Failed to set users offline: {e}")
-
- 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():
- stop_session_web_egress_if_active(session)
- now = timezone.now()
- session.ended_at = now
- session.save(update_fields=['ended_at', 'updated_at'])
-
- # Also set all users offline
- try:
- updated_count = LiveSessionUser.objects.filter(
- session=session,
- is_online=True
- ).update(is_online=False, exited_at=now, updated_at=now)
- logger.info(f"[Room Verify] Set {updated_count} users offline (room not found) for session {session.id}")
- except Exception as ex:
- logger.error(f"[Room Verify] Failed to set users offline: {ex}")
-
- 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 get_live_session_subject(course)
- 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)
- if avatar and getattr(avatar, 'url', None):
- return request.build_absolute_uri(avatar.url)
- return None
-
-
-class CourseLiveSessionRecordingAPIView(GenericAPIView):
- permission_classes = [AllowAny]
- authentication_classes = []
- serializer_class = LiveSessionRecordingControlSerializer
-
- @swagger_auto_schema(
- operation_description="Get or control WebEgress recording state for the current room.",
- tags=["Imam-Javad - Course"],
- responses={200: openapi.Response(description="Recording state returned successfully")},
- )
- def get(self, request, *args, **kwargs):
- session, _ = get_session_from_meeting_token(request, require_admin=False)
- self._refresh_status_from_service(session)
- return Response(self._build_status_payload(session), status=status.HTTP_200_OK)
-
- @swagger_auto_schema(
- operation_description="Start or stop WebEgress recording for the current room.",
- tags=["Imam-Javad - Course"],
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=["action"],
- properties={
- "action": openapi.Schema(
- type=openapi.TYPE_STRING,
- enum=["start", "stop"],
- description="Recording control action.",
- ),
- },
- ),
- responses={200: openapi.Response(description="Recording action completed successfully")},
- )
- def post(self, request, *args, **kwargs):
- if not getattr(settings, "ONLINE_CLASS_WEB_EGRESS_ENABLED", False):
- raise AppAPIException(
- {"message": "WebEgress recording is not enabled."},
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- )
-
- session, claims = get_session_from_meeting_token(request, require_admin=True)
- action = str(request.data.get("action") or "").strip().lower()
- if action not in {"start", "stop"}:
- raise AppAPIException(
- {"message": "Invalid recording action."},
- status_code=status.HTTP_400_BAD_REQUEST,
- )
-
- if action == "start":
- payload = self._start_recording(session, claims)
- else:
- payload = self._stop_recording(session)
-
- return Response(payload, status=status.HTTP_200_OK)
-
- def _start_recording(self, session: CourseLiveSession, claims: dict) -> dict:
- if is_web_egress_recording_status(session.web_egress_status):
- return self._build_status_payload(
- session,
- message="Recording is already active or being processed.",
- )
-
- client = WebEgressClient()
- now = timezone.now()
- session.web_egress_status = CourseLiveSession.WEB_EGRESS_STATUS_STARTING
- session.web_egress_started_at = now
- session.web_egress_stopped_at = None
- session.web_egress_error = None
- session.save(
- update_fields=[
- "web_egress_status",
- "web_egress_started_at",
- "web_egress_stopped_at",
- "web_egress_error",
- "updated_at",
- ]
- )
-
- try:
- service_response = client.start_recording(
- {
- "session_id": session.id,
- "course_id": session.course_id,
- "room_id": session.room_id,
- "room_title": session.subject,
- "recording_title": build_web_egress_title(session),
- "join_url": self._build_web_egress_join_url(session),
- "callback_url": get_web_egress_callback_url(),
- "callback_token": getattr(
- settings,
- "ONLINE_CLASS_WEB_EGRESS_CALLBACK_TOKEN",
- "",
- ),
- "requested_by": {
- "user_id": str(claims.get("user_id") or claims.get("sub") or ""),
- "name": claims.get("name") or "",
- },
- }
- )
- except (ImproperlyConfigured, WebEgressError) as exc:
- session.web_egress_status = CourseLiveSession.WEB_EGRESS_STATUS_FAILED
- session.web_egress_error = str(exc)
- session.save(
- update_fields=[
- "web_egress_status",
- "web_egress_error",
- "updated_at",
- ]
- )
- raise AppAPIException(
- {"message": str(exc)},
- status_code=status.HTTP_502_BAD_GATEWAY,
- )
-
- session.web_egress_id = service_response.get("egress_id") or session.web_egress_id
- session.web_egress_status = CourseLiveSession.WEB_EGRESS_STATUS_RECORDING
- session.web_egress_error = None
- session.save(
- update_fields=[
- "web_egress_id",
- "web_egress_status",
- "web_egress_error",
- "updated_at",
- ]
- )
-
- return self._build_status_payload(
- session,
- message=service_response.get("message") or "Recording started successfully.",
- )
-
- def _stop_recording(self, session: CourseLiveSession) -> dict:
- if not is_web_egress_recording_status(session.web_egress_status):
- return self._build_status_payload(
- session,
- message="Recording is not active.",
- )
-
- client = WebEgressClient()
- try:
- service_response = client.stop_recording(
- {
- "session_id": session.id,
- "room_id": session.room_id,
- "egress_id": session.web_egress_id,
- }
- )
- except (ImproperlyConfigured, WebEgressError) as exc:
- session.web_egress_status = CourseLiveSession.WEB_EGRESS_STATUS_FAILED
- session.web_egress_error = str(exc)
- session.save(
- update_fields=[
- "web_egress_status",
- "web_egress_error",
- "updated_at",
- ]
- )
- raise AppAPIException(
- {"message": str(exc)},
- status_code=status.HTTP_502_BAD_GATEWAY,
- )
-
- session.web_egress_status = CourseLiveSession.WEB_EGRESS_STATUS_STOPPING
- session.web_egress_stopped_at = timezone.now()
- session.web_egress_error = None
- session.save(
- update_fields=[
- "web_egress_status",
- "web_egress_stopped_at",
- "web_egress_error",
- "updated_at",
- ]
- )
-
- return self._build_status_payload(
- session,
- message=service_response.get("message") or "Recording stop requested successfully.",
- )
-
- def _refresh_status_from_service(self, session: CourseLiveSession) -> None:
- if not getattr(settings, "ONLINE_CLASS_WEB_EGRESS_ENABLED", False):
- return
- if not session.web_egress_id and session.web_egress_status in {
- CourseLiveSession.WEB_EGRESS_STATUS_IDLE,
- CourseLiveSession.WEB_EGRESS_STATUS_COMPLETED,
- CourseLiveSession.WEB_EGRESS_STATUS_FAILED,
- }:
- return
-
- try:
- client = WebEgressClient()
- service_response = client.get_status(
- room_id=session.room_id,
- session_id=session.id,
- )
- except (ImproperlyConfigured, WebEgressError) as exc:
- logger.warning(
- "[WebEgress] Failed to refresh status for session=%s room_id=%s error=%s",
- session.id,
- session.room_id,
- str(exc),
- )
- return
-
- normalized_status = self._normalize_service_status(service_response)
- update_fields = ["updated_at"]
-
- if service_response.get("egress_id") is not None and session.web_egress_id != service_response.get("egress_id"):
- session.web_egress_id = service_response.get("egress_id")
- update_fields.append("web_egress_id")
-
- if normalized_status and session.web_egress_status != normalized_status:
- session.web_egress_status = normalized_status
- update_fields.append("web_egress_status")
-
- error_message = service_response.get("error") or service_response.get("message")
- if normalized_status == CourseLiveSession.WEB_EGRESS_STATUS_FAILED:
- session.web_egress_error = error_message or session.web_egress_error
- update_fields.append("web_egress_error")
-
- if normalized_status == CourseLiveSession.WEB_EGRESS_STATUS_RECORDING and not session.web_egress_started_at:
- session.web_egress_started_at = timezone.now()
- update_fields.append("web_egress_started_at")
-
- if normalized_status in {
- CourseLiveSession.WEB_EGRESS_STATUS_STOPPING,
- CourseLiveSession.WEB_EGRESS_STATUS_PROCESSING,
- CourseLiveSession.WEB_EGRESS_STATUS_COMPLETED,
- CourseLiveSession.WEB_EGRESS_STATUS_FAILED,
- } and not session.web_egress_stopped_at:
- session.web_egress_stopped_at = timezone.now()
- update_fields.append("web_egress_stopped_at")
-
- if len(update_fields) > 1:
- session.save(update_fields=update_fields)
-
- def _normalize_service_status(self, payload: dict) -> str:
- raw = str(payload.get("state") or payload.get("status") or "").strip().lower()
- if raw in {"recording", "active", "started"}:
- return CourseLiveSession.WEB_EGRESS_STATUS_RECORDING
- if raw in {"starting", "pending"}:
- return CourseLiveSession.WEB_EGRESS_STATUS_STARTING
- if raw in {"stopping"}:
- return CourseLiveSession.WEB_EGRESS_STATUS_STOPPING
- if raw in {"processing", "finalizing", "uploading"}:
- return CourseLiveSession.WEB_EGRESS_STATUS_PROCESSING
- if raw in {"completed", "done", "finished"}:
- return CourseLiveSession.WEB_EGRESS_STATUS_COMPLETED
- if raw in {"failed", "error"}:
- return CourseLiveSession.WEB_EGRESS_STATUS_FAILED
- if payload.get("is_recording") is True:
- return CourseLiveSession.WEB_EGRESS_STATUS_RECORDING
- if payload.get("is_recording") is False:
- return CourseLiveSession.WEB_EGRESS_STATUS_IDLE
- return ""
-
- def _build_web_egress_join_url(self, session: CourseLiveSession) -> str:
- client = PlugNMeetClient()
- join_response = client.get_join_token(
- {
- "room_id": session.room_id,
- "user_info": {
- "user_id": f"web-egress-{session.id}",
- "name": "Session Recorder",
- "is_admin": True,
- "is_hidden": True,
- },
- }
- )
- access_token = join_response.get("token")
- if not access_token:
- raise WebEgressError("PlugNMeet did not return a recorder access token.")
-
- frontend_base = get_online_class_frontend_base()
- return (
- f"{frontend_base}/?access_token={access_token}"
- f"&web_egress=1&skip_landing=1&room_id={session.room_id}"
- )
-
- def _build_status_payload(self, session: CourseLiveSession, message: str | None = None) -> dict:
- return {
- "success": True,
- "room_id": session.room_id,
- "session_id": session.id,
- "status": session.web_egress_status,
- "is_recording": is_web_egress_recording_status(session.web_egress_status),
- "egress_id": session.web_egress_id,
- "started_at": session.web_egress_started_at.isoformat()
- if session.web_egress_started_at
- else None,
- "stopped_at": session.web_egress_stopped_at.isoformat()
- if session.web_egress_stopped_at
- else None,
- "error": session.web_egress_error,
- "message": message,
- }
-
-
-class CourseLiveSessionWebEgressCallbackAPIView(GenericAPIView):
- permission_classes = [AllowAny]
- authentication_classes = []
-
- @swagger_auto_schema(
- operation_description="Receive final WebEgress recording callback and save the final file.",
- tags=["Imam-Javad - Course"],
- responses={201: openapi.Response(description="Recording callback processed successfully")},
- )
- def post(self, request, *args, **kwargs):
- self._verify_callback_token(request)
-
- room_id = request.data.get("room_id")
- session_id = request.data.get("session_id")
- status_value = str(request.data.get("status") or "completed").strip().lower()
- egress_id = request.data.get("egress_id")
- error_message = request.data.get("error") or ""
-
- if not room_id and not session_id:
- raise AppAPIException(
- {"message": "room_id or session_id is required."},
- status_code=status.HTTP_400_BAD_REQUEST,
- )
-
- session = self._get_target_session(room_id=room_id, session_id=session_id)
-
- uploaded_file = request.FILES.get("file")
- duration_seconds = request.data.get("file_time_seconds")
- recording = None
-
- if uploaded_file:
- file_duration = None
- if duration_seconds not in (None, ""):
- try:
- file_duration = timedelta(seconds=float(duration_seconds))
- except (TypeError, ValueError):
- file_duration = None
-
- recording = LiveSessionRecording.objects.create(
- session=session,
- file=uploaded_file,
- title=request.data.get("title") or build_web_egress_title(session),
- recording_type=request.data.get("recording_type") or "video",
- file_time=file_duration,
- )
-
- session.web_egress_id = egress_id or session.web_egress_id
- session.web_egress_stopped_at = session.web_egress_stopped_at or timezone.now()
- session.web_egress_error = error_message or None
- session.web_egress_status = self._normalize_callback_status(
- status_value=status_value,
- has_file=bool(uploaded_file),
- )
- session.save(
- update_fields=[
- "web_egress_id",
- "web_egress_stopped_at",
- "web_egress_error",
- "web_egress_status",
- "updated_at",
- ]
- )
-
- response_status = status.HTTP_201_CREATED if recording else status.HTTP_200_OK
- return Response(
- {
- "success": True,
- "session_id": session.id,
- "room_id": session.room_id,
- "recording_id": recording.id if recording else None,
- "status": session.web_egress_status,
- },
- status=response_status,
- )
-
- def _verify_callback_token(self, request) -> None:
- expected = getattr(settings, "ONLINE_CLASS_WEB_EGRESS_CALLBACK_TOKEN", "")
- if not expected:
- raise AppAPIException(
- {"message": "WebEgress callback token is not configured."},
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- )
-
- received = (
- request.headers.get("X-Web-Egress-Token")
- or request.data.get("callback_token")
- or ""
- )
- if received != expected:
- raise AppAPIException(
- {"message": "Invalid WebEgress callback token."},
- status_code=status.HTTP_403_FORBIDDEN,
- )
-
- def _get_target_session(self, *, room_id=None, session_id=None) -> CourseLiveSession:
- queryset = CourseLiveSession.objects.order_by("-started_at", "-id")
- if session_id:
- try:
- return queryset.get(id=int(session_id))
- except (ValueError, CourseLiveSession.DoesNotExist):
- pass
- if room_id:
- return get_object_or_404(queryset, room_id=room_id)
- raise AppAPIException(
- {"message": "Unable to resolve target live session."},
- status_code=status.HTTP_404_NOT_FOUND,
- )
-
- @staticmethod
- def _normalize_callback_status(*, status_value: str, has_file: bool) -> str:
- if has_file:
- return CourseLiveSession.WEB_EGRESS_STATUS_COMPLETED
- if status_value in {"failed", "error"}:
- return CourseLiveSession.WEB_EGRESS_STATUS_FAILED
- if status_value in {"processing", "uploading", "stopping"}:
- return CourseLiveSession.WEB_EGRESS_STATUS_PROCESSING
- return CourseLiveSession.WEB_EGRESS_STATUS_COMPLETED
-
-
-class CourseLiveSessionRecordedFileAPIView(GenericAPIView):
- permission_classes = [AllowAny]
- authentication_classes = [TokenAuthentication]
- serializer_class = LiveSessionRecordingSerializer
-
- @swagger_auto_schema(
- operation_description="Update recorded file for live session",
- tags=["Imam-Javad - Course"],
- manual_parameters=[
- openapi.Parameter(
- 'room_slug', openapi.IN_PATH,
- description="Room Slug (e.g. room-16)",
- type=openapi.TYPE_STRING,
- required=True
- )
- ],
- responses={
- 200: openapi.Response(
- description="Recorded file updated successfully"
- )
- }
- )
- def patch(self, request, room_slug, *args, **kwargs):
- logger.info(f"[LiveSession Recorded File] Request headers: {dict(request.headers)}")
- user_id = request.user.id if request.user else None
- logger.info(f"[LiveSession Recorded File] Request from user_id={user_id} for room_slug={room_slug}")
-
- import re
- match = re.search(r'\d+', room_slug)
- if not match:
- logger.error(f"[LiveSession Recorded File] Invalid room_slug: {room_slug}")
- raise AppAPIException({'message': f'Invalid room slug: {room_slug}'}, status_code=status.HTTP_400_BAD_REQUEST)
-
- course_id = int(match.group())
- logger.info(f"[LiveSession Recorded File] Extracted course_id={course_id} from room_slug={room_slug}")
-
- course = get_object_or_404(Course, id=course_id)
-
- # If user is authenticated, enforce permissions.
- # Otherwise, allow the background recording service to proceed.
- if request.user and request.user.is_authenticated:
- if not request.user.can_manage_course(course):
- logger.warning(f"[LiveSession Recorded File] Permission denied - user_id={request.user.id} course_id={course_id}")
- raise AppAPIException({'message': 'You do not have permission to update this course.'}, status_code=status.HTTP_403_FORBIDDEN)
- else:
- logger.info(f"[LiveSession Recorded File] Allowing unauthenticated recording registration for room_slug={room_slug}")
-
- try:
- session = course.live_sessions.latest('-started_at')
- except CourseLiveSession.DoesNotExist:
- logger.warning(f"[LiveSession Recorded File] No active session found - course_id={course_id}")
- raise AppAPIException({'message': 'No live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND)
-
- logger.info(f"[LiveSession Recorded File] Latest session found - session_id={session.id} course_id={course_id}")
-
- logger.info(f"[LiveSession Recorded File] Request data: {request.data}")
- serializer = self.get_serializer(data=request.data)
- if not serializer.is_valid():
- logger.error(f"[LiveSession Recorded File] Validation errors: {serializer.errors}")
- raise AppAPIException(serializer.errors, status_code=status.HTTP_400_BAD_REQUEST)
-
- recording = LiveSessionRecording.objects.create(
- session=session,
- file=serializer.validated_data['file'],
- title=serializer.validated_data.get('title') or f"{session.subject} Recording",
- recording_type=serializer.validated_data.get('recording_type', 'video'),
- file_time=serializer.validated_data.get('file_time'),
- )
-
- logger.info(f"[LiveSession Recorded File] Recording created successfully - recording_id={recording.id} session_id={session.id}")
-
- return Response({
- 'id': recording.id,
- 'session_id': session.id,
- 'title': recording.title,
- 'file': request.build_absolute_uri(recording.file.url),
- 'recording_type': recording.recording_type,
- 'file_time': recording.file_time,
- 'is_active': recording.is_active,
- }, status=status.HTTP_201_CREATED)
-
-
-class CourseLiveSessionSetRecordingTitleAPIView(GenericAPIView):
- permission_classes = [AllowAny]
- authentication_classes = []
-
- @swagger_auto_schema(
- operation_description="Set the recording title for the active live session",
- tags=["Imam-Javad - Course"],
- request_body=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- required=['room_id', 'title'],
- properties={
- 'room_id': openapi.Schema(type=openapi.TYPE_STRING, description='Room ID'),
- 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Custom Recording Title'),
- }
- ),
- responses={
- 200: openapi.Response(
- description="Recording title updated successfully"
- )
- }
- )
- def post(self, request, *args, **kwargs):
- room_id = request.data.get('room_id')
- title = request.data.get('title')
-
- if not room_id or not title:
- raise AppAPIException({'message': 'room_id and title are required.'}, status_code=status.HTTP_400_BAD_REQUEST)
-
- title = title.strip()
- if not title:
- raise AppAPIException({'message': 'title cannot be empty.'}, status_code=status.HTTP_400_BAD_REQUEST)
-
- try:
- session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True)
- except CourseLiveSession.DoesNotExist:
- raise AppAPIException({'message': 'Active session not found.'}, status_code=status.HTTP_404_NOT_FOUND)
-
- session.recording_title = title
- session.subject = title
- session.save(update_fields=['recording_title', 'subject', 'updated_at'])
- sync_session_recording_titles(session)
-
- logger.info(f"[LiveSession Title] Set recording title for room_id={room_id} to '{title}'")
- return Response({'success': True, 'message': 'Recording title updated successfully.'}, status=status.HTTP_200_OK)
diff --git a/apps/course/views/participant.py b/apps/course/views/participant.py
deleted file mode 100644
index b49d743..0000000
--- a/apps/course/views/participant.py
+++ /dev/null
@@ -1,79 +0,0 @@
-from rest_framework import generics
-from rest_framework.exceptions import NotFound
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
-from rest_framework.permissions import IsAuthenticated
-
-from apps.account.models import StudentUser
-from apps.course.models import Participant, Course
-from apps.course.serializers import ParticipantSerializer
-from apps.account.serializers import UserProfileSerializer
-from apps.course.doc import *
-from utils.exceptions import AppAPIException
-from utils.pagination import StandardResultsSetPagination
-
-
-
-from rest_framework import serializers
-from django.db.models import Subquery, OuterRef
-
-class CourseParticipantSerializer(UserProfileSerializer):
- is_active = serializers.BooleanField(read_only=True)
-
- class Meta(UserProfileSerializer.Meta):
- fields = UserProfileSerializer.Meta.fields + ['is_active']
-
-
-class CourseParticipantsView(generics.ListAPIView):
- serializer_class = CourseParticipantSerializer
- pagination_class = StandardResultsSetPagination
-
- @swagger_auto_schema(
- operation_description=doc_course_participants(),
- tags=['Imam-Javad - Course'],
- )
- def get(self, request, *args, **kwargs):
- return self.list(request, *args, **kwargs)
- def get_queryset(self):
- """
- Optimized queryset with select_related for course relationship.
- Filters out guest users (no email) while keeping inactive students visible
- for the admin course participants table.
- """
- course_slug = self.kwargs.get('slug')
- course = Course.objects.filter(slug__contains=[{"title": course_slug}]).first()
- if not course:
- raise AppAPIException({'message': "Course not found"})
-
- return StudentUser.objects.select_related().filter(
- participated_courses__course=course,
- email__isnull=False # Exclude guest users
- ).exclude(
- email__exact='' # Extra safety just in case an email is a blank string
- ).distinct()
-# queryset = StudentUser.objects.all()
-# serializer_class = ParticipantSerializer
-# permission_classes = [IsAuthenticated]
-
-
-# def create(self, request, *args, **kwargs):
-# user = request.user
-# course_slug = self.kwargs.get('slug') # Get the slug from the URL
-# try:
-# course = Course.objects.get(slug=slug) # Retrieve the Course object
-# except Course.DoesNotExist:
-# raise AppAPIException({'message': "Course not found"}) # Handle course not found
-
-# if request.data.get('email') != request.user:
-# raise AppAPIException({'message': "The email must be for the requesting user"})
-
-# if user.user_type != User.UserType.STUDENT:
-# user.change_user_type(User.UserType.STUDENT)
-
-# participant, created = Participant.objects.get_or_create(
-# student=user,
-# course=course
-# )
-
-# serializer = self.get_serializer(participant)
-# return Response(serializer.data, status=status.HTTP_201_CREATED)
diff --git a/apps/course/views/professor.py b/apps/course/views/professor.py
deleted file mode 100644
index 895fb94..0000000
--- a/apps/course/views/professor.py
+++ /dev/null
@@ -1,206 +0,0 @@
-from django.contrib.auth import get_user_model
-from django.db.models import Count, Q
-from django.shortcuts import get_object_or_404
-
-from rest_framework.filters import SearchFilter
-from rest_framework.generics import ListAPIView, RetrieveAPIView
-from rest_framework.permissions import AllowAny
-from drf_yasg import openapi
-from drf_yasg.utils import swagger_auto_schema
-
-from apps.course.models import Course
-from apps.course.serializers import (
- CourseListSerializer,
- ProfessorDetailSerializer,
- ProfessorListSerializer,
-)
-from utils.pagination import StandardResultsSetPagination
-from django.utils.decorators import method_decorator
-from django.views.decorators.cache import never_cache
-from django.core.cache import cache
-from rest_framework.response import Response
-
-
-UserModel = get_user_model()
-
-
-class ProfessorListAPIView(ListAPIView):
- permission_classes = [AllowAny]
- serializer_class = ProfessorListSerializer
- filter_backends = [SearchFilter]
- search_fields = ['fullname', 'email']
- pagination_class = StandardResultsSetPagination
-
- @swagger_auto_schema(
- operation_description='دریافت فهرست استادها به همراه تعداد دورهها و درسهای فعال هر استاد.',
- tags=['Imam-Javad - Course'],
- responses={
- 200: openapi.Response(
- description='فهرست صفحهبندیشدهی استادها.',
- schema=ProfessorListSerializer(many=True),
- examples={
- 'application/json': {
- 'count': 1,
- 'next': None,
- 'previous': None,
- 'results': [
- {
- 'id': 7,
- 'slug': 'dr-rahimi',
- 'fullname': 'دکتر رحیمی',
- 'experience_years': 10,
- 'course_count': 4,
- 'lesson_count': 56,
- }
- ],
- }
- },
- )
- },
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def get_queryset(self):
- return (
- UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR, is_active=True)
- .annotate(
- course_count=Count('courses', distinct=True),
- lesson_count=Count('courses__lessons', filter=Q(courses__lessons__is_active=True), distinct=True),
- )
- .order_by('fullname')
- )
-
-
-class ProfessorDetailAPIView(RetrieveAPIView):
- permission_classes = [AllowAny]
- serializer_class = ProfessorDetailSerializer
- lookup_field = 'slug'
-
- @swagger_auto_schema(
- operation_description='دریافت جزئیات یک استاد بر اساس اسلاگ.',
- tags=['Imam-Javad - Course'],
- responses={
- 200: openapi.Response(
- description='اطلاعات کامل استاد.',
- schema=ProfessorDetailSerializer(),
- examples={
- 'application/json': {
- 'id': 7,
- 'device_id': 'abc-123',
- 'fcm': None,
- 'fullname': 'دکتر رحیمی',
- 'avatar': None,
- 'email': 'rahimi@example.com',
- 'phone_number': '+989121234567',
- 'password': None,
- 'info': 'متخصص فیزیک پزشکی.',
- 'skill': 'فیزیک، تدریس آنلاین',
- 'city': 'تهران',
- 'country': 'ایران',
- 'birthdate': '1985-04-12',
- 'gender': 'male',
- 'slug': 'dr-rahimi',
- 'experience_years': 10,
- 'course_count': 4,
- 'lesson_count': 56,
- }
- },
- )
- },
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def retrieve(self, request, *args, **kwargs):
- slug = self.kwargs.get('slug')
- cache_key = f"professor_detail_{slug}"
-
- # Try to get the data from Redis/Cache
- cached_data = cache.get(cache_key)
- if cached_data:
- return Response(cached_data)
-
- # If not in cache, let DRF do the heavy lifting (DB queries, serialization)
- response = super().retrieve(request, *args, **kwargs)
-
- # Store the serialized data in the cache for 24 hours
- cache.set(cache_key, response.data, timeout=60 * 60 * 24)
-
- return response
-
-
-
- def get_queryset(self):
- return UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR, is_active=True).annotate(
- course_count=Count('courses', distinct=True),
- lesson_count=Count('courses__lessons', filter=Q(courses__lessons__is_active=True), distinct=True),
- )
-
-
-class ProfessorCourseListAPIView(ListAPIView):
- permission_classes = [AllowAny]
- serializer_class = CourseListSerializer
- filter_backends = [SearchFilter]
- search_fields = ['title', 'category__name']
- pagination_class = StandardResultsSetPagination
-
- @method_decorator(never_cache)
- def dispatch(self, request, *args, **kwargs):
- return super().dispatch(request, *args, **kwargs)
-
-
- @swagger_auto_schema(
- operation_description='دریافت فهرست دورههای فعال یک استاد مشخصشده با اسلاگ.',
- tags=['Imam-Javad - Course'],
- responses={
- 200: openapi.Response(
- description='فهرست صفحهبندیشدهی دورهها.',
- schema=CourseListSerializer(many=True),
- examples={
- 'application/json': {
- 'count': 1,
- 'next': None,
- 'previous': None,
- 'results': [
- {
- 'id': 42,
- 'title': 'فیزیک پایه',
- 'slug': 'basic-physics',
- 'participant_count': 150,
- 'category': {
- 'name': 'علوم پایه',
- 'slug': 'basic-science',
- 'course_count': 12,
- },
- 'thumbnail': None,
- 'is_online': True,
- 'online_link': 'https://example.com/live/basic-physics',
- 'level': 'beginner',
- 'duration': '12h',
- 'lessons_count': 24,
- 'short_description': 'مروری بر مفاهیم پایه فیزیک.',
- 'status': 'published',
- 'is_free': False,
- 'price': '250000',
- 'discount_percentage': 20,
- 'final_price': '200000',
- }
- ],
- }
- },
- )
- },
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def get_queryset(self):
- slug = self.kwargs.get('slug')
- professor = get_object_or_404(UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR, is_active=True, slug=slug))
- return Course.objects.select_related('category').prefetch_related(
- 'professors',
- 'lessons__lesson',
- 'lessons__completions',
- 'participants__student',
- ).exclude(status__contains=[{"title": Course.StatusChoices.INACTIVE}]).filter(professors=professor)
diff --git a/apps/course/views/webhook.py b/apps/course/views/webhook.py
deleted file mode 100644
index 3cce51f..0000000
--- a/apps/course/views/webhook.py
+++ /dev/null
@@ -1,491 +0,0 @@
-import json
-import hmac
-import hashlib
-import logging
-import os
-import tempfile
-import subprocess
-import base64
-import jwt
-from typing import Dict, Any, Optional
-from datetime import timedelta
-
-from django.conf import settings
-from django.core.exceptions import ImproperlyConfigured
-from django.utils import timezone
-from django.utils.decorators import method_decorator
-from django.views.decorators.csrf import csrf_exempt
-from django.core.files.base import ContentFile
-
-from rest_framework import status
-from rest_framework.views import APIView
-from rest_framework.response import Response
-from rest_framework.permissions import AllowAny
-from rest_framework.parsers import BaseParser
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
-
-from apps.course.models import CourseLiveSession, LiveSessionUser, Course, Participant, LiveSessionRecording
-from apps.account.models import User
-from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError
-from apps.course.services.web_egress import stop_session_web_egress_if_active
-from utils.exceptions import AppAPIException
-
-logger = logging.getLogger(__name__)
-
-AUTO_CLOSE_AFTER_MODERATOR_EXIT_MINUTES = getattr(
- settings,
- "ONLINE_CLASS_AUTO_CLOSE_AFTER_MODERATOR_EXIT_MINUTES",
- 10,
-)
-
-class RawJSONParser(BaseParser):
- """
- Parser that preserves the raw body bytes for HMAC signature verification.
- """
- media_type = '*/*'
- def parse(self, stream, media_type=None, parser_context=None):
- return stream.read()
-
-@method_decorator(csrf_exempt, name='dispatch')
-class PlugNMeetWebhookAPIView(APIView):
- """
- Webhook endpoint to receive and process events from the PlugNMeet server.
-
- Handles:
- - room_finished: Closes the live session record.
- - participant_joined: Tracks student entry.
- - participant_left: Tracks student exit.
- - end_recording: Downloads and saves session recordings.
- """
- authentication_classes = []
- permission_classes = [AllowAny]
- parser_classes = [RawJSONParser]
-
- @swagger_auto_schema(
- operation_description="Handle webhook events from PlugNMeet server for live sessions",
- tags=["Imam-Javad - Course"],
- responses={
- 200: openapi.Response(description="Webhook processed successfully"),
- 403: openapi.Response(description="Invalid signature"),
- 400: openapi.Response(description="Invalid payload")
- }
- )
- def post(self, request, *args, **kwargs):
- logger.info("⚡ [PlugNMeet Webhook] Request received")
-
- # 1. Extract Signature
- hash_token = request.headers.get('Hash-Token') or request.META.get('HTTP_HASH_TOKEN')
-
- if not hash_token:
- logger.error("❌ [PlugNMeet Webhook] Missing Hash-Token header")
- return Response({'message': 'Missing Hash-Token header'}, status=403)
-
- # 2. Verify Signature
- if not self._verify_webhook_signature(request, hash_token):
- return Response({'message': 'Invalid webhook signature'}, status=403)
-
- # 3. Parse Payload
- try:
- body_bytes = request.data # RawJSONParser puts bytes here
- payload = json.loads(body_bytes.decode('utf-8'))
- except Exception as e:
- logger.error(f"❌ [PlugNMeet Webhook] Parsing Error: {e}")
- return Response({'message': 'Invalid JSON'}, status=400)
-
- event = payload.get('event')
- logger.info(f"✅ [PlugNMeet Webhook] Event: {event}")
-
- # 4. Route Event
- handler_map = {
- 'room_finished': self._handle_room_finished,
- 'session_ended': self._handle_room_finished,
- 'participant_joined': self._handle_participant_joined,
- 'participant_left': self._handle_participant_left,
- 'recording_proceeded': self._handle_recording_proceeded,
- }
-
- handler = handler_map.get(event)
- if not handler:
- logger.info(f"ℹ️ [PlugNMeet Webhook] Event {event} ignored")
- return Response({'status': 'ok', 'message': f'Event {event} ignored'})
-
- try:
- result = handler(payload)
- return Response({'status': 'ok', **result})
- except Exception as e:
- logger.error(f"❌ [PlugNMeet Webhook] Error in {event}: {e}", exc_info=True)
- return Response({'status': 'error', 'message': str(e)}, status=500)
-
- def _verify_webhook_signature(self, request, hash_token: str) -> bool:
- api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None)
- if not api_secret:
- logger.error("❌ [PlugNMeet Webhook] PLUGNMEET_API_SECRET not configured")
- return False
-
- body_bytes = request.data
- auth_header = request.headers.get('Authorization') or request.META.get('HTTP_AUTHORIZATION', '')
-
- logger.info(f"🔍 [PlugNMeet Webhook] Verify Signature details:")
- logger.info(f" - Hash-Token (first 30 chars): {hash_token[:30]}... (length: {len(hash_token)})")
- logger.info(f" - Authorization (first 30 chars): {auth_header[:30]}... (length: {len(auth_header)})")
- logger.info(f" - Body bytes length: {len(body_bytes)}")
- if len(body_bytes) > 0:
- logger.info(f" - Body preview (first 100 bytes): {body_bytes[:100]}")
-
- # 1. Try JWT verification (PlugNMeet production standard)
- try:
- # A JWT typically consists of three segments separated by dots
- if len(hash_token.split('.')) == 3:
- logger.info(" - Attempting JWT decode on Hash-Token...")
- decoded = jwt.decode(
- hash_token,
- api_secret,
- algorithms=['HS256'],
- options={
- "verify_signature": True,
- "verify_exp": False,
- "verify_nbf": False,
- "verify_iat": False,
- "verify_aud": False,
- "verify_iss": False
- }
- )
- logger.info(f" - Decoded JWT claims: {list(decoded.keys())}")
- token_sha256 = decoded.get('sha256')
- if token_sha256:
- hasher = hashlib.sha256()
- hasher.update(body_bytes)
- computed_sha256 = base64.b64encode(hasher.digest()).decode('utf-8')
- logger.info(f" - Claim SHA256: {token_sha256}")
- logger.info(f" - Computed SHA256: {computed_sha256}")
- if hmac.compare_digest(token_sha256, computed_sha256):
- logger.info(" - JWT SHA256 signature verification successful!")
- return True
- else:
- logger.error(f"❌ [PlugNMeet Webhook] SHA256 mismatch! Token claim: {token_sha256}, Computed: {computed_sha256}")
- else:
- logger.error("❌ [PlugNMeet Webhook] JWT missing 'sha256' claim")
- else:
- logger.info(" - Hash-Token does not have 3 segments (not a JWT).")
- except jwt.PyJWTError as e:
- logger.error(f"❌ [PlugNMeet Webhook] JWT decoding failed: {e.__class__.__name__}: {str(e)}")
- logger.warning(f"⚠️ [PlugNMeet Webhook] JWT decoding failed. Falling back to HMAC-SHA256 check.")
-
- # 2. Fallback: HMAC-SHA256 verification (for backward compatibility with test_webhook.py)
- expected_signature = hmac.new(
- api_secret.encode('utf-8'),
- body_bytes,
- hashlib.sha256
- ).hexdigest()
-
- logger.info(f" - Expected HMAC: {expected_signature}")
- if hmac.compare_digest(hash_token, expected_signature):
- logger.info(" - Fallback HMAC verification successful!")
- return True
-
- logger.error(f"❌ [PlugNMeet Webhook] Signature mismatch! \nReceived: {hash_token[:10]}...\nExpected HMAC: {expected_signature[:10]}...")
- return False
-
- @staticmethod
- def _extract_room_id(room_data: Dict[str, Any]) -> Optional[str]:
- if not isinstance(room_data, dict):
- return None
-
- return (
- room_data.get('room_id')
- or room_data.get('roomId')
- or room_data.get('identity')
- or room_data.get('name')
- )
-
- def _handle_room_finished(self, payload: Dict[str, Any]) -> Dict[str, Any]:
- room_data = payload.get('room', {})
- room_id = self._extract_room_id(room_data)
-
- if not room_id:
- logger.warning(f"⚠️ [PlugNMeet Webhook] Missing room_id in room_finished event. Payload: {payload}")
- return {'message': 'Missing room identity'}
-
- try:
- session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True)
- stop_session_web_egress_if_active(session)
- now = timezone.now()
- session.ended_at = now
- update_fields = ['ended_at', 'updated_at']
- if session.web_egress_status in {
- CourseLiveSession.WEB_EGRESS_STATUS_STARTING,
- CourseLiveSession.WEB_EGRESS_STATUS_RECORDING,
- }:
- session.web_egress_status = CourseLiveSession.WEB_EGRESS_STATUS_PROCESSING
- update_fields.append('web_egress_status')
- if not session.web_egress_stopped_at:
- session.web_egress_stopped_at = now
- update_fields.append('web_egress_stopped_at')
- session.save(update_fields=update_fields)
-
- # Close active user sessions
- updated_count = LiveSessionUser.objects.filter(
- session=session,
- is_online=True,
- exited_at__isnull=True
- ).update(is_online=False, exited_at=now, updated_at=now)
-
- logger.info(f"🏁 [PlugNMeet Webhook] Session {session.id} ended. Users disconnected: {updated_count}")
- return {'session_id': session.id, 'closed_users': updated_count}
- except CourseLiveSession.DoesNotExist:
- logger.warning(f"⚠️ [PlugNMeet Webhook] room_finished: No active session found for room_id {room_id}")
- return {'message': 'No active session found for this room'}
- except Exception as e:
- logger.error(f"❌ [PlugNMeet Webhook] Error in room_finished for room_id {room_id}: {e}", exc_info=True)
- return {'error': str(e)}
-
- def _handle_participant_joined(self, payload: Dict[str, Any]) -> Dict[str, Any]:
- room_data = payload.get('room', {})
- participant_data = payload.get('participant', {})
- room_id = self._extract_room_id(room_data)
- user_id = participant_data.get('identity') or participant_data.get('user_id') or participant_data.get('userId')
-
- if not room_id or not user_id:
- logger.warning(f"⚠️ [PlugNMeet Webhook] Missing room_id or user_id in participant_joined. room_id: {room_id}, user_id: {user_id}. Payload: {payload}")
- return {'message': 'Missing required metadata'}
-
- try:
- if not str(user_id).isdigit():
- logger.info(f"ℹ️ [PlugNMeet Webhook] Ignoring non-integer user join: {user_id}")
- return {'message': f'Ignored non-integer user_id {user_id}'}
-
- session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True)
- user = User.objects.get(id=int(user_id))
-
- role = 'moderator' if user.can_manage_course(session.course) else 'participant'
-
- session_user, created = LiveSessionUser.objects.update_or_create(
- session=session,
- user=user,
- defaults={
- 'role': role,
- 'is_online': True,
- 'exited_at': None,
- 'entered_at': timezone.now()
- }
- )
- if role == 'moderator':
- self._clear_pending_auto_close(session)
- logger.info(f"👤 [PlugNMeet Webhook] User {user.id} joined session {session.id} (created: {created})")
- return {'session_user_id': session_user.id, 'created': created}
- except Exception as e:
- logger.error(f"❌ [PlugNMeet Webhook] Error in participant_joined (room_id: {room_id}, user_id: {user_id}): {e}", exc_info=True)
- return {'error': str(e)}
-
- def _handle_participant_left(self, payload: Dict[str, Any]) -> Dict[str, Any]:
- room_data = payload.get('room', {})
- participant_data = payload.get('participant', {})
- room_id = self._extract_room_id(room_data)
- user_id = participant_data.get('identity') or participant_data.get('user_id') or participant_data.get('userId')
-
- if not room_id or not user_id:
- logger.warning(f"⚠️ [PlugNMeet Webhook] Missing room_id or user_id in participant_left. room_id: {room_id}, user_id: {user_id}. Payload: {payload}")
- return {'message': 'Missing required metadata'}
-
- try:
- if not str(user_id).isdigit():
- logger.info(f"ℹ️ [PlugNMeet Webhook] Ignoring non-integer user leave: {user_id}")
- return {'message': f'Ignored non-integer user_id {user_id}'}
-
- session = CourseLiveSession.objects.get(room_id=room_id)
- user = User.objects.get(id=int(user_id))
-
- updated = LiveSessionUser.objects.filter(
- session=session, user=user, is_online=True
- ).update(is_online=False, exited_at=timezone.now(), updated_at=timezone.now())
-
- if updated:
- self._update_auto_close_state_after_leave(session, user)
-
- logger.info(f"🚪 [PlugNMeet Webhook] User {user.id} left session {session.id} (updated entries count: {updated})")
- return {'updated': bool(updated)}
- except Exception as e:
- logger.error(f"❌ [PlugNMeet Webhook] Error in participant_left (room_id: {room_id}, user_id: {user_id}): {e}", exc_info=True)
- return {'error': str(e)}
-
- def _get_video_duration(self, video_path: str) -> timezone.timedelta:
- """Get video duration using ffprobe, return as timedelta."""
- try:
- cmd = [
- 'ffprobe',
- '-v', 'error',
- '-show_entries', 'format=duration',
- '-of', 'default=noprint_wrappers=1:nokey=1',
- video_path
- ]
- result = subprocess.run(
- cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- timeout=30
- )
- if result.returncode == 0:
- duration_seconds = float(result.stdout.decode().strip())
- return timezone.timedelta(seconds=duration_seconds)
- except Exception as e:
- logger.warning(f"⚠️ [PlugNMeet Webhook] Duration extraction failed: {e}")
- return timezone.timedelta(seconds=0)
-
- def _handle_recording_proceeded(self, payload: Dict[str, Any]) -> Dict[str, Any]:
- logger.info(f"📥 [PlugNMeet Webhook] Processing recording_proceeded payload: {payload}")
- room_data = payload.get('room', {})
- recording_info = payload.get('recording_info', {})
-
- room_id = self._extract_room_id(room_data)
- recording_id = (
- recording_info.get('record_id') or
- recording_info.get('recordId') or
- recording_info.get('recording_id') or
- recording_info.get('recordingId')
- )
- file_path = recording_info.get('file_path') or recording_info.get('filePath')
-
- if not room_id or not recording_id:
- print("ERROR------------------------", recording_id, room_id, payload)
- logger.warning(f"⚠️ [PlugNMeet Webhook] Missing room_id or recording_id in recording_proceeded event. room_id: {room_id}, recording_id: {recording_id}")
- return {'message': 'Missing recording metadata'}
-
- file_name = os.path.basename(file_path) if file_path else f"{recording_id}.mp4"
-
- try:
- session = CourseLiveSession.objects.get(room_id=room_id)
- client = PlugNMeetClient()
-
- recording_info_response = client.get_recording_info(recording_id)
- recording_info_payload = (
- recording_info_response.get('recordingInfo')
- or recording_info_response.get('recording_info')
- or {}
- )
- recording_metadata = recording_info_payload.get('metadata') or {}
- extra_data = recording_metadata.get('extraData') or recording_metadata.get('extra_data') or {}
- if extra_data.get('imamjavad_recording_kind') == 'segment':
- logger.info(f"ℹ️ [PlugNMeet Webhook] Ignoring partial recording segment: {recording_id}")
- return {'message': 'Ignored partial recording segment'}
-
- # 1. Fetch download token
- token_response = client.get_recording_download_token(recording_id)
- if not token_response.get('status'):
- return {'error': 'Failed to get download token'}
-
- download_token = token_response.get('token')
- download_path = f"/download/recording/{download_token}"
-
- # 2. Download to temporary file
- with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_file:
- tmp_file_path = tmp_file.name
-
- try:
- logger.info(f"📥 [PlugNMeet Webhook] Downloading recording {recording_id}...")
- client.download_file(download_path, tmp_file_path)
-
- # 3. Read duration using ffprobe
- duration = self._get_video_duration(tmp_file_path)
-
- # 4. Save to Database
- with open(tmp_file_path, 'rb') as f:
- content = f.read()
-
- recording = LiveSessionRecording.objects.create(
- session=session,
- title=session.recording_title or f"{session.subject} - Recording",
- file_time=duration if duration.total_seconds() > 0 else None,
- recording_type='video' if file_name.lower().endswith('.mp4') else 'voice'
- )
- recording.file.save(file_name, ContentFile(content), save=True)
-
- # 5. Generate thumbnail (Optional)
- self._generate_video_thumbnail(tmp_file_path, recording)
-
- logger.info(f"💾 [PlugNMeet Webhook] Recording saved successfully: {recording.id}")
- return {'recording_id': recording.id, 'file': file_name}
-
- finally:
- if os.path.exists(tmp_file_path):
- os.unlink(tmp_file_path)
-
- except Exception as e:
- logger.error(f"❌ [PlugNMeet Webhook] RECORDING_PROCEEDED Error: {e}", exc_info=True)
- return {'error': str(e)}
-
- def _generate_video_thumbnail(self, video_path: str, recording: LiveSessionRecording) -> bool:
- try:
- with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_thumb:
- thumbnail_path = tmp_thumb.name
-
- cmd = [
- 'ffmpeg', '-ss', '1', '-i', video_path, '-frames:v', '1',
- '-q:v', '2', '-vf', 'scale=640:-1', '-y', thumbnail_path
- ]
-
- result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
-
- if result.returncode == 0 and os.path.exists(thumbnail_path) and os.path.getsize(thumbnail_path) > 0:
- with open(thumbnail_path, 'rb') as f:
- recording.thumbnail.save(f"thumb_{recording.id}.jpg", ContentFile(f.read()), save=True)
- os.unlink(thumbnail_path)
- return True
- return False
- except Exception as e:
- logger.warning(f"⚠️ [PlugNMeet Webhook] Thumbnail failed: {e}")
- return False
-
- def _update_auto_close_state_after_leave(self, session: CourseLiveSession, user: User) -> None:
- if session.ended_at or not user.can_manage_course(session.course):
- return
-
- has_other_moderator_online = LiveSessionUser.objects.filter(
- session=session,
- role='moderator',
- is_online=True,
- ).exists()
- if has_other_moderator_online:
- self._clear_pending_auto_close(session)
- return
-
- has_non_moderator_online = LiveSessionUser.objects.filter(
- session=session,
- is_online=True,
- ).exclude(role='moderator').exists()
- if not has_non_moderator_online:
- self._clear_pending_auto_close(session)
- return
-
- now = timezone.now()
- due_at = now + timedelta(minutes=AUTO_CLOSE_AFTER_MODERATOR_EXIT_MINUTES)
- session.last_moderator_left_at = now
- session.auto_close_after_moderator_exit_at = due_at
- session.save(
- update_fields=[
- 'last_moderator_left_at',
- 'auto_close_after_moderator_exit_at',
- 'updated_at',
- ]
- )
- logger.info(
- "⏳ [PlugNMeet Webhook] Auto-close countdown scheduled - session_id=%s room_id=%s due_at=%s",
- session.id,
- session.room_id,
- due_at.isoformat(),
- )
-
- @staticmethod
- def _clear_pending_auto_close(session: CourseLiveSession) -> None:
- if not session.last_moderator_left_at and not session.auto_close_after_moderator_exit_at:
- return
-
- session.last_moderator_left_at = None
- session.auto_close_after_moderator_exit_at = None
- session.save(
- update_fields=[
- 'last_moderator_left_at',
- 'auto_close_after_moderator_exit_at',
- 'updated_at',
- ]
- )
diff --git a/apps/quiz/__init__.py b/apps/quiz/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/quiz/admin/__init__.py b/apps/quiz/admin/__init__.py
deleted file mode 100644
index 42092ad..0000000
--- a/apps/quiz/admin/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from .quiz import *
-from .question import *
-from .participant import *
-# from .prize import *
-# from .user_rank_quiz import *
\ No newline at end of file
diff --git a/apps/quiz/admin/participant.py b/apps/quiz/admin/participant.py
deleted file mode 100644
index 0756e30..0000000
--- a/apps/quiz/admin/participant.py
+++ /dev/null
@@ -1,111 +0,0 @@
-from django.contrib import admin
-from django.db.models import F
-from django.contrib.admin import SimpleListFilter
-from django.utils.translation import gettext_lazy as _
-from django import forms
-
-from unfold.admin import ModelAdmin, StackedInline, TabularInline
-from unfold.decorators import display
-
-from apps.quiz.models import QuizParticipant, ParticipantAnswer
-from apps.account.models import User
-
-from utils.admin import project_admin_site
-
-# --- INLINE FOR QUIZ DETAIL PAGE ---
-class MinWidthInlineForm(forms.ModelForm):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- target_dropdown_fields = ['user']
- for field_name, field in self.fields.items():
- if field_name in target_dropdown_fields and hasattr(field.widget, 'attrs'):
- existing_class = field.widget.attrs.get('class', '')
- field.widget.attrs['class'] = f"{existing_class} min-w-[250px] w-full"
- existing_style = field.widget.attrs.get('style', '')
- field.widget.attrs['style'] = f"{existing_style} min-width: 250px; width: 250px;"
-
-class QuizParticipantInline(TabularInline):
- model = QuizParticipant
- form = MinWidthInlineForm
- extra = 0
- tab = True
- fields = ('user', 'started_at', 'total_timing', 'total_score')
- readonly_fields = ('user', 'started_at', 'total_timing', 'total_score')
- autocomplete_fields = ('user',)
- show_change_link = True
- verbose_name = _("Recent Participant")
- verbose_name_plural = _("Recent Participants (Latest 10)")
-
- def get_queryset(self, request):
- qs = super().get_queryset(request)
- object_id = request.resolver_match.kwargs.get('object_id')
- if object_id:
- latest_ids = list(qs.filter(quiz_id=object_id).order_by('-started_at').values_list('id', flat=True)[:10])
- return qs.filter(id__in=latest_ids).order_by('-started_at')
- return qs.none()
-
- def has_add_permission(self, request, obj):
- return False
- def has_change_permission(self, request, obj=None):
- return False
- def has_delete_permission(self, request, obj=None):
- return False
-
-
-class ParticipantAnswerInline(StackedInline):
- model = ParticipantAnswer
- readonly_fields = (
- 'correct_answer_display', 'question', 'at_time', 'answer_timing',
- )
-
- @display(description=_("Correct Answer"))
-
- def correct_answer_display(self, obj):
- return obj.correct_answer
-
- def has_add_permission(self, request, obj):
- return False
-
- def has_delete_permission(self, request, obj=None):
- return False
-
- def get_queryset(self, request):
- return super().get_queryset(request).annotate(correct_answer=F('question__correct_answer'))
-
-
-class UserEmailFilter(SimpleListFilter):
- title = _('User Email')
- parameter_name = 'user_email'
-
- def lookups(self, request, model_admin):
- # 🔔 FILTER: Only fetch active users who have an email (not guests or deleted)
- users = User.objects.filter(is_active=True, email__isnull=False)
- return [(user.email, user.email) for user in users]
-
- def queryset(self, request, queryset):
- if self.value():
- email = self.value().replace('%40', '@')
- return queryset.filter(user__email=email)
- return queryset
-
-
-class ParticipantAdmin(ModelAdmin):
- inlines = [ParticipantAnswerInline]
- search_fields = ['user__username', 'user__fullname']
- list_display = [
- 'quiz', 'user', 'started_at', 'ended_at', 'total_timing',
- 'question_score', 'timing_score', 'total_score'
- ]
- list_filter = ['started_at', 'ended_at', 'quiz__status', UserEmailFilter]
-
- # Optional: Add these for better UI experience
- date_hierarchy = 'started_at'
- ordering = ['-started_at']
-
- # 🔔 FILTER: Restrict the user dropdown in forms to active, non-guest users
- def formfield_for_foreignkey(self, db_field, request, **kwargs):
- if db_field.name == "user":
- kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
- return super().formfield_for_foreignkey(db_field, request, **kwargs)
-
-project_admin_site.register(QuizParticipant, ParticipantAdmin)
\ No newline at end of file
diff --git a/apps/quiz/admin/question.py b/apps/quiz/admin/question.py
deleted file mode 100644
index ef4e464..0000000
--- a/apps/quiz/admin/question.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from django import forms
-from django.contrib import admin
-from django.utils.translation import gettext_lazy as _
-
-from unfold.admin import ModelAdmin, TabularInline, StackedInline
-from unfold.forms import forms
-
-from apps.course.models.course import extract_text_from_json
-from apps.quiz.models import Question
-
-from utils.admin import project_admin_site
-
-
-
-# Uncomment if you want to register Question as a standalone admin
-# @admin.register(Question)
-# class QuestionAdmin(ModelAdmin):
-# list_display = ('question', 'correct_answer', 'quiz', 'priority')
-# form = QuestionAdminForm
-# ordering = ("priority", "id",)
-# fieldsets = (
-# (
-# None, {
-# 'fields': (
-# 'question',
-# ('option1', 'option2'),
-# ('option3', 'option4'),
-# 'correct_answer',
-# )
-# },
-# ),
-# (
-# None, {
-# 'fields': ('priority',)
-# }
-# )
-# )
-@admin.register(Question)
-class QuestionAdmin(ModelAdmin):
- list_display = ('question_display', 'correct_answer', 'quiz', 'priority')
- ordering = ("priority", "id",)
- search_fields = ('question', 'quiz__title')
- list_filter = ('quiz',)
-
- fieldsets = (
- (
- None, {
- 'fields': (
- 'quiz',
- 'question',
- ('option1', 'option2'),
- ('option3', 'option4'),
- 'correct_answer',
- )
- },
- ),
- (
- None, {
- 'fields': ('priority',)
- }
- )
- )
-
- @admin.display(description=_('Question'))
- def question_display(self, obj):
- return extract_text_from_json(obj.question)
-
-class QuestionAdminInline(StackedInline):
- model = Question
- ordering = ("priority", "id",)
- extra = 0
-
- fieldsets = (
- (
- None, {
- 'fields': (
- 'question',
- ('option1', 'option2'),
- ('option3', 'option4'),
- 'correct_answer',
- )
- },
- ),
- (
- None, {
- 'fields': ('priority',)
- }
- )
- )
-project_admin_site.register(Question, QuestionAdmin)
-
diff --git a/apps/quiz/admin/quiz.py b/apps/quiz/admin/quiz.py
deleted file mode 100644
index 2dcbf5b..0000000
--- a/apps/quiz/admin/quiz.py
+++ /dev/null
@@ -1,194 +0,0 @@
-from django.contrib import admin, messages
-from django.db.models import Q
-from django.db.models import Count
-from django.utils.safestring import mark_safe
-from django.urls import reverse
-from django.shortcuts import redirect
-from django.utils.translation import gettext_lazy as _
-
-from unfold.admin import ModelAdmin
-from unfold.decorators import display, action
-
-from apps.course.models import CourseLesson
-from apps.course.models import Course
-from apps.course.models.course import extract_text_from_json
-from apps.quiz.models import Quiz
-from apps.quiz.admin.question import QuestionAdminInline
-from apps.quiz.admin.participant import QuizParticipantInline
-from utils.admin import project_admin_site, admin_url_generator
-
-
-class QuizAdmin(ModelAdmin):
- search_fields = ['title', 'description']
- list_display = ['title_display', 'description_display', 'lesson', 'each_question_timing', 'status_display', 'questions_display']
- list_filter = ['each_question_timing', 'status']
- inlines = [QuestionAdminInline, QuizParticipantInline]
- compressed_fields = True
-
- # 🔔 ADD THE TOP ACTION BUTTON
- actions_detail = ['manage_all_participants','go_to_course']
-
- def get_queryset(self, request):
- queryset = super().get_queryset(request).annotate(
- questions_count=Count('questions')
- )
-
- # اولویت اول: staff یا admin - دسترسی کامل
- if (request.user.is_staff or
- request.user.has_role('admin') or
- request.user.has_role('super_admin')):
- return queryset
-
- # اولویت دوم: professor - فقط کوئیزهای دورههای خود
- if request.user.has_role('professor'):
- return queryset.filter(lesson__course__professors=request.user)
-
- return queryset.none()
-
- def _find_matching_course_ids(self, search_term):
- normalized = (search_term or "").strip().lower()
- if not normalized:
- return []
-
- matching_ids = list(
- Course.objects.filter(
- Q(title__icontains=search_term) | Q(slug__icontains=search_term)
- ).values_list("id", flat=True)
- )
-
- if matching_ids:
- return matching_ids
-
- for course in Course.objects.all().only("id", "title", "slug"):
- title_text = extract_text_from_json(course.title).lower()
- slug_text = extract_text_from_json(course.slug).lower()
- if normalized in title_text or normalized in slug_text:
- matching_ids.append(course.id)
-
- return matching_ids
-
- def _find_matching_lesson_ids(self, search_term):
- normalized = (search_term or "").strip().lower()
- if not normalized:
- return []
-
- matching_ids = []
- for lesson in CourseLesson.objects.select_related("lesson").all():
- lesson_title = extract_text_from_json(
- lesson.title or (lesson.lesson.title if lesson.lesson else [])
- ).lower()
- if normalized in lesson_title:
- matching_ids.append(lesson.id)
-
- return matching_ids
-
- def get_exclude(self, request, obj=None):
- if not obj: # اگر obj وجود ندارد یعنی صفحه Add است
- return ['course']
- return []
-
- # 🔔 قفل کردن (Readonly) فیلد کورس در زمان مشاهده و ویرایش
- def get_readonly_fields(self, request, obj=None):
- if obj: # اگر obj وجود دارد یعنی صفحه Change/Detail است
- return ['course']
- return []
-
- def get_form(self, request, obj=None, **kwargs):
- form = super().get_form(request, obj, **kwargs)
-
- # محدود کردن انتخاب lesson بر اساس سطح دسترسی کاربر
- if (request.user.is_staff or
- request.user.has_role('admin') or
- request.user.has_role('super_admin')):
- # اولویت اول: staff یا admin - دسترسی کامل
- form.base_fields['lesson'].queryset = CourseLesson.objects.all()
- elif request.user.has_role('professor'):
- # اولویت دوم: professor - فقط CourseLesson های دورههای خود
- form.base_fields['lesson'].queryset = CourseLesson.objects.filter(course__professors=request.user)
- else:
- # سایر کاربران - عدم دسترسی
- form.base_fields['lesson'].queryset = CourseLesson.objects.none()
-
- form.base_fields['lesson'].widget.can_add_related = False
-
- return form
-
- def get_search_results(self, request, queryset, search_term):
- queryset, use_distinct = super().get_search_results(request, queryset, search_term)
- matching_course_ids = self._find_matching_course_ids(search_term)
- matching_lesson_ids = self._find_matching_lesson_ids(search_term)
- matching_quiz_ids = []
-
- normalized = (search_term or "").strip().lower()
- if normalized:
- for quiz in self.model.objects.all().only("id", "title", "description"):
- title_text = extract_text_from_json(quiz.title).lower()
- description_text = extract_text_from_json(quiz.description).lower()
- if normalized in title_text or normalized in description_text:
- matching_quiz_ids.append(quiz.id)
-
- if matching_course_ids or matching_lesson_ids or matching_quiz_ids:
- queryset = queryset | self.model.objects.filter(
- Q(id__in=matching_quiz_ids) |
- Q(course_id__in=matching_course_ids) |
- Q(lesson_id__in=matching_lesson_ids)
- )
- use_distinct = True
-
- return queryset, use_distinct
-
- @display(description=_('Title'))
- def title_display(self, obj):
- return extract_text_from_json(obj.title)
-
- @display(description=_('Description'))
- def description_display(self, obj):
- return extract_text_from_json(obj.description)
-
- @display(description=_('Status'), ordering='status')
- def status_display(self, obj):
- if obj.status:
- return mark_safe(f'{_("Active")}')
- return mark_safe(f'{_("Inactive")}')
-
- @display(description=_('Questions'), ordering='questions_count')
- def questions_display(self, obj):
- url = reverse('admin:quiz_question_changelist') + f'?quiz={obj.id}'
- return mark_safe(f'{_("Questions")}: {obj.questions_count}')
-
- # 🔔 THE REDIRECT LOGIC FOR THE NEW BUTTON
- @action(
- description=_("Manage All Participants"),
- icon="groups",
- )
- def manage_all_participants(self, request, object_id):
- """Redirect to the pre-filtered Quiz Participant changelist for this quiz."""
- quiz = self.get_object(request, object_id)
- if not quiz:
- messages.error(request, _("Quiz not found"))
- return redirect(admin_url_generator(request, "quiz_quiz_changelist"))
-
- # Generate base URL for quiz participant list
- base_url = admin_url_generator(request, "quiz_quizparticipant_changelist")
-
- # Append the filter query parameter
- url = f"{base_url}?quiz__id__exact={object_id}"
- return redirect(url)
-
- @action(
- description=_("View Course"),
- icon="school", # آیکون کلاه فارغالتحصیلی
- )
- def go_to_course(self, request, object_id):
- """دکمهای برای رفتن به صفحه جزئیات کورسِ این کوییز"""
- quiz = self.get_object(request, object_id)
-
- # پیدا کردن کورس از طریق درس (Lesson)
- if quiz and quiz.lesson and quiz.lesson.course_id:
- url = reverse('admin:course_course_change', args=[quiz.lesson.course_id])
- return redirect(url)
-
- messages.error(request, _("Course not found for this quiz."))
- return redirect(request.META.get('HTTP_REFERER', '/'))
-
-project_admin_site.register(Quiz, QuizAdmin)
diff --git a/apps/quiz/admin/user_rank_quiz.py b/apps/quiz/admin/user_rank_quiz.py
deleted file mode 100644
index e6b988d..0000000
--- a/apps/quiz/admin/user_rank_quiz.py
+++ /dev/null
@@ -1,132 +0,0 @@
-# import calendar
-# from django.utils import timezone
-
-# from ajaxdatatable.admin import AjaxDatatable
-# from django.contrib import admin
-# from django.utils.translation import gettext_lazy as _
-# from django.contrib.admin import SimpleListFilter
-# from django.db.models.functions import Rank, Coalesce
-# from django.db.models import Sum, F, Window, CharField
-# from django.utils.html import format_html
-# from apps.quiz.models import Quiz, QuizRankUser, Participant, QuizCategory
-# from apps.account.models import User
-
-
-# class QuizFilter(SimpleListFilter):
-# title = _('Quiz')
-# parameter_name = 'quiz'
-
-# def lookups(self, request, model_admin):
-# quizzes = Quiz.objects.all()
-# return [(quiz.id, quiz.video.title) for quiz in quizzes]
-
-# def queryset(self, request, queryset):
-# if self.value():
-# return queryset.filter(uquizzes__quiz__id=self.value())
-# return queryset
-
-# class QuizCategoryFilter(SimpleListFilter):
-# title = _('Quiz Category')
-# parameter_name = 'quiz_category'
-
-# def lookups(self, request, model_admin):
-# categories = QuizCategory.objects.all()
-# return [(category.id, category.name) for category in categories]
-
-# def queryset(self, request, queryset):
-# if self.value():
-# return queryset.filter(uquizzes__quiz__category__id=self.value())
-# return queryset
-
-# class MonthFilter(SimpleListFilter):
-# title = _('Month')
-# parameter_name = 'month'
-
-# def lookups(self, request, model_admin):
-# return [(str(i), calendar.month_name[i]) for i in range(1, 13)]
-
-# def queryset(self, request, queryset):
-# if self.value():
-# month = int(self.value())
-# year = timezone.now().year
-# return queryset.filter(uquizzes__started_at__year=year, uquizzes__started_at__month=month)
-# return queryset
-
-
-# @admin.register(QuizRankUser)
-# class QuizRankUserAdmin(AjaxDatatable):
-# list_display = ('username_link', 'get_total_score', 'get_rank')
-# list_filter = (QuizFilter, QuizCategoryFilter, MonthFilter)
-# readonly_fields = ('date_joined', 'last_login')
-
-
-# def get_queryset(self, request):
-# queryset = super().get_queryset(request)
-
-# quiz_id = request.GET.get('quiz')
-# category_id = request.GET.get('quiz_category')
-# month = request.GET.get('month')
-
-# filters = {}
-# if quiz_id:
-# filters['uquizzes__quiz_id'] = quiz_id
-# if category_id:
-# filters['uquizzes__quiz__category_id'] = category_id
-# if month:
-# month = int(month)
-# year = timezone.now().year
-# filters['uquizzes__started_at__year'] = year
-# filters['uquizzes__started_at__month'] = month
-
-# if filters:
-# queryset = queryset.filter(**filters)
-
-# users_scores = Participant.objects.filter(**{k.replace('uquizzes__', ''): v for k, v in filters.items()}).select_related('user').values(
-# username=Coalesce(F('user__username'), F('user__email'), output_field=CharField())
-# ).annotate(
-# score=Sum('total_score')
-# ).order_by('-score')
-
-# # Add rank to each user using window function
-# users_scores = users_scores.annotate(
-# rank=Window(
-# expression=Rank(),
-# order_by=F('score').desc()
-# )
-# ).order_by("rank")
-
-# user_scores_dict = {user['username']: user for user in users_scores}
-# for user in queryset:
-# user.score = user_scores_dict.get(user.username, {}).get('score', 0)
-# user.rank = user_scores_dict.get(user.username, {}).get('rank', 'N/A')
-# self.queryset = queryset
-# return queryset
-
-# def has_view_permission(self, request, obj=None):
-# return True
-
-# def has_change_permission(self, request, obj=None):
-# return False
-
-# def has_add_permission(self, request):
-# return False
-
-# def has_delete_permission(self, request, obj=None):
-# return False
-
-# def username_link(self, obj):
-# return format_html('{}', obj.id, obj.username)
-# username_link.short_description = 'Username'
-# username_link.admin_order_field = 'username'
-
-# def get_total_score(self, obj):
-# for user in self.queryset:
-# if user.id == obj.id:
-# return user.score
-# get_total_score.short_description = 'Total Score'
-
-# def get_rank(self, obj):
-# for user in self.queryset:
-# if user.id == obj.id:
-# return user.rank
-# get_rank.short_description = 'Rank'
diff --git a/apps/quiz/apps.py b/apps/quiz/apps.py
deleted file mode 100644
index 519127d..0000000
--- a/apps/quiz/apps.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from django.apps import AppConfig
-
-
-class QuizConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'apps.quiz'
diff --git a/apps/quiz/doc.py b/apps/quiz/doc.py
deleted file mode 100644
index 7099d7e..0000000
--- a/apps/quiz/doc.py
+++ /dev/null
@@ -1,131 +0,0 @@
-def doc_quiz_submit():
- return """
-# 📝 ارسال پاسخهای کوییز
-
-این API برای ثبت شرکت کاربر در کوییز و ارسال پاسخهای مربوطه استفاده میشود. زمانی که کاربر در کوییز شرکت میکند، باید به همراه پاسخهای خود، زمان پاسخدهی و اطلاعات دیگر را ارسال نماید. در این API، کاربر نمیتواند دوباره در همان کوییز شرکت کند.
-
----
-
-## 📄 توضیحات مقادیر پاسخ
-
-| کلید | نوع داده | توضیحات |
-|------------------------|-----------------|---------------------------------------------------------|
-| `quiz` | Integer | شناسه کوییز که کاربر در آن شرکت کرده است. |
-| `started_at` | DateTime | زمان شروع کوییز. |
-| `ended_at` | DateTime | زمان پایان کوییز. |
-| `total_timing` | Integer | مدت زمان کلی که کاربر برای پاسخدهی به کوییز صرف کرده است.|
-| `question_score` | Integer | امتیاز بهدستآمده توسط کاربر در پاسخ به سوالات کوییز. |
-| `timing_score` | Integer | امتیاز بهدستآمده توسط کاربر بر اساس زمان پاسخدهی. |
-| `total_score` | Integer | امتیاز کلی کاربر در کوییز (ترکیب امتیاز سوالات و زمان).|
-| `answers` | Array | لیستی از پاسخهای کاربر به سوالات. |
-| `answers.question` | Integer | شناسه سوالی که کاربر به آن پاسخ داده است. |
-| `answers.option_num` | Integer | شماره گزینهای که کاربر انتخاب کرده است. |
-| `answers.at_time` | DateTime | زمانی که کاربر پاسخ به سوال را ارسال کرده است. |
-| `answers.answer_timing`| Integer | مدت زمان پاسخدهی به سوال (در ثانیه). |
-
----
-## errors:
-# کاربر از قبل کوعیز را شرکت کرده است
-```json
-{
- "status": "error",
- "code": "validation_error",
- "status_code": 400,
- "message": "There were validation errors.",
- "errors": [
- {
- "field": "quiz",
- "message": "you have already participated in the quiz"
- }
- ]
-}
-```
-
----
-## پاسخ موفق (201 Created)
-
-در صورتی که ثبتنام موفقیتآمیز باشد و پاسخها ذخیره شوند، یک شیء JSON مشابه با نمونه زیر برگشت داده میشود:
-
-### پاسخ:
-```json
-{
- "quiz": 1,
- "started_at": "2024-11-29T12:00:00Z",
- "ended_at": "2024-11-29T12:30:00Z",
- "total_timing": 1800,
- "question_score": 80,
- "timing_score": 10,
- "total_score": 90,
- "answers": [
- {
- "question": 1,
- "option_num": 3,
- "at_time": "2024-11-29T12:05:00Z",
- "answer_timing": 30
- },
- {
- "question": 2,
- "option_num": 1,
- "at_time": "2024-11-29T12:15:00Z",
- "answer_timing": 45
- }
- ]
-}
-"""
-
-def doc_quiz_detail():
- return """
-# 📋 Quiz Detail API
-
-با ایدی درس میتواند وارد یک کوعیز شوید
-این api
-سوالات کوعیز و جزعیاتش را برمیگرداند
-
-
-## URL
-`GET /path//`
-
-## پارامترها
-
-- `lesson_id`: شناسه درس برای دریافت کوییز مرتبط.
-
-## پاسخ موفق (200 OK)
-
-در صورتی که درس دارای کوییز باشد، یک شیء JSON با اطلاعات کوییز برگشت داده میشود.
-
-### پاسخ:
-
-```json
-{
- "id": 1,
- "permission": true,
- "lesson": 101,
- "title": "Quiz on Python Basics",
- "description": "A quiz on the basics of Python programming.",
- "each_question_timing": 30,
- "questions": [
- {
- "id": 1,
- "question": "What is the output of print(2 + 3)?",
- "options": [
- {"id": 1, "title": "5"},
- {"id": 2, "title": "6"},
- {"id": 3, "title": "7"},
- {"id": 4, "title": "8"}
- ],
- "correct_answer": 1
- },
- {
- "id": 2,
- "question": "What is the result of 2 * 3?",
- "options": [
- {"id": 1, "title": "6"},
- {"id": 2, "title": "5"},
- {"id": 3, "title": "7"},
- {"id": 4, "title": "8"}
- ],
- "correct_answer": 1
- }
- ]
-}
-"""
diff --git a/apps/quiz/management/__init__.py b/apps/quiz/management/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/quiz/management/commands/__init__.py b/apps/quiz/management/commands/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/quiz/management/commands/clear_quiz_data.py b/apps/quiz/management/commands/clear_quiz_data.py
deleted file mode 100644
index c3a0192..0000000
--- a/apps/quiz/management/commands/clear_quiz_data.py
+++ /dev/null
@@ -1,78 +0,0 @@
-from django.core.management.base import BaseCommand
-from django.db import transaction
-
-from apps.quiz.models import Quiz, Question, QuizParticipant, ParticipantAnswer
-
-
-class Command(BaseCommand):
- help = 'Clear all quiz-related data from the database'
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--confirm',
- action='store_true',
- help='Confirm that you want to delete all quiz data',
- )
-
- def handle(self, *args, **options):
- if not options['confirm']:
- self.stdout.write(
- self.style.WARNING(
- 'This command will delete ALL quiz-related data from the database!\n'
- 'This includes:\n'
- '- All Quizzes\n'
- '- All Questions\n'
- '- All Quiz Participants\n'
- '- All Participant Answers\n\n'
- 'Use --confirm flag to proceed with deletion.\n'
- 'Example: python manage.py clear_quiz_data --confirm'
- )
- )
- return
-
- try:
- with transaction.atomic():
- # Count records before deletion
- participant_answers_count = ParticipantAnswer.objects.count()
- quiz_participants_count = QuizParticipant.objects.count()
- questions_count = Question.objects.count()
- quizzes_count = Quiz.objects.count()
-
- self.stdout.write(
- f'Found {participant_answers_count} participant answers, '
- f'{quiz_participants_count} quiz participants, '
- f'{questions_count} questions, and '
- f'{quizzes_count} quizzes.'
- )
-
- # Delete in order to respect foreign key constraints
- # ParticipantAnswer -> QuizParticipant -> Quiz
- # Question -> Quiz
-
- self.stdout.write('Deleting participant answers...')
- ParticipantAnswer.objects.all().delete()
-
- self.stdout.write('Deleting quiz participants...')
- QuizParticipant.objects.all().delete()
-
- self.stdout.write('Deleting questions...')
- Question.objects.all().delete()
-
- self.stdout.write('Deleting quizzes...')
- Quiz.objects.all().delete()
-
- self.stdout.write(
- self.style.SUCCESS(
- f'Successfully deleted all quiz data:\n'
- f'- {participant_answers_count} participant answers\n'
- f'- {quiz_participants_count} quiz participants\n'
- f'- {questions_count} questions\n'
- f'- {quizzes_count} quizzes'
- )
- )
-
- except Exception as e:
- self.stdout.write(
- self.style.ERROR(f'Error occurred while clearing quiz data: {str(e)}')
- )
- raise
\ No newline at end of file
diff --git a/apps/quiz/migrations/0001_initial.py b/apps/quiz/migrations/0001_initial.py
deleted file mode 100644
index a496dba..0000000
--- a/apps/quiz/migrations/0001_initial.py
+++ /dev/null
@@ -1,220 +0,0 @@
-# Generated by Django 4.2.27 on 2026-01-22 10:48
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ("course", "0001_initial"),
- ("account", "0001_initial"),
- ]
-
- operations = [
- migrations.CreateModel(
- name="Quiz",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "title",
- models.CharField(
- help_text="Quiz Title", max_length=255, verbose_name="title"
- ),
- ),
- (
- "description",
- models.CharField(
- blank=True, max_length=55, null=True, verbose_name="Description"
- ),
- ),
- ("each_question_timing", models.PositiveIntegerField()),
- ("status", models.BooleanField(default=True)),
- (
- "lesson",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="quizzes",
- to="course.courselesson",
- verbose_name="lesson",
- ),
- ),
- ],
- options={
- "verbose_name": "Quiz",
- "verbose_name_plural": "Quizzes",
- "ordering": ("-id",),
- },
- ),
- migrations.CreateModel(
- name="QuizRankUser",
- fields=[],
- options={
- "verbose_name": "Rank Quiz",
- "verbose_name_plural": "Rank Quizzes",
- "proxy": True,
- "indexes": [],
- "constraints": [],
- },
- bases=("account.user",),
- ),
- migrations.CreateModel(
- name="QuizParticipant",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- ("started_at", models.DateTimeField(verbose_name="started at")),
- ("ended_at", models.DateTimeField(verbose_name="ended at")),
- (
- "total_timing",
- models.PositiveIntegerField(
- help_text="Seconds take to finish the quiz"
- ),
- ),
- ("question_score", models.PositiveIntegerField()),
- ("timing_score", models.PositiveIntegerField()),
- ("total_score", models.PositiveIntegerField()),
- (
- "quiz",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="participants",
- to="quiz.quiz",
- ),
- ),
- (
- "user",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="uquizzes",
- to=settings.AUTH_USER_MODEL,
- verbose_name="user",
- ),
- ),
- ],
- options={
- "verbose_name": "Participant",
- "verbose_name_plural": "Participants",
- "ordering": ("-id",),
- },
- ),
- migrations.CreateModel(
- name="Question",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- ("question", models.CharField(max_length=255)),
- ("option1", models.CharField(max_length=255, verbose_name="option 1")),
- ("option2", models.CharField(max_length=255, verbose_name="option 2")),
- ("option3", models.CharField(max_length=255, verbose_name="option 3")),
- ("option4", models.CharField(max_length=255, verbose_name="option 4")),
- (
- "correct_answer",
- models.PositiveSmallIntegerField(
- choices=[
- (1, "Option 1"),
- (2, "Option 2"),
- (3, "Option 3"),
- (4, "Option 4"),
- ]
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="created at"),
- ),
- ("priority", models.IntegerField(blank=True, null=True)),
- (
- "quiz",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="questions",
- to="quiz.quiz",
- verbose_name="quiz",
- ),
- ),
- ],
- options={
- "verbose_name": "Question",
- "verbose_name_plural": "Questions",
- "ordering": ("-priority", "-id"),
- },
- ),
- migrations.CreateModel(
- name="ParticipantAnswer",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "option_num",
- models.PositiveSmallIntegerField(
- choices=[
- (1, "Option 1"),
- (2, "Option 2"),
- (3, "Option 3"),
- (4, "Option 4"),
- ],
- verbose_name="selected option",
- ),
- ),
- ("at_time", models.DateTimeField()),
- (
- "answer_timing",
- models.PositiveSmallIntegerField(
- default=0, verbose_name="seconds take to answer"
- ),
- ),
- (
- "participant",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="answers",
- to="quiz.quizparticipant",
- ),
- ),
- (
- "question",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE, to="quiz.question"
- ),
- ),
- ],
- options={
- "verbose_name": "User Quiz Answer",
- "verbose_name_plural": "User Quiz Answers",
- "ordering": ("-id",),
- },
- ),
- ]
diff --git a/apps/quiz/migrations/0002_alter_participantanswer_answer_timing_and_more.py b/apps/quiz/migrations/0002_alter_participantanswer_answer_timing_and_more.py
deleted file mode 100644
index 173ff55..0000000
--- a/apps/quiz/migrations/0002_alter_participantanswer_answer_timing_and_more.py
+++ /dev/null
@@ -1,147 +0,0 @@
-# Generated by Django 5.2.12 on 2026-05-03 14:09
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0004_alter_lessoncompletion_options_and_more'),
- ('quiz', '0001_initial'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='participantanswer',
- name='answer_timing',
- field=models.PositiveSmallIntegerField(default=0, verbose_name='Seconds Take to Answer'),
- ),
- migrations.AlterField(
- model_name='participantanswer',
- name='at_time',
- field=models.DateTimeField(verbose_name='At Time'),
- ),
- migrations.AlterField(
- model_name='participantanswer',
- name='option_num',
- field=models.PositiveSmallIntegerField(choices=[(1, 'Option 1'), (2, 'Option 2'), (3, 'Option 3'), (4, 'Option 4')], verbose_name='Selected Option'),
- ),
- migrations.AlterField(
- model_name='participantanswer',
- name='participant',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='quiz.quizparticipant', verbose_name='Participant'),
- ),
- migrations.AlterField(
- model_name='participantanswer',
- name='question',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='quiz.question', verbose_name='Question'),
- ),
- migrations.AlterField(
- model_name='question',
- name='correct_answer',
- field=models.PositiveSmallIntegerField(choices=[(1, 'Option 1'), (2, 'Option 2'), (3, 'Option 3'), (4, 'Option 4')], verbose_name='Correct Answer'),
- ),
- migrations.AlterField(
- model_name='question',
- name='created_at',
- field=models.DateTimeField(auto_now_add=True, verbose_name='Created At'),
- ),
- migrations.AlterField(
- model_name='question',
- name='option1',
- field=models.CharField(max_length=255, verbose_name='Option 1'),
- ),
- migrations.AlterField(
- model_name='question',
- name='option2',
- field=models.CharField(max_length=255, verbose_name='Option 2'),
- ),
- migrations.AlterField(
- model_name='question',
- name='option3',
- field=models.CharField(max_length=255, verbose_name='Option 3'),
- ),
- migrations.AlterField(
- model_name='question',
- name='option4',
- field=models.CharField(max_length=255, verbose_name='Option 4'),
- ),
- migrations.AlterField(
- model_name='question',
- name='priority',
- field=models.IntegerField(blank=True, null=True, verbose_name='Priority'),
- ),
- migrations.AlterField(
- model_name='question',
- name='question',
- field=models.CharField(max_length=255, verbose_name='Question'),
- ),
- migrations.AlterField(
- model_name='question',
- name='quiz',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='quiz.quiz', verbose_name='Quiz'),
- ),
- migrations.AlterField(
- model_name='quiz',
- name='each_question_timing',
- field=models.PositiveIntegerField(verbose_name='Each Question Timing'),
- ),
- migrations.AlterField(
- model_name='quiz',
- name='lesson',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='course.courselesson', verbose_name='Lesson'),
- ),
- migrations.AlterField(
- model_name='quiz',
- name='status',
- field=models.BooleanField(default=True, verbose_name='Status'),
- ),
- migrations.AlterField(
- model_name='quiz',
- name='title',
- field=models.CharField(help_text='Quiz Title', max_length=255, verbose_name='Title'),
- ),
- migrations.AlterField(
- model_name='quizparticipant',
- name='ended_at',
- field=models.DateTimeField(verbose_name='Ended At'),
- ),
- migrations.AlterField(
- model_name='quizparticipant',
- name='question_score',
- field=models.PositiveIntegerField(verbose_name='Question Score'),
- ),
- migrations.AlterField(
- model_name='quizparticipant',
- name='quiz',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='quiz.quiz', verbose_name='Quiz'),
- ),
- migrations.AlterField(
- model_name='quizparticipant',
- name='started_at',
- field=models.DateTimeField(verbose_name='Started At'),
- ),
- migrations.AlterField(
- model_name='quizparticipant',
- name='timing_score',
- field=models.PositiveIntegerField(verbose_name='Timing Score'),
- ),
- migrations.AlterField(
- model_name='quizparticipant',
- name='total_score',
- field=models.PositiveIntegerField(verbose_name='Total Score'),
- ),
- migrations.AlterField(
- model_name='quizparticipant',
- name='total_timing',
- field=models.PositiveIntegerField(help_text='Seconds take to finish the quiz', verbose_name='Total Timing'),
- ),
- migrations.AlterField(
- model_name='quizparticipant',
- name='user',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uquizzes', to=settings.AUTH_USER_MODEL, verbose_name='User'),
- ),
- ]
diff --git a/apps/quiz/migrations/0003_quiz_course.py b/apps/quiz/migrations/0003_quiz_course.py
deleted file mode 100644
index ba5c3ae..0000000
--- a/apps/quiz/migrations/0003_quiz_course.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# Generated by Django 5.2.12 on 2026-05-16 15:01
-
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0006_alter_course_professor_alter_course_video_file_and_more'),
- ('quiz', '0002_alter_participantanswer_answer_timing_and_more'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='quiz',
- name='course',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='course.course', verbose_name='Course'),
- ),
- ]
diff --git a/apps/quiz/migrations/0004_alter_question_options.py b/apps/quiz/migrations/0004_alter_question_options.py
deleted file mode 100644
index 9aa00e0..0000000
--- a/apps/quiz/migrations/0004_alter_question_options.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# Generated by Django 5.2.12 on 2026-06-01 15:39
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('quiz', '0003_quiz_course'),
- ]
-
- operations = [
- migrations.AlterModelOptions(
- name='question',
- options={'ordering': ('priority', 'id'), 'verbose_name': 'Question', 'verbose_name_plural': 'Questions'},
- ),
- ]
diff --git a/apps/quiz/migrations/0005_multilingual_quiz_fields.py b/apps/quiz/migrations/0005_multilingual_quiz_fields.py
deleted file mode 100644
index 449ba27..0000000
--- a/apps/quiz/migrations/0005_multilingual_quiz_fields.py
+++ /dev/null
@@ -1,137 +0,0 @@
-# Generated by Codex on 2026-06-11
-
-from django.db import migrations, models
-
-
-def _is_multilingual_list(value):
- return isinstance(value, list) and all(
- isinstance(item, dict) and item.get("language_code")
- for item in value
- )
-
-
-def _normalize_multilingual(value):
- if value in (None, "", []):
- return []
-
- if _is_multilingual_list(value):
- normalized_items = []
- for item in value:
- title = item.get("title") or item.get("text") or item.get("value") or item.get("name")
- language_code = item.get("language_code") or "en"
- if title:
- normalized_items.append({"title": title, "language_code": language_code})
- return normalized_items
-
- if isinstance(value, dict):
- language_map_keys = {"fa", "en", "ru", "ar", "tr"}
- if any(key in value for key in language_map_keys):
- return [
- {"title": title, "language_code": language_code}
- for language_code, title in value.items()
- if title
- ]
-
- title = value.get("title") or value.get("text") or value.get("value") or value.get("name")
- language_code = value.get("language_code") or "en"
- return [{"title": title, "language_code": language_code}] if title else []
-
- if isinstance(value, list):
- normalized_items = []
- for item in value:
- if isinstance(item, dict):
- title = item.get("title") or item.get("text") or item.get("value") or item.get("name")
- language_code = item.get("language_code") or "en"
- if title:
- normalized_items.append({"title": title, "language_code": language_code})
- elif item not in (None, ""):
- normalized_items.append({"title": str(item), "language_code": "en"})
- return normalized_items
-
- return [{"title": str(value), "language_code": "en"}]
-
-
-def forwards(apps, schema_editor):
- Quiz = apps.get_model("quiz", "Quiz")
- Question = apps.get_model("quiz", "Question")
-
- for quiz in Quiz.objects.all().iterator():
- quiz.title_i18n = _normalize_multilingual(quiz.title)
- quiz.description_i18n = _normalize_multilingual(quiz.description)
- quiz.save(update_fields=["title_i18n", "description_i18n"])
-
- for question in Question.objects.all().iterator():
- question.question_i18n = _normalize_multilingual(question.question)
- question.option1_i18n = _normalize_multilingual(question.option1)
- question.option2_i18n = _normalize_multilingual(question.option2)
- question.option3_i18n = _normalize_multilingual(question.option3)
- question.option4_i18n = _normalize_multilingual(question.option4)
- question.save(
- update_fields=[
- "question_i18n",
- "option1_i18n",
- "option2_i18n",
- "option3_i18n",
- "option4_i18n",
- ]
- )
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("quiz", "0004_alter_question_options"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="quiz",
- name="title_i18n",
- field=models.JSONField(default=list, help_text="Quiz Title", verbose_name="Title"),
- ),
- migrations.AddField(
- model_name="quiz",
- name="description_i18n",
- field=models.JSONField(blank=True, default=list, verbose_name="Description"),
- ),
- migrations.AddField(
- model_name="question",
- name="question_i18n",
- field=models.JSONField(default=list, verbose_name="Question"),
- ),
- migrations.AddField(
- model_name="question",
- name="option1_i18n",
- field=models.JSONField(default=list, verbose_name="Option 1"),
- ),
- migrations.AddField(
- model_name="question",
- name="option2_i18n",
- field=models.JSONField(default=list, verbose_name="Option 2"),
- ),
- migrations.AddField(
- model_name="question",
- name="option3_i18n",
- field=models.JSONField(default=list, verbose_name="Option 3"),
- ),
- migrations.AddField(
- model_name="question",
- name="option4_i18n",
- field=models.JSONField(default=list, verbose_name="Option 4"),
- ),
- migrations.RunPython(forwards, migrations.RunPython.noop),
- migrations.RemoveField(model_name="quiz", name="title"),
- migrations.RemoveField(model_name="quiz", name="description"),
- migrations.RemoveField(model_name="question", name="question"),
- migrations.RemoveField(model_name="question", name="option1"),
- migrations.RemoveField(model_name="question", name="option2"),
- migrations.RemoveField(model_name="question", name="option3"),
- migrations.RemoveField(model_name="question", name="option4"),
- migrations.RenameField(model_name="quiz", old_name="title_i18n", new_name="title"),
- migrations.RenameField(model_name="quiz", old_name="description_i18n", new_name="description"),
- migrations.RenameField(model_name="question", old_name="question_i18n", new_name="question"),
- migrations.RenameField(model_name="question", old_name="option1_i18n", new_name="option1"),
- migrations.RenameField(model_name="question", old_name="option2_i18n", new_name="option2"),
- migrations.RenameField(model_name="question", old_name="option3_i18n", new_name="option3"),
- migrations.RenameField(model_name="question", old_name="option4_i18n", new_name="option4"),
- ]
diff --git a/apps/quiz/migrations/0006_quiz_lesson_number_alter_quiz_lesson.py b/apps/quiz/migrations/0006_quiz_lesson_number_alter_quiz_lesson.py
deleted file mode 100644
index 6c5e13b..0000000
--- a/apps/quiz/migrations/0006_quiz_lesson_number_alter_quiz_lesson.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# Generated by Django 5.2.12 on 2026-06-16 16:32
-
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0015_rename_course_cour_ended_a_0f47b4_idx_course_cour_ended_a_32eaaa_idx_and_more'),
- ('quiz', '0005_multilingual_quiz_fields'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='quiz',
- name='lesson_number',
- field=models.PositiveIntegerField(default=0, verbose_name='Lesson Number'),
- ),
- migrations.AlterField(
- model_name='quiz',
- name='lesson',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='quizzes', to='course.courselesson', verbose_name='Lesson'),
- ),
- ]
diff --git a/apps/quiz/migrations/__init__.py b/apps/quiz/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/quiz/models/__init__.py b/apps/quiz/models/__init__.py
deleted file mode 100644
index 16a10f1..0000000
--- a/apps/quiz/models/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .quiz import *
-from .participant import *
\ No newline at end of file
diff --git a/apps/quiz/models/participant.py b/apps/quiz/models/participant.py
deleted file mode 100644
index f2a46da..0000000
--- a/apps/quiz/models/participant.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from django.db import models
-from django.db.models import F, Window
-from django.db.models.functions import Rank
-from django.utils.translation import gettext_lazy as _
-
-from apps.account.models import User
-
-
-class QuizParticipant(models.Model):
- quiz = models.ForeignKey('quiz.Quiz', on_delete=models.CASCADE, related_name='participants', verbose_name=_('Quiz'))
- user = models.ForeignKey('account.User', on_delete=models.CASCADE, verbose_name=_('User'), related_name='uquizzes')
- started_at = models.DateTimeField(verbose_name=_('Started At'))
- ended_at = models.DateTimeField(verbose_name=_('Ended At'))
- total_timing = models.PositiveIntegerField(verbose_name=_('Total Timing'), help_text=_('Seconds take to finish the quiz'))
-
- question_score = models.PositiveIntegerField(verbose_name=_('Question Score'))
- timing_score = models.PositiveIntegerField(verbose_name=_('Timing Score'))
- total_score = models.PositiveIntegerField(verbose_name=_('Total Score'))
-
- class Meta:
- verbose_name = _("Participant")
- verbose_name_plural = _("Participants")
- ordering = ("-id",)
-
- def __str__(self):
- return f"Participant: {self.id}, ParticipantName: {self.user}, Quiz: {self.quiz.id}"
-
- def __repr__(self):
- return f"Participant(id={self.id})"
-
-
- @staticmethod
- def get_user_ranks(quiz_id):
- return QuizParticipant.objects.filter(quiz_id=quiz_id).annotate(
- rank=Window(
- expression=Rank(),
- order_by=F('total_score').desc()
- )
- )
-
-
-class ParticipantAnswer(models.Model):
- CHOICES = [
- (1, _('Option 1')),
- (2, _('Option 2')),
- (3, _('Option 3')),
- (4, _('Option 4')),
- ]
-
- participant = models.ForeignKey(QuizParticipant, on_delete=models.CASCADE, related_name='answers', verbose_name=_('Participant'))
- question = models.ForeignKey("quiz.Question", on_delete=models.CASCADE, verbose_name=_('Question'))
- option_num = models.PositiveSmallIntegerField(choices=CHOICES, verbose_name=_('Selected Option'))
- at_time = models.DateTimeField(verbose_name=_('At Time'))
- answer_timing = models.PositiveSmallIntegerField(default=0, verbose_name=_('Seconds Take to Answer'))
-
-
- class Meta:
- verbose_name = _("User Quiz Answer")
- verbose_name_plural = _("User Quiz Answers")
- ordering = ("-id",)
-
- def __str__(self):
- return f"Participant Answer: {self.id}"
-
- def __repr__(self):
- return f"ParticipantAnswer(id={self.id})"
-
-
diff --git a/apps/quiz/models/quiz.py b/apps/quiz/models/quiz.py
deleted file mode 100644
index 5cc1587..0000000
--- a/apps/quiz/models/quiz.py
+++ /dev/null
@@ -1,102 +0,0 @@
-from django.db import models
-from django.utils.translation import gettext_lazy as _
-from apps.account.models import User
-from apps.course.models.course import extract_text_from_json, format_multilingual_field
-
-
-
-class Quiz(models.Model):
- lesson = models.ForeignKey("course.CourseLesson", verbose_name=_('Lesson'), related_name='quizzes', on_delete=models.SET_NULL, null=True, blank=True)
- course = models.ForeignKey("course.Course", verbose_name=_('Course'), related_name='quizzes', on_delete=models.CASCADE , null=True, blank=True)
- lesson_number = models.PositiveIntegerField(default=0, verbose_name=_("Lesson Number"))
-
- title = models.JSONField(default=list, verbose_name=_('Title'), help_text=_("Quiz Title"))
- description = models.JSONField(default=list, blank=True, verbose_name=_("Description"))
- each_question_timing = models.PositiveIntegerField(verbose_name=_("Each Question Timing"))
- status = models.BooleanField(default=True, verbose_name=_("Status"))
-
-
- class Meta:
- verbose_name = _("Quiz")
- verbose_name_plural = _("Quizzes")
- ordering = ("-id",)
-
- def __str__(self):
- return f"Quiz: {self.id}"
-
- def __repr__(self):
- return f"Quiz(id={self.id})"
-
- @property
- def lesson_computed(self):
- if self.lesson_number and self.lesson_number > 0 and self.course_id:
- from apps.course.models import CourseLesson
- from django.db.models import Q
- lessons = list(CourseLesson.objects.filter(
- course_id=self.course_id,
- is_active=True
- ).filter(
- Q(chapter__isnull=True) | Q(chapter__is_active=True)
- ).order_by('chapter__priority', 'priority'))
- if len(lessons) >= self.lesson_number:
- return lessons[self.lesson_number - 1]
- return self.lesson
-
- def save(self, *args, **kwargs):
- # Automatically set the course based on the selected lesson
- if self.lesson_id and hasattr(self.lesson, 'course'):
- self.course = self.lesson.course
- self.title = format_multilingual_field(self.title)
- self.description = format_multilingual_field(self.description)
- super().save(*args, **kwargs)
-
-
-class Question(models.Model):
- CHOICES = [
- (1, _('Option 1')),
- (2, _('Option 2')),
- (3, _('Option 3')),
- (4, _('Option 4')),
- ]
-
- quiz = models.ForeignKey(Quiz, verbose_name=_('Quiz'), on_delete=models.CASCADE, related_name='questions')
- question = models.JSONField(default=list, verbose_name=_('Question'))
- option1 = models.JSONField(default=list, verbose_name=_('Option 1'))
- option2 = models.JSONField(default=list, verbose_name=_('Option 2'))
- option3 = models.JSONField(default=list, verbose_name=_('Option 3'))
- option4 = models.JSONField(default=list, verbose_name=_('Option 4'))
- correct_answer = models.PositiveSmallIntegerField(choices=CHOICES, verbose_name=_('Correct Answer'))
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At'))
- priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority'))
-
-
- class Meta:
- verbose_name = _("Question")
- verbose_name_plural = _("Questions")
- ordering = ("priority", "id",)
-
- def __str__(self):
- return extract_text_from_json(self.question)
-
- def __repr__(self):
- return f"Question(id={self.id})"
-
- def save(self, *args, **kwargs):
- if self.priority is None:
- max_priority = Question.objects.filter(quiz=self.quiz).aggregate(models.Max('priority'))['priority__max']
- self.priority = (max_priority or 0) + 1
- self.question = format_multilingual_field(self.question)
- self.option1 = format_multilingual_field(self.option1)
- self.option2 = format_multilingual_field(self.option2)
- self.option3 = format_multilingual_field(self.option3)
- self.option4 = format_multilingual_field(self.option4)
- super().save(*args, **kwargs)
-
-
-class QuizRankUser(User):
- class Meta:
- proxy = True
- verbose_name = _('Rank Quiz')
- verbose_name_plural = _('Rank Quizzes')
-
-
diff --git a/apps/quiz/serializers/__init__.py b/apps/quiz/serializers/__init__.py
deleted file mode 100644
index 16a10f1..0000000
--- a/apps/quiz/serializers/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .quiz import *
-from .participant import *
\ No newline at end of file
diff --git a/apps/quiz/serializers/admin.py b/apps/quiz/serializers/admin.py
deleted file mode 100644
index 4962329..0000000
--- a/apps/quiz/serializers/admin.py
+++ /dev/null
@@ -1,224 +0,0 @@
-from rest_framework import serializers
-from apps.quiz.models import Quiz, Question, QuizParticipant, ParticipantAnswer
-from apps.course.models import Course, CourseLesson
-from apps.account.models import User
-from apps.course.models.course import get_localized_field
-
-def get_request_lang(serializer_instance):
- request = serializer_instance.context.get('request')
- return getattr(request, 'LANGUAGE_CODE', None) or 'en'
-
-
-class AdminCourseSerializer(serializers.ModelSerializer):
- class Meta:
- model = Course
- fields = ['id', 'title']
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- lang = get_request_lang(self)
- ret['title'] = get_localized_field(lang, instance.title)
- return ret
-
-
-class AdminCourseLessonSerializer(serializers.ModelSerializer):
- title = serializers.SerializerMethodField()
-
- class Meta:
- model = CourseLesson
- fields = ['id', 'title']
- ref_name = 'QuizAdminCourseLesson'
-
- def get_title(self, obj):
- lang = get_request_lang(self)
- raw_title = obj.title or (obj.lesson.title if obj.lesson else [])
- return get_localized_field(lang, raw_title)
-
-
-class AdminQuizListSerializer(serializers.ModelSerializer):
- questions_count = serializers.SerializerMethodField()
- participants_count = serializers.SerializerMethodField()
- course_title = serializers.SerializerMethodField()
- lesson_title = serializers.SerializerMethodField()
- title_i18n = serializers.JSONField(source='title', read_only=True)
- description_i18n = serializers.JSONField(source='description', read_only=True)
-
- class Meta:
- model = Quiz
- fields = [
- 'id',
- 'title',
- 'title_i18n',
- 'description',
- 'description_i18n',
- 'each_question_timing',
- 'status',
- 'course',
- 'course_title',
- 'lesson',
- 'lesson_number',
- 'lesson_title',
- 'questions_count',
- 'participants_count'
- ]
-
- def get_questions_count(self, obj):
- return obj.questions.count()
-
- def get_participants_count(self, obj):
- return obj.participants.count()
-
- def get_course_title(self, obj):
- if not obj.course:
- return None
- lang = get_request_lang(self)
- return get_localized_field(lang, obj.course.title)
-
- def get_lesson_title(self, obj):
- lesson = obj.lesson_computed
- if not lesson:
- return None
- lang = get_request_lang(self)
- raw_title = lesson.title or (lesson.lesson.title if lesson.lesson else None)
- return get_localized_field(lang, raw_title)
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- lang = get_request_lang(self)
- ret['title'] = get_localized_field(lang, instance.title)
- ret['description'] = get_localized_field(lang, instance.description)
- computed = instance.lesson_computed
- ret['lesson'] = computed.id if computed else None
- return ret
-
-
-class AdminQuizDetailSerializer(serializers.ModelSerializer):
- questions_count = serializers.SerializerMethodField()
- participants_count = serializers.SerializerMethodField()
- course_title = serializers.SerializerMethodField()
- lesson_title = serializers.SerializerMethodField()
- title_i18n = serializers.JSONField(source='title', read_only=True)
- description_i18n = serializers.JSONField(source='description', read_only=True)
-
- class Meta:
- model = Quiz
- fields = [
- 'id',
- 'title',
- 'title_i18n',
- 'description',
- 'description_i18n',
- 'each_question_timing',
- 'status',
- 'course',
- 'course_title',
- 'lesson',
- 'lesson_number',
- 'lesson_title',
- 'questions_count',
- 'participants_count'
- ]
- read_only_fields = ['course_title', 'lesson_title', 'questions_count', 'participants_count']
-
- def get_questions_count(self, obj):
- return obj.questions.count()
-
- def get_participants_count(self, obj):
- return obj.participants.count()
-
- def get_course_title(self, obj):
- if not obj.course:
- return None
- lang = get_request_lang(self)
- return get_localized_field(lang, obj.course.title)
-
- def get_lesson_title(self, obj):
- lesson = obj.lesson_computed
- if not lesson:
- return None
- lang = get_request_lang(self)
- raw_title = lesson.title or (lesson.lesson.title if lesson.lesson else None)
- return get_localized_field(lang, raw_title)
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- lang = get_request_lang(self)
- ret['title'] = get_localized_field(lang, instance.title)
- ret['description'] = get_localized_field(lang, instance.description)
- computed = instance.lesson_computed
- ret['lesson'] = computed.id if computed else None
- return ret
-
-
-class AdminQuestionSerializer(serializers.ModelSerializer):
- question_i18n = serializers.JSONField(source='question', read_only=True)
- option1_i18n = serializers.JSONField(source='option1', read_only=True)
- option2_i18n = serializers.JSONField(source='option2', read_only=True)
- option3_i18n = serializers.JSONField(source='option3', read_only=True)
- option4_i18n = serializers.JSONField(source='option4', read_only=True)
-
- class Meta:
- model = Question
- fields = [
- 'id',
- 'quiz',
- 'question',
- 'question_i18n',
- 'option1',
- 'option1_i18n',
- 'option2',
- 'option2_i18n',
- 'option3',
- 'option3_i18n',
- 'option4',
- 'option4_i18n',
- 'correct_answer',
- 'priority'
- ]
-
- def _get_localized_value(self, value):
- lang = get_request_lang(self)
- return get_localized_field(lang, value)
-
- def to_representation(self, instance):
- ret = super().to_representation(instance)
- ret['question'] = self._get_localized_value(instance.question)
- ret['option1'] = self._get_localized_value(instance.option1)
- ret['option2'] = self._get_localized_value(instance.option2)
- ret['option3'] = self._get_localized_value(instance.option3)
- ret['option4'] = self._get_localized_value(instance.option4)
- return ret
-
-
-class AdminParticipantAnswerSerializer(serializers.ModelSerializer):
- class Meta:
- model = ParticipantAnswer
- fields = ['id', 'question', 'option_num', 'at_time', 'answer_timing']
-
-
-class AdminQuizParticipantSerializer(serializers.ModelSerializer):
- user_name = serializers.CharField(source='user.fullname', read_only=True)
- user_email = serializers.EmailField(source='user.email', read_only=True)
- answers = AdminParticipantAnswerSerializer(many=True, read_only=True)
-
- class Meta:
- model = QuizParticipant
- fields = [
- 'id',
- 'user_id',
- 'user_name',
- 'user_email',
- 'started_at',
- 'ended_at',
- 'total_timing',
- 'question_score',
- 'timing_score',
- 'total_score',
- 'answers'
- ]
-
-
-class AdminQuizAbsenteeSerializer(serializers.ModelSerializer):
- class Meta:
- model = User
- fields = ['id', 'fullname', 'email']
diff --git a/apps/quiz/serializers/participant.py b/apps/quiz/serializers/participant.py
deleted file mode 100644
index 4635a12..0000000
--- a/apps/quiz/serializers/participant.py
+++ /dev/null
@@ -1,69 +0,0 @@
-from rest_framework import serializers
-
-from apps.course.access import user_has_course_access
-from apps.quiz.models import QuizParticipant, ParticipantAnswer
-
-
-class ParticipantAnswerSerializer(serializers.ModelSerializer):
- class Meta:
- model = ParticipantAnswer
- fields = ['question', 'option_num', 'at_time', 'answer_timing']
-
-
-
-class QuizParticipantSerializer(serializers.ModelSerializer):
- user = serializers.HiddenField(default=serializers.CurrentUserDefault())
- answers = ParticipantAnswerSerializer(many=True)
-
- def validate_quiz(self, obj):
- if QuizParticipant.objects.filter(quiz=obj, user=self.context['request'].user).exists():
- raise serializers.ValidationError('you have already participated in the quiz')
-
- course = getattr(obj, 'course', None)
- if course and not user_has_course_access(self.context['request'].user, course):
- raise serializers.ValidationError('you do not have access to this quiz')
-
- return obj
-
- class Meta:
- model = QuizParticipant
- fields = [
- 'quiz', 'user', 'started_at', 'ended_at', 'total_timing',
- 'question_score', 'timing_score', 'total_score',
- 'answers',
- ]
-
- def create(self, validated_data):
- answers = validated_data.pop('answers', [])
-
- quiz = validated_data.get('quiz')
- total_questions = quiz.questions.count()
- correct_count = 0
- questions_map = {q.id: q.correct_answer for q in quiz.questions.all()}
-
- for ans in answers:
- # Depending on if ans['question'] is an instance or ID
- q_id = ans.get('question').id if hasattr(ans.get('question'), 'id') else ans.get('question')
- option_num = ans.get('option_num')
- if q_id in questions_map and questions_map[q_id] == option_num:
- correct_count += 1
-
- question_score = round((correct_count / total_questions) * 100) if total_questions > 0 else 0
-
- validated_data['question_score'] = question_score
- validated_data['timing_score'] = 0
- validated_data['total_score'] = question_score
-
- obj = super().create(validated_data)
- answers_objs = []
- for ans in answers:
- answers_objs.append(
- ParticipantAnswer(
- participant=obj,
- **ans,
- )
- )
-
- ParticipantAnswer.objects.bulk_create(answers_objs)
-
- return obj
diff --git a/apps/quiz/serializers/quiz.py b/apps/quiz/serializers/quiz.py
deleted file mode 100644
index 48cf1ad..0000000
--- a/apps/quiz/serializers/quiz.py
+++ /dev/null
@@ -1,130 +0,0 @@
-from rest_framework import serializers
-
-from apps.course.access import user_has_course_access
-from apps.course.models.course import get_localized_field
-from apps.quiz.models import Question, Quiz, QuizParticipant
-
-
-
-
-
-
-
-class QuizListSerializer(serializers.ModelSerializer):
- is_complated = serializers.SerializerMethodField()
- permission = serializers.SerializerMethodField()
- title = serializers.SerializerMethodField()
- description = serializers.SerializerMethodField()
-
- class Meta:
- model = Quiz
- fields = ['id', 'title', 'description', 'permission', 'each_question_timing', 'is_complated']
-
- def get_title(self, obj):
- request = self.context.get('request')
- lang = getattr(request, 'LANGUAGE_CODE', None) or 'en'
- return get_localized_field(lang, obj.title)
-
- def get_description(self, obj):
- request = self.context.get('request')
- lang = getattr(request, 'LANGUAGE_CODE', None) or 'en'
- return get_localized_field(lang, obj.description)
-
- def get_permission(self, obj):
- request = self.context.get('request')
- if not request or not request.user.is_authenticated:
- return False
- user = request.user
-
- course = obj.course
- if not course:
- course_lesson = obj.lesson_computed
- if course_lesson:
- course = course_lesson.course
-
- if not course:
- return False
-
- if not user_has_course_access(user, course):
- return False
-
- participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists()
- return not participated
-
- def get_is_complated(self, obj):
- request = self.context.get('request')
- if not request or not request.user.is_authenticated:
- return False
- user = request.user
- return QuizParticipant.objects.filter(user=user, quiz=obj).exists()
-
-
-
-
-
-class QuestionSerializer(serializers.ModelSerializer):
- options = serializers.SerializerMethodField()
- question = serializers.SerializerMethodField()
-
- def get_question(self, obj):
- request = self.context.get('request')
- lang = getattr(request, 'LANGUAGE_CODE', None) or 'en'
- return get_localized_field(lang, obj.question)
-
- def get_options(self, obj) -> list:
- request = self.context.get('request')
- lang = getattr(request, 'LANGUAGE_CODE', None) or 'en'
- return [
- {
- 'id': i,
- 'title': get_localized_field(lang, getattr(obj, f"option{i}"))
- } for i in range(1, 5)
- ]
-
-
- class Meta:
- model = Question
- fields = ['id', 'question', 'options', 'correct_answer']
-
-
-class QuizSerializer(serializers.ModelSerializer):
- lesson = serializers.SerializerMethodField()
- questions = QuestionSerializer(many=True)
- permission = serializers.SerializerMethodField()
- title = serializers.SerializerMethodField()
- description = serializers.SerializerMethodField()
-
- class Meta:
- model = Quiz
- fields = ['id', 'permission', 'lesson', 'lesson_number', 'title', 'description', 'each_question_timing', 'questions']
-
- def get_title(self, obj):
- request = self.context.get('request')
- lang = getattr(request, 'LANGUAGE_CODE', None) or 'en'
- return get_localized_field(lang, obj.title)
-
- def get_description(self, obj):
- request = self.context.get('request')
- lang = getattr(request, 'LANGUAGE_CODE', None) or 'en'
- return get_localized_field(lang, obj.description)
-
- def get_lesson(self, obj):
- computed = obj.lesson_computed
- return computed.id if computed else None
-
- def get_permission(self, obj):
- request = self.context.get('request')
- if not request or not request.user.is_authenticated:
- return False
- user = request.user
-
- course = obj.course
- if not course:
- course_lesson = obj.lesson_computed
- if course_lesson:
- course = course_lesson.course
-
- if not course:
- return False
-
- return user_has_course_access(user, course)
diff --git a/apps/quiz/tests.py b/apps/quiz/tests.py
deleted file mode 100644
index 7ce503c..0000000
--- a/apps/quiz/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/apps/quiz/urls.py b/apps/quiz/urls.py
deleted file mode 100644
index 1705382..0000000
--- a/apps/quiz/urls.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from django.urls import path, include
-from rest_framework.routers import DefaultRouter, SimpleRouter
-
-from . import views
-
-router = SimpleRouter()
-router.register(r'admin/quizzes', views.AdminQuizViewSet, basename='admin-quizzes')
-router.register(r'admin/questions', views.AdminQuestionViewSet, basename='admin-questions')
-
-# Hide admin viewsets from swagger
-for prefix, viewset, basename in router.registry:
- viewset.swagger_schema = None
-views.AdminCourseListView.swagger_schema = None
-views.AdminLessonListView.swagger_schema = None
-
-urlpatterns = [
- path('', include(router.urls)),
- path('admin/courses/', views.AdminCourseListView.as_view(), name='admin-courses-list'),
- path('admin/lessons/', views.AdminLessonListView.as_view(), name='admin-lessons-list'),
-
- # path('prizes/', PrizeListAPIView.as_view()),
- # path('ranked-list/', RankedListAPIView.as_view()),
- # path('self-rank/', SelfRankAPIView.as_view()),
- # path('my-quizzes/', UserQuizScores.as_view()),
- path('submit-quiz/', views.QuizParticipantCreateAPIView.as_view()),
- path('/', views.QuizDetailAPIView.as_view()),
-]
diff --git a/apps/quiz/views.py b/apps/quiz/views.py
deleted file mode 100644
index 91ea44a..0000000
--- a/apps/quiz/views.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.shortcuts import render
-
-# Create your views here.
diff --git a/apps/quiz/views/__init__.py b/apps/quiz/views/__init__.py
deleted file mode 100644
index 46d88e7..0000000
--- a/apps/quiz/views/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from .quiz import *
-from .participant import *
-from .admin import *
\ No newline at end of file
diff --git a/apps/quiz/views/admin.py b/apps/quiz/views/admin.py
deleted file mode 100644
index fef819a..0000000
--- a/apps/quiz/views/admin.py
+++ /dev/null
@@ -1,298 +0,0 @@
-from django.db.models import Q
-from rest_framework.viewsets import ModelViewSet
-from rest_framework.generics import ListAPIView
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.authentication import TokenAuthentication
-from rest_framework.decorators import action
-from rest_framework.response import Response
-from django.shortcuts import get_object_or_404
-from utils.pagination import StandardResultsSetPagination
-
-from apps.quiz.models import Quiz, Question, QuizParticipant
-from apps.course.models import Course, CourseLesson, Participant
-from apps.course.models.course import extract_text_from_json
-from apps.account.models import User
-from apps.account.permissions import IsPanelUser
-
-from apps.quiz.serializers.admin import (
- AdminCourseSerializer,
- AdminCourseLessonSerializer,
- AdminQuizListSerializer,
- AdminQuizDetailSerializer,
- AdminQuestionSerializer,
- AdminQuizParticipantSerializer,
- AdminQuizAbsenteeSerializer
-)
-
-
-def is_professor(request):
- return getattr(request.user, 'user_type', None) == 'professor'
-
-
-def find_matching_course_ids(search_term: str):
- normalized = (search_term or "").strip().lower()
- if not normalized:
- return []
-
- matching_ids = list(
- Course.objects.filter(
- Q(title__icontains=search_term) | Q(slug__icontains=search_term)
- ).values_list("id", flat=True)
- )
-
- if matching_ids:
- return matching_ids
-
- for course in Course.objects.all().only("id", "title", "slug"):
- title_text = extract_text_from_json(course.title).lower()
- slug_text = extract_text_from_json(course.slug).lower()
- if normalized in title_text or normalized in slug_text:
- matching_ids.append(course.id)
-
- return matching_ids
-
-
-def find_matching_lesson_ids(search_term: str):
- normalized = (search_term or "").strip().lower()
- if not normalized:
- return []
-
- matching_ids = []
- for lesson in CourseLesson.objects.select_related("lesson").all():
- lesson_title = extract_text_from_json(
- lesson.title or (lesson.lesson.title if lesson.lesson else [])
- ).lower()
- if normalized in lesson_title:
- matching_ids.append(lesson.id)
-
- return matching_ids
-
-
-def find_matching_quiz_ids(search_term: str):
- normalized = (search_term or "").strip().lower()
- if not normalized:
- return []
-
- matching_ids = []
- for quiz in Quiz.objects.all().only("id", "title", "description"):
- title_text = extract_text_from_json(quiz.title).lower()
- description_text = extract_text_from_json(quiz.description).lower()
- if normalized in title_text or normalized in description_text:
- matching_ids.append(quiz.id)
-
- return matching_ids
-
-
-class AdminQuizViewSet(ModelViewSet):
- """
- Admin CRUD ViewSet for Quiz management.
- Professors can only access quizzes of their own courses.
- """
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- pagination_class = StandardResultsSetPagination
-
- def get_serializer_class(self):
- if self.action == 'list':
- return AdminQuizListSerializer
- return AdminQuizDetailSerializer
-
- def get_queryset(self):
- queryset = Quiz.objects.all().select_related('course', 'lesson')
-
- # Professors can only see quizzes of their own courses
- if is_professor(self.request):
- queryset = queryset.filter(course__professors=self.request.user)
-
- # Handle Search
- search_query = self.request.query_params.get('search', None)
- has_search = False
- if search_query:
- has_search = True
- matching_course_ids = find_matching_course_ids(search_query)
- matching_lesson_ids = find_matching_lesson_ids(search_query)
- matching_quiz_ids = find_matching_quiz_ids(search_query)
- from django.db.models import Case, When, Value, IntegerField
- queryset = queryset.filter(
- Q(id__in=matching_quiz_ids) |
- Q(course_id__in=matching_course_ids) |
- Q(lesson_id__in=matching_lesson_ids)
- ).annotate(
- search_relevance=Case(
- When(id__in=matching_quiz_ids, then=Value(2)),
- default=Value(1),
- output_field=IntegerField()
- )
- )
-
- # Handle Course Filter
- course_id = self.request.query_params.get('course', None)
- if course_id:
- queryset = queryset.filter(course_id=course_id)
-
- # Handle Lesson Filter
- lesson_id = self.request.query_params.get('lesson', None)
- if lesson_id:
- try:
- lesson_obj = CourseLesson.objects.get(id=lesson_id)
- course = lesson_obj.course
- lessons = list(CourseLesson.objects.filter(
- course=course,
- is_active=True
- ).order_by('chapter__priority', 'priority'))
- try:
- pos = lessons.index(lesson_obj) + 1
- queryset = queryset.filter(
- Q(lesson_id=lesson_id) | (Q(course=course) & Q(lesson_number=pos))
- )
- except ValueError:
- queryset = queryset.filter(lesson_id=lesson_id)
- except CourseLesson.DoesNotExist:
- queryset = queryset.filter(lesson_id=lesson_id)
-
- # Handle Status Filter
- status_param = self.request.query_params.get('status', None)
- if status_param is not None:
- if status_param.lower() == 'true':
- queryset = queryset.filter(status=True)
- elif status_param.lower() == 'false':
- queryset = queryset.filter(status=False)
-
- if has_search:
- return queryset.order_by('-search_relevance', '-id')
- return queryset.order_by('-id')
-
- @action(detail=True, methods=['get'])
- def participants(self, request, pk=None):
- """
- Returns two lists:
- 1. Participants: users who have participated in the quiz.
- 2. Absentees: users who are enrolled in the course but have not participated.
- """
- quiz = self.get_object()
-
- # 1. Fetch participants
- participants_qs = QuizParticipant.objects.filter(quiz=quiz).select_related('user').prefetch_related('answers')
- participants_serializer = AdminQuizParticipantSerializer(participants_qs, many=True)
-
- # 2. Fetch absentees
- absentees_data = []
- if quiz.course:
- # Enrolled course student IDs
- enrolled_student_ids = Participant.objects.filter(
- course=quiz.course,
- is_active=True
- ).values_list('student_id', flat=True)
-
- # Participated user IDs
- participated_user_ids = participants_qs.values_list('user_id', flat=True)
-
- # Absentee IDs
- absentee_ids = set(enrolled_student_ids) - set(participated_user_ids)
-
- # Fetch User details for absentees
- absentees_qs = User.objects.filter(id__in=absentee_ids)
- absentees_serializer = AdminQuizAbsenteeSerializer(absentees_qs, many=True)
- absentees_data = absentees_serializer.data
-
- return Response({
- 'participants': participants_serializer.data,
- 'absentees': absentees_data
- })
-
-
-class AdminQuestionViewSet(ModelViewSet):
- """
- Admin CRUD ViewSet for Question management.
- Professors can only access questions of quizzes belonging to their own courses.
- """
- serializer_class = AdminQuestionSerializer
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- pagination_class = None
-
- def get_queryset(self):
- queryset = Question.objects.all()
-
- # Professors can only see questions of their own courses' quizzes
- if is_professor(self.request):
- queryset = queryset.filter(quiz__course__professors=self.request.user)
-
- quiz_id = self.request.query_params.get('quiz_id', None)
- if not quiz_id:
- # Fallback parameter name
- quiz_id = self.request.query_params.get('quiz', None)
-
- if quiz_id:
- queryset = queryset.filter(quiz_id=quiz_id)
-
- return queryset.order_by('priority', 'id')
-
- @action(detail=False, methods=['post'])
- def reorder(self, request):
- """
- Reorders questions in bulk.
- Accepts a list of question IDs.
- Format:
- {
- "question_ids": [10, 12, 11, 15]
- }
- """
- question_ids = request.data.get('question_ids', [])
- if not question_ids:
- return Response({'error': 'question_ids list is required'}, status=400)
-
- # Update priority for each question based on its position in the list
- from django.db import transaction
- questions = Question.objects.filter(id__in=question_ids)
- question_map = {q.id: q for q in questions}
-
- with transaction.atomic():
- for idx, q_id in enumerate(question_ids):
- if q_id in question_map:
- question = question_map[q_id]
- question.priority = idx + 1
- question.save(update_fields=['priority'])
-
- return Response({'status': 'success'})
-
-
-class AdminCourseListView(ListAPIView):
- """
- Dropdown listing helper for all courses.
- Professors only see their own courses.
- """
- serializer_class = AdminCourseSerializer
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- pagination_class = None # Return all courses for dropdown usage
-
- def get_queryset(self):
- queryset = Course.objects.all().order_by('-id')
- if is_professor(self.request):
- queryset = queryset.filter(professors=self.request.user)
- return queryset
-
-
-class AdminLessonListView(ListAPIView):
- """
- Dropdown listing helper for lessons. Filtered by course_id.
- Professors only see lessons of their own courses.
- """
- serializer_class = AdminCourseLessonSerializer
- permission_classes = [IsAuthenticated, IsPanelUser]
- authentication_classes = [TokenAuthentication]
- pagination_class = None # Return all matching lessons for dropdown usage
-
- def get_queryset(self):
- course_id = self.request.query_params.get('course_id', None)
- if course_id:
- qs = CourseLesson.objects.filter(course_id=course_id).select_related('lesson').order_by('priority')
- else:
- qs = CourseLesson.objects.all().select_related('lesson').order_by('-id')
-
- # Professors only see lessons of their own courses
- if is_professor(self.request):
- qs = qs.filter(course__professors=self.request.user)
-
- return qs
diff --git a/apps/quiz/views/participant.py b/apps/quiz/views/participant.py
deleted file mode 100644
index c4d89c4..0000000
--- a/apps/quiz/views/participant.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from django.db.models import Value
-from rest_framework.generics import RetrieveAPIView, CreateAPIView
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.authentication import TokenAuthentication
-
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
-
-from apps.quiz.serializers import QuizParticipantSerializer
-from apps.quiz.doc import *
-
-
-
-class QuizParticipantCreateAPIView(CreateAPIView):
- serializer_class = QuizParticipantSerializer
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
-
-
- @swagger_auto_schema(
- operation_description=doc_quiz_submit(),
- tags=["Imam-Javad - Quiz"],
- )
- def post(self, request, *args, **kwargs):
- return super().post(request, *args, **kwargs)
\ No newline at end of file
diff --git a/apps/quiz/views/quiz.py b/apps/quiz/views/quiz.py
deleted file mode 100644
index 6d5284e..0000000
--- a/apps/quiz/views/quiz.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from django.db.models import Value
-from django.shortcuts import get_object_or_404
-from rest_framework.generics import RetrieveAPIView
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.authentication import TokenAuthentication
-
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
-from apps.course.access import user_has_course_access
-from apps.quiz.models import Quiz
-from apps.quiz.serializers.quiz import QuizSerializer
-from apps.quiz.doc import *
-from utils.exceptions import AppAPIException
-
-
-
-class QuizDetailAPIView(RetrieveAPIView):
- serializer_class = QuizSerializer
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
-
-
- @swagger_auto_schema(
- operation_description=doc_quiz_detail(),
- tags=["Imam-Javad - Quiz"],
- )
- def get(self, request, *args, **kwargs):
- return super().get(request, *args, **kwargs)
-
- def get_object(self):
- quiz = get_object_or_404(
- Quiz.objects.filter(
- id=self.kwargs['quiz_id'],
- ).annotate(
- lesson__has_quiz=Value(True)
- ).select_related('lesson', 'course')
- )
-
- if quiz.course and not user_has_course_access(self.request.user, quiz.course):
- raise AppAPIException(
- {'message': 'You do not have access to this quiz.'},
- status_code=403,
- )
-
- return quiz
diff --git a/apps/transaction/__init__.py b/apps/transaction/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/transaction/admin.py b/apps/transaction/admin.py
deleted file mode 100644
index 80107fd..0000000
--- a/apps/transaction/admin.py
+++ /dev/null
@@ -1,165 +0,0 @@
-from django.contrib import admin
-from django.utils.translation import gettext_lazy as _
-from django.utils.html import format_html
-from django.contrib import messages
-
-from unfold.admin import ModelAdmin, StackedInline, TabularInline
-from unfold.decorators import display
-
-from apps.transaction.models import TransactionParticipant, ParticipantInfo, TransactionReceipt
-from apps.course.models import Participant
-
-from utils.admin import project_admin_site
-
-class ParticipantInfoInline(StackedInline):
- model = ParticipantInfo
- extra = 1
- fields = ['fullname', 'email', 'phone_number', 'gender', 'birthdate']
- # readonly_fields = ['email', 'phone_number']
- classes = ['collapse']
- tab = True
- show_change_link = True
-
-
-class TransactionReceiptInline(TabularInline):
- model = TransactionReceipt
- extra = 0
- fields = ['file', 'description', 'uploaded_at']
- readonly_fields = ['uploaded_at']
- classes = ['collapse']
- tab = True
- show_change_link = True
- verbose_name = _('Payment Receipt')
- verbose_name_plural = _('Payment Receipts')
-
-
-@admin.register(TransactionParticipant)
-class TransactionParticipantAdmin(ModelAdmin):
- list_display = ('user', 'course', 'payment_status', 'price_display', 'participant_status', 'receipts_count', 'created_at', 'updated_at')
- list_filter = ('status', 'course', 'created_at')
- search_fields = ('user__email', 'course__title')
- readonly_fields = ['created_at', 'updated_at']
- inlines = [ParticipantInfoInline, TransactionReceiptInline]
- autocomplete_fields = ['user',]
- show_change_link = True
- ordering = ('-created_at',)
-
- fieldsets = (
- (None, {
- 'fields': ('user', 'course', 'status', 'price')
- }),
- (_('Timestamps'), {
- 'fields': ('created_at', 'updated_at'),
- 'classes': ('collapse',)
- }),
- )
-
- @display(description=_("Payment Status"), ordering="status")
- def payment_status(self, obj):
- if obj.status == 'success':
- return format_html('{}', _("Paid"))
- elif obj.status == 'failed':
- return format_html('{}', _("Failed"))
- elif obj.status == 'waiting_approval':
- return format_html('{}', _("Waiting Approval"))
- return format_html('{}', _("Pending"))
-
- @display(description=_("Receipts Count"))
- def receipts_count(self, obj):
- """Display count of uploaded receipts"""
- count = obj.receipts.count()
- if count > 0:
- return format_html('{} {}', count, _("receipts"))
- return format_html('{}', _("No receipts"))
-
- @display(description=_("Price"), ordering="price")
- def price_display(self, obj):
- return format_html('${}', obj.price)
-
- @display(description=_("Course Participant Status"))
- def participant_status(self, obj):
- """نمایش وضعیت شرکتکننده در دوره"""
- if obj.status == TransactionParticipant.TransactionStatus.SUCCESS:
- participant_exists = Participant.objects.filter(
- student=obj.user,
- course=obj.course
- ).exists()
- if participant_exists:
- return format_html('✓ {}', _("Enrolled"))
- else:
- return format_html('⚠ {}', _("Not Enrolled"))
- else:
- return format_html('- {}', _("Not Applicable"))
-
- def save_model(self, request, obj, form, change):
- """Override save_model to show messages when participant is created"""
- if change:
- # Store the old status before saving
- old_obj = TransactionParticipant.objects.get(pk=obj.pk)
- old_status = old_obj.status
-
- # Save the object
- super().save_model(request, obj, form, change)
-
- # Check if status changed to SUCCESS
- if (old_status != TransactionParticipant.TransactionStatus.SUCCESS and
- obj.status == TransactionParticipant.TransactionStatus.SUCCESS):
-
- participant_exists = Participant.objects.filter(
- student=obj.user,
- course=obj.course
- ).exists()
-
- if participant_exists:
- messages.success(
- request,
- _("Transaction status updated to SUCCESS. User {user_email} is now enrolled in course '{course_title}'.").format(
- user_email=obj.user.email,
- course_title=obj.course.title
- )
- )
- else:
- messages.warning(
- request,
- _("Transaction status updated to SUCCESS, but there was an issue enrolling user {user_email} in course '{course_title}'. Please check the logs.").format(
- user_email=obj.user.email,
- course_title=obj.course.title
- )
- )
- else:
- super().save_model(request, obj, form, change)
-
- def get_queryset(self, request):
- # Filter out deleted transactions
- return super().get_queryset(request).filter(is_deleted=False)
-
-project_admin_site.register(TransactionParticipant, TransactionParticipantAdmin)
-
-
-@admin.register(TransactionReceipt)
-class TransactionReceiptAdmin(ModelAdmin):
- list_display = ('transaction', 'file', 'uploaded_at', 'description_preview')
- list_filter = ('uploaded_at', 'transaction__status')
- search_fields = ('transaction__user__email', 'transaction__course__title', 'description')
- readonly_fields = ['uploaded_at']
- autocomplete_fields = ['transaction']
- ordering = ('-uploaded_at',)
-
- fieldsets = (
- (None, {
- 'fields': ('transaction', 'file', 'description')
- }),
- (_('Timestamps'), {
- 'fields': ('uploaded_at',),
- 'classes': ('collapse',)
- }),
- )
-
- @display(description=_("Description"))
- def description_preview(self, obj):
- """Display truncated description"""
- if obj.description:
- return obj.description[:50] + '...' if len(obj.description) > 50 else obj.description
- return '-'
-
-project_admin_site.register(TransactionReceipt, TransactionReceiptAdmin)
diff --git a/apps/transaction/apps.py b/apps/transaction/apps.py
deleted file mode 100644
index d10da14..0000000
--- a/apps/transaction/apps.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from django.apps import AppConfig
-
-
-class TransactionConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'apps.transaction'
-
- def ready(self):
- import apps.transaction.signals
diff --git a/apps/transaction/doc.py b/apps/transaction/doc.py
deleted file mode 100644
index 1eacff9..0000000
--- a/apps/transaction/doc.py
+++ /dev/null
@@ -1,711 +0,0 @@
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
-from rest_framework import status
-
-def doc_upload_transaction_receipts():
- return """
-# 🐈 Scenario
-🛠️ آپلود رسید پرداخت برای تراکنش
-
-این API برای آپلود یک یا چند رسید پرداخت برای یک تراکنش استفاده میشود.
-پس از آپلود موفقیتآمیز، وضعیت تراکنش به 'waiting_approval' (در انتظار تایید) تغییر میکند.
-
----
-
-## 🚀 روند آپلود (دو مرحلهای)
-
-### مرحله 1️⃣: آپلود فایل به سرور موقت
-ابتدا باید فایلهای خود را به endpoint زیر آپلود کنید:
-
-```
-POST /upload-tmp-media/
-Content-Type: multipart/form-data
-
-Body:
-- file: [فایل رسید]
-```
-
-**پاسخ:**
-```json
-{
- "url": "/static/tmp/xyz123-receipt.jpg",
- "name": "receipt.jpg",
- "size": "1024000",
- "mime_type": "image/jpeg"
-}
-```
-
-### مرحله 2️⃣: ثبت URL فایلها در تراکنش
-سپس URL های دریافتی را به این endpoint ارسال کنید:
-
-```
-POST /api/transactions//receipts/upload/
-Content-Type: application/json
-```
-
----
-
-## 🚀 درخواست API (مرحله 2)
-
-### URL:
-```
-POST /api/transactions//receipts/upload/
-```
-
-### پارامترهای URL:
-| کلید | نوع داده | توضیحات |
-|------------------|-----------|----------------------------------------------------------|
-| `transaction_id` | Integer | شناسه تراکنش که میخواهید رسید برای آن ثبت کنید |
-
-### پارامترهای درخواست (JSON Body):
-| کلید | نوع داده | الزامی | توضیحات |
-|---------------|-----------|--------|----------------------------------------------------------|
-| `files` | String[] | بله | لیست URL های فایلهای آپلود شده از مرحله 1 (حداکثر 10 فایل) |
-| `description` | String | خیر | توضیحات اختیاری درباره رسیدها |
-
----
-
-## 💡 نکات مهم:
-1. **روند دو مرحلهای**:
- - **مرحله 1**: ابتدا فایلها را به `/upload-tmp-media/` آپلود کنید
- - **مرحله 2**: سپس URL های دریافتی را به این API ارسال کنید
-
-2. **محدودیت فایلها**:
- - حداکثر 10 فایل میتوانید در هر درخواست ثبت کنید
-
-3. **وضعیت تراکنش**:
- - فقط میتوانید برای تراکنشهایی با وضعیت 'pending' یا 'waiting_approval' رسید آپلود کنید
- - پس از ثبت موفقیتآمیز، وضعیت تراکنش به 'waiting_approval' تغییر میکند
-
-4. **احراز هویت**:
- - باید توکن احراز هویت را در هدر درخواست ارسال کنید
- - فقط میتوانید برای تراکنشهای خودتان رسید آپلود کنید
-
----
-
-## 📊 پاسخها
-
-| کد وضعیت | توضیحات |
-|---------------|-----------------------------------------------------------|
-| `201` | موفقیتآمیز - رسیدها با موفقیت ثبت شدند |
-| `400` | دادههای نامعتبر یا تراکنش قادر به دریافت رسید نیست |
-| `403` | عدم دسترسی - شما صاحب این تراکنش نیستید |
-| `404` | تراکنش یافت نشد |
-
----
-
-## 📄 نمونه درخواست کامل (JSON):
-
-```json
-{
- "files": [
- "/static/tmp/xyz123-receipt1.jpg",
- "/static/tmp/abc456-receipt2.jpg"
- ],
- "description": "Payment receipt for Python course"
-}
-```
-
----
-
-## 📄 نمونه پاسخ موفقیتآمیز
-
-```json
-{
- "success": true,
- "message": "Receipts uploaded successfully",
- "transaction_status": "waiting_approval",
- "receipts": [
- {
- "id": 1,
- "file": "http://example.com/media/receipts/1/receipt1.jpg",
- "description": "Payment receipt for course enrollment",
- "uploaded_at": "2025-12-03T10:30:00Z"
- },
- {
- "id": 2,
- "file": "http://example.com/media/receipts/1/receipt2.jpg",
- "description": "Payment receipt for course enrollment",
- "uploaded_at": "2025-12-03T10:30:05Z"
- }
- ]
-}
-```
-
----
-
-## 📄 نمونه درخواست کامل (cURL):
-
-### مرحله 1 - آپلود فایل:
-```bash
-curl -X POST \\
- 'http://your-api.com/upload-tmp-media/' \\
- -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\
- -F 'file=@/path/to/receipt1.jpg'
-```
-
-### مرحله 2 - ثبت رسید:
-```bash
-curl -X POST \\
- 'http://your-api.com/api/transactions/123/receipts/upload/' \\
- -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\
- -H 'Content-Type: application/json' \\
- -d '{
- "files": ["/static/tmp/xyz123-receipt1.jpg"],
- "description": "Payment receipt for Python course"
- }'
-```
-
----
-
-## 📄 نمونه پاسخ خطا (403 - عدم دسترسی):
-
-```json
-{
- "message": "You don't have permission to upload receipts for this transaction"
-}
-```
-
----
-
-## 📄 نمونه پاسخ خطا (400 - وضعیت نامعتبر):
-
-```json
-{
- "message": "Cannot upload receipts for transaction with status 'success'"
-}
-```
-"""
-
-
-def doc_list_transaction_receipts():
- return """
-# 🐈 Scenario
-🛠️ لیست رسیدهای پرداخت یک تراکنش
-
-این API برای دریافت لیست تمام رسیدهای آپلود شده برای یک تراکنش خاص استفاده میشود.
-
----
-
-## 🚀 درخواست API
-
-### URL:
-```
-GET /api/transactions//receipts/
-```
-
-### پارامترهای URL:
-| کلید | نوع داده | توضیحات |
-|------------------|-----------|----------------------------------------------------------|
-| `transaction_id` | Integer | شناسه تراکنش که میخواهید رسیدهای آن را مشاهده کنید |
-
----
-
-## 💡 نکات مهم:
-1. **احراز هویت**:
- - باید توکن احراز هویت را در هدر درخواست ارسال کنید
- - فقط میتوانید رسیدهای تراکنشهای خودتان را مشاهده کنید
-
-2. **مرتبسازی**:
- - رسیدها بر اساس تاریخ آپلود (جدیدترین اول) مرتب میشوند
-
----
-
-## 📊 پاسخها
-
-| کد وضعیت | توضیحات |
-|---------------|-----------------------------------------------------------|
-| `200` | موفقیتآمیز - لیست رسیدها بازگردانده شد |
-| `403` | عدم دسترسی - شما صاحب این تراکنش نیستید |
-| `404` | تراکنش یافت نشد |
-
----
-
-## 📄 نمونه پاسخ موفقیتآمیز
-
-```json
-[
- {
- "id": 1,
- "file": "http://example.com/media/receipts/1/receipt1.jpg",
- "description": "Payment receipt for course enrollment",
- "uploaded_at": "2025-12-03T10:30:00Z"
- },
- {
- "id": 2,
- "file": "http://example.com/media/receipts/1/receipt2.jpg",
- "description": "Second payment receipt",
- "uploaded_at": "2025-12-03T10:25:00Z"
- }
-]
-```
-
----
-
-## 📄 توضیحات مقادیر پاسخ
-
-| کلید | نوع داده | توضیحات |
-|---------------|------------|----------------------------------------------------------|
-| `id` | Integer | شناسه یکتای رسید |
-| `file` | String | URL کامل فایل رسید آپلود شده |
-| `description` | String | توضیحات اختیاری درباره رسید (ممکن است خالی باشد) |
-| `uploaded_at` | DateTime | تاریخ و زمان آپلود رسید |
-
----
-
-## 📄 نمونه درخواست (cURL):
-
-```bash
-curl -X GET \\
- 'http://your-api.com/api/transactions/123/receipts/' \\
- -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
-```
-
----
-
-## 📄 نمونه پاسخ خطا (403 - عدم دسترسی):
-
-```json
-{
- "message": "You don't have permission to view receipts for this transaction"
-}
-```
-
----
-
-## 📄 نمونه پاسخ خطا (404 - تراکنش یافت نشد):
-
-```json
-{
- "message": "Transaction not found"
-}
-```
-"""
-
-
-def doc_transaction_list():
- return """
-# 🐈 Scenario
-🛠️ لیست تراکنشهای کاربر
-
-این API برای دریافت لیست تمام تراکنشهای کاربر احراز هویت شده استفاده میشود.
-
----
-
-## 🚀 درخواست API
-
-### URL:
-```
-GET /api/transactions/list/
-```
-
----
-
-## 💡 نکات مهم:
-1. **احراز هویت**:
- - باید توکن احراز هویت را در هدر درخواست ارسال کنید
- - فقط تراکنشهای خودتان را مشاهده میکنید
-
-2. **فیلترینگ خودکار**:
- - تراکنشهای حذف شده (soft deleted) نمایش داده نمیشوند
-
-3. **وضعیتهای تراکنش**:
- - `pending`: در انتظار پرداخت
- - `waiting_approval`: در انتظار تایید (رسید آپلود شده)
- - `success`: پرداخت موفق و تایید شده
- - `failed`: پرداخت ناموفق
-
----
-
-## 📊 پاسخها
-
-| کد وضعیت | توضیحات |
-|---------------|-----------------------------------------------------------|
-| `200` | موفقیتآمیز - لیست تراکنشها بازگردانده شد |
-| `401` | عدم احراز هویت |
-
----
-
-## 📄 توضیحات مقادیر پاسخ
-
-| کلید | نوع داده | توضیحات |
-|---------------|------------|----------------------------------------------------------|
-| `id` | Integer | شناسه یکتای تراکنش |
-| `course` | Object | اطلاعات دوره مرتبط با تراکنش |
-| `status` | String | وضعیت تراکنش (pending, waiting_approval, success, failed) |
-| `price` | Decimal | مبلغ تراکنش |
-| `created_at` | DateTime | تاریخ و زمان ایجاد تراکنش |
-| `updated_at` | DateTime | تاریخ و زمان آخرین بهروزرسانی تراکنش |
-
----
-
-## 📄 نمونه پاسخ موفقیتآمیز
-
-```json
-[
- {
- "id": 1,
- "course": {
- "id": 5,
- "title": "Python Programming Basics",
- "slug": "python-programming-basics",
- "thumbnail": "http://example.com/media/courses/thumbnails/python.jpg",
- "price": "99.00",
- "final_price": "79.00"
- },
- "status": "waiting_approval",
- "price": "79.00",
- "created_at": "2025-12-01T10:00:00Z",
- "updated_at": "2025-12-03T10:30:00Z"
- },
- {
- "id": 2,
- "course": {
- "id": 8,
- "title": "Django Web Development",
- "slug": "django-web-development",
- "thumbnail": "http://example.com/media/courses/thumbnails/django.jpg",
- "price": "149.00",
- "final_price": "149.00"
- },
- "status": "success",
- "price": "149.00",
- "created_at": "2025-11-28T14:20:00Z",
- "updated_at": "2025-11-29T09:15:00Z"
- }
-]
-```
-
----
-
-## 📄 نمونه درخواست (cURL):
-
-```bash
-curl -X GET \\
- 'http://your-api.com/api/transactions/list/' \\
- -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
-```
-"""
-
-
-def doc_create_transaction():
- return """
-# 🐈 Scenario
-🛠️ ثبتنام در دوره و ایجاد تراکنش
-
-این API برای ثبتنام کاربر در یک دوره و ایجاد تراکنش استفاده میشود.
-
----
-
-## 🚀 درخواست API
-
-### URL:
-```
-POST /api/transactions//join/
-```
-
-### پارامترهای URL:
-| کلید | نوع داده | توضیحات |
-|---------|-----------|----------------------------------------------------------|
-| `slug` | String | اسلاگ دورهای که میخواهید در آن ثبتنام کنید |
-
-### پارامترهای درخواست (JSON Body):
-| کلید | نوع داده | الزامی | توضیحات |
-|---------------------|-----------|--------|----------------------------------------------------------|
-| `participant_infos` | Array | بله | لیست اطلاعات شرکتکنندگان |
-
-### ساختار `participant_infos`:
-| کلید | نوع داده | الزامی | توضیحات |
-|---------------|-----------|--------|----------------------------------------------------------|
-| `fullname` | String | بله | نام کامل شرکتکننده |
-| `email` | String | بله | ایمیل شرکتکننده (برای دوره رایگان باید با ایمیل کاربر احراز هویت شده یکسان باشد) |
-| `phone_number`| String | خیر | شماره تلفن شرکتکننده |
-| `gender` | String | خیر | جنسیت شرکتکننده (male, female) |
-| `birthdate` | Date | خیر | تاریخ تولد شرکتکننده (فرمت: YYYY-MM-DD) |
-
----
-
-## 💡 نکات مهم:
-1. **دوره رایگان**:
- - اگر دوره رایگان باشد و فقط یک شرکتکننده در لیست باشد و ایمیل او با کاربر احراز هویت شده یکسان باشد، تراکنش به صورت خودکار تایید میشود (status = 'success')
- - کاربر به صورت خودکار به عنوان دانشجو در دوره ثبت میشود
-
-2. **دوره پولی**:
- - تراکنش با وضعیت 'pending' ایجاد میشود
- - سیستم بر اساس موقعیت جغرافیایی کاربر، روش پرداخت مناسب را تعیین میکند
-
-3. **روش پرداخت (Payment Method)**:
- - **Payment_Gateway**: برای کاربران غیر روسی - پرداخت از طریق درگاه پرداخت آنلاین
- - **Receipt**: برای کاربران روسی - آپلود رسید پرداخت از طریق واتساپ
-
-4. **تشخیص موقعیت جغرافیایی**:
- - ابتدا از هدر Cloudflare (`CF-IPCountry`) استفاده میشود
- - در صورت عدم وجود، از پایگاه داده GeoIP محلی استفاده میشود
- - کاربران روسی روش پرداخت Receipt دریافت میکنند
- - کاربران سایر کشورها روش پرداخت Payment_Gateway دریافت میکنند
-
-5. **احراز هویت**:
- - باید توکن احراز هویت را در هدر درخواست ارسال کنید
-
----
-
-## 📊 پاسخها
-
-| کد وضعیت | توضیحات |
-|---------------|-----------------------------------------------------------|
-| `201` | موفقیتآمیز - تراکنش ایجاد شد |
-| `400` | دادههای نامعتبر |
-| `404` | دوره یافت نشد |
-
-### ساختار پاسخ:
-| کلید | نوع داده | توضیحات |
-|---------------------|-----------|----------------------------------------------------------|
-| `message` | String | پیام موفقیتآمیز |
-| `transaction_id` | Integer | شناسه تراکنش ایجاد شده |
-| `payment_method` | String | روش پرداخت (Payment_Gateway یا Receipt) |
-| `payment_link` | String | لینک پرداخت (فقط برای Payment_Gateway) |
-| `participant_infos` | Array | لیست اطلاعات شرکتکنندگان |
-
----
-
-## 💳 روشهای پرداخت:
-
-### Payment_Gateway (درگاه پرداخت):
-- **کاربران**: غیر روسی
-- **اقدام کاربر**: کلیک روی `payment_link` و پرداخت آنلاین
-- **فرآیند**: پرداخت مستقیم از طریق درگاه پرداخت
-
-### Receipt (رسید پرداخت):
-- **کاربران**: روسی
-- **اقدام کاربر**: آپلود رسید پرداخت از طریق واتساپ
-- **فرآیند**: آپلود رسید → بررسی توسط ادمین → تایید پرداخت
-
----
-
-## 📄 نمونه درخواست (JSON Body):
-
-```json
-{
- "participant_infos": [
- {
- "fullname": "علی رضایی",
- "email": "ali@example.com",
- "phone_number": "+989123456789",
- "gender": "male",
- "birthdate": "1995-05-15"
- }
- ]
-}
-```
-
----
-
-## 📄 نمونه پاسخ (دوره رایگان):
-
-```json
-{
- "message": "Transaction Participant created successfully.",
- "transaction_id": 123,
- "payment_method": Free,
- "payment_link": null,
- "participant_infos": [
- {
- "fullname": "علی رضایی",
- "email": "ali@example.com",
- "phone_number": "+989123456789",
- "gender": "male",
- "birthdate": "1995-05-15"
- }
- ]
-}
-```
-
----
-
-## 📄 نمونه پاسخ (دوره پولی - Payment_Gateway):
-
-```json
-{
- "message": "Transaction Participant created successfully.",
- "transaction_id": 374,
- "payment_method": "Payment_Gateway",
- "payment_link": "https://russia-payment.com/pay/374",
- "participant_infos": [
- {
- "fullname": "John Doe",
- "email": "john@example.com",
- "phone_number": "+1234567890",
- "gender": "male",
- "birthdate": "1990-01-01"
- }
- ]
-}
-```
-
----
-
-## 📄 نمونه پاسخ (دوره پولی - Receipt):
-
-```json
-{
- "message": "Transaction Participant created successfully.",
- "transaction_id": 375,
- "payment_method": "receipt",
- "payment_link": null,
- "participant_infos": [
- {
- "fullname": "Иван Иванов",
- "email": "ivan@example.ru",
- "phone_number": "+71234567890",
- "gender": "male",
- "birthdate": "1992-05-15"
- }
- ]
-}
-```
-
-**نکته**: برای روش پرداخت Receipt، کاربر باید رسید پرداخت خود را از طریق واتساپ آپلود کند.
-
----
-
-## 📄 نمونه درخواست (cURL):
-
-```bash
-curl -X POST \\
- 'http://your-api.com/api/transactions/python-programming-basics/join/' \\
- -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \\
- -H 'Content-Type: application/json' \\
- -d '{
- "participant_infos": [
- {
- "fullname": "علی رضایی",
- "email": "ali@example.com",
- "phone_number": "+989123456789",
- "gender": "male",
- "birthdate": "1995-05-15"
- }
- ]
- }'
-```
-"""
-
-
-hadis_list_swagger = swagger_auto_schema(
- operation_description="""
- Retrieve a paginated list of Hadis (traditions) for a specific category.
-
- **Key Features:**
- - Returns hadis entries filtered by category ID
- - Supports pagination for large datasets
- - Translations are automatically provided based on the Accept-Language header
- - Each hadis includes its category information, title, narrator, Arabic text, and translation
-
- **Usage:**
- - Use this endpoint to browse hadis within a specific category
- - The response includes pagination links (next/previous) for navigation
- - Set the Accept-Language header to get translations in your preferred language (en, fa, ar, ur)
- - Only active (status=True) hadis are returned
-
- **Response Structure:**
- - `count`: Total number of hadis in the category
- - `next`: URL for the next page (null if on last page)
- - `previous`: URL for the previous page (null if on first page)
- - `results`: Array of hadis objects with full details
- """,
- operation_summary="List Hadis by Category",
- tags=['Hadis'],
- manual_parameters=[
- openapi.Parameter(
- 'category_slug',
- openapi.IN_PATH,
- description="Unique identifier of the Hadis category. Must be a valid category ID that exists in the system.",
- type=openapi.TYPE_STRING,
- required=True,
- example='-330'
- ),
- openapi.Parameter(
- 'page',
- openapi.IN_QUERY,
- description="Page number for pagination. Starts from 1. If not provided, returns the first page.",
- type=openapi.TYPE_INTEGER,
- required=False,
- example=1
- ),
- openapi.Parameter(
- 'Accept-Language',
- openapi.IN_HEADER,
- description="Language code for translations. Supported codes: 'en' (English), 'fa' (Persian), 'ar' (Arabic), 'ur' (Urdu). Defaults to 'en' if not specified.",
- type=openapi.TYPE_STRING,
- required=False,
- default='en',
- enum=['en', 'fa', 'ar', 'ur']
- )
- ],
- responses={
- status.HTTP_200_OK: openapi.Response(
- description="Successfully retrieved paginated list of hadis for the specified category",
- examples={
- "application/json": {
- "count": 150,
- "next": "http://example.com/api/hadis/category/1/?page=2",
- "previous": None,
- "results": [
- {
- "id": 1,
- "number": 1,
- "title": "The Opening",
- "title_narrator": "From Abu Hurairah",
- "text": "إنما الأعمال بالنيات وإنما لكل امرئ ما نوى",
- "translation": "Actions are but by intention, and every man shall have only what he intended",
- "category": {
- "id": 1,
- "title": "Book of Faith",
- "slug": "book-of-faith",
- "source_type": "hadith",
- "sect_type": "sunni"
- },
- "status": {
- "id": 130,
- "title": "Прерванный",
- "color": "orange"
- },
- "share_link": "http://example.com/hadis/1"
- },
- {
- "id": 2,
- "number": 2,
- "title": "The Second Hadith",
- "title_narrator": "From Umar ibn al-Khattab",
- "text": "بينما نحن عند رسول الله صلى الله عليه وسلم ذات يوم",
- "translation": "While we were sitting with the Messenger of Allah (peace be upon him) one day",
- "category": {
- "id": 1,
- "title": "Book of Faith",
- "slug": "book-of-faith",
- "source_type": "hadith",
- "sect_type": "sunni"
- },
- "status": {
- "id": 130,
- "title": "Прерванный",
- "color": "orange"
- },
- "share_link": "http://example.com/hadis/2"
- }
- ]
- }
- }
- ),
- status.HTTP_404_NOT_FOUND: openapi.Response(
- description="The specified category ID does not exist or the category has no active hadis",
- examples={
- "application/json": {
- "detail": "Not found."
- }
- }
- ),
- status.HTTP_500_INTERNAL_SERVER_ERROR: openapi.Response(
- description="Internal server error occurred while processing the request"
- )
- }
-)
\ No newline at end of file
diff --git a/apps/transaction/management/__init__.py b/apps/transaction/management/__init__.py
deleted file mode 100644
index 11942f5..0000000
--- a/apps/transaction/management/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-# Management commands for transaction app
\ No newline at end of file
diff --git a/apps/transaction/management/commands/__init__.py b/apps/transaction/management/commands/__init__.py
deleted file mode 100644
index 0b1b20d..0000000
--- a/apps/transaction/management/commands/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-# Management commands
\ No newline at end of file
diff --git a/apps/transaction/management/commands/sync_successful_transactions.py b/apps/transaction/management/commands/sync_successful_transactions.py
deleted file mode 100644
index cddae07..0000000
--- a/apps/transaction/management/commands/sync_successful_transactions.py
+++ /dev/null
@@ -1,201 +0,0 @@
-from django.core.management.base import BaseCommand, CommandError
-from django.db import transaction
-from django.utils import timezone
-from apps.transaction.models import TransactionParticipant
-from apps.course.models import Participant
-from apps.account.models import User
-
-
-class Command(BaseCommand):
- help = 'بررسی تراکنشهای موفق و ایجاد شرکتکنندگان دوره در صورت عدم وجود'
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--dry-run',
- action='store_true',
- help='فقط نمایش تراکنشهایی که نیاز به بروزرسانی دارند بدون اعمال تغییرات',
- )
- parser.add_argument(
- '--transaction-id',
- type=int,
- help='بررسی تراکنش خاص با ID مشخص',
- )
- parser.add_argument(
- '--user-email',
- type=str,
- help='بررسی تراکنشهای کاربر خاص',
- )
- parser.add_argument(
- '--course-slug',
- type=str,
- help='بررسی تراکنشهای دوره خاص',
- )
-
- def handle(self, *args, **options):
- dry_run = options['dry_run']
- transaction_id = options.get('transaction_id')
- user_email = options.get('user_email')
- course_slug = options.get('course_slug')
-
- # ساخت queryset اولیه
- queryset = TransactionParticipant.objects.filter(
- status=TransactionParticipant.TransactionStatus.SUCCESS,
- is_deleted=False
- ).select_related('user', 'course')
-
- # اعمال فیلترهای اضافی
- if transaction_id:
- queryset = queryset.filter(id=transaction_id)
-
- if user_email:
- try:
- user = User.objects.get(email=user_email)
- queryset = queryset.filter(user=user)
- except User.DoesNotExist:
- raise CommandError(f'کاربر با ایمیل {user_email} یافت نشد.')
-
- if course_slug:
- queryset = queryset.filter(course__slug=course_slug)
-
- total_transactions = queryset.count()
-
- if total_transactions == 0:
- self.stdout.write(
- self.style.WARNING('هیچ تراکنش موفقی برای بررسی یافت نشد.')
- )
- return
-
- self.stdout.write(
- self.style.SUCCESS(f'تعداد {total_transactions} تراکنش موفق برای بررسی یافت شد.')
- )
-
- missing_participants = []
- existing_participants = []
- errors = []
-
- # بررسی هر تراکنش
- for trans in queryset:
- try:
- # بررسی وجود participant
- participant_exists = Participant.objects.filter(
- student=trans.user,
- course=trans.course
- ).exists()
-
- if not participant_exists:
- missing_participants.append(trans)
- self.stdout.write(
- self.style.WARNING(
- f'❌ تراکنش {trans.id}: کاربر {trans.user.email} در دوره "{trans.course.title}" ثبتنام نشده'
- )
- )
- else:
- existing_participants.append(trans)
- self.stdout.write(
- self.style.SUCCESS(
- f'✅ تراکنش {trans.id}: کاربر {trans.user.email} در دوره "{trans.course.title}" قبلاً ثبتنام شده'
- )
- )
-
- except Exception as e:
- errors.append((trans, str(e)))
- self.stdout.write(
- self.style.ERROR(
- f'⚠️ خطا در بررسی تراکنش {trans.id}: {str(e)}'
- )
- )
-
- # نمایش خلاصه
- self.stdout.write('\n' + '='*50)
- self.stdout.write(f'📊 خلاصه نتایج:')
- self.stdout.write(f' • کل تراکنشهای بررسی شده: {total_transactions}')
- self.stdout.write(f' • شرکتکنندگان موجود: {len(existing_participants)}')
- self.stdout.write(f' • شرکتکنندگان ناموجود: {len(missing_participants)}')
- self.stdout.write(f' • خطاها: {len(errors)}')
- self.stdout.write('='*50 + '\n')
-
- if not missing_participants:
- self.stdout.write(
- self.style.SUCCESS('🎉 همه تراکنشهای موفق دارای شرکتکننده مربوطه هستند!')
- )
- return
-
- if dry_run:
- self.stdout.write(
- self.style.WARNING(
- f'🔍 حالت Dry Run: {len(missing_participants)} شرکتکننده نیاز به ایجاد دارند.'
- )
- )
- self.stdout.write(
- 'برای اعمال تغییرات، دستور را بدون --dry-run اجرا کنید.'
- )
- return
-
- # ایجاد شرکتکنندگان ناموجود
- created_count = 0
- failed_count = 0
-
- self.stdout.write(
- self.style.SUCCESS(f'🚀 شروع ایجاد {len(missing_participants)} شرکتکننده...')
- )
-
- for trans in missing_participants:
- try:
- with transaction.atomic():
- # اضافه کردن نقش student اگر وجود نداشته باشد
- if not trans.user.has_role('student'):
- trans.user.add_role('student')
- self.stdout.write(
- f' 👤 نقش student به کاربر {trans.user.email} اضافه شد'
- )
-
- # ایجاد participant
- participant = Participant.objects.create(
- student=trans.user,
- course=trans.course
- )
-
- created_count += 1
- self.stdout.write(
- self.style.SUCCESS(
- f' ✅ شرکتکننده ایجاد شد: {trans.user.email} در دوره "{trans.course.title}"'
- )
- )
-
- except Exception as e:
- failed_count += 1
- self.stdout.write(
- self.style.ERROR(
- f' ❌ خطا در ایجاد شرکتکننده برای تراکنش {trans.id}: {str(e)}'
- )
- )
-
- # نمایش نتیجه نهایی
- self.stdout.write('\n' + '='*50)
- self.stdout.write('🏁 نتیجه نهایی:')
- self.stdout.write(f' • شرکتکنندگان ایجاد شده: {created_count}')
- self.stdout.write(f' • شکستها: {failed_count}')
-
- if created_count > 0:
- self.stdout.write(
- self.style.SUCCESS(f'✅ {created_count} شرکتکننده با موفقیت ایجاد شد!')
- )
-
- if failed_count > 0:
- self.stdout.write(
- self.style.ERROR(f'❌ {failed_count} مورد با خطا مواجه شد!')
- )
-
- self.stdout.write('='*50)
-
- def get_transaction_info(self, trans):
- """اطلاعات کامل تراکنش را برمیگرداند"""
- return {
- 'id': trans.id,
- 'user_email': trans.user.email,
- 'course_title': trans.course.title,
- 'course_slug': trans.course.slug,
- 'price': trans.price,
- 'created_at': trans.created_at,
- 'status': trans.status
- }
\ No newline at end of file
diff --git a/apps/transaction/migrations/0001_initial.py b/apps/transaction/migrations/0001_initial.py
deleted file mode 100644
index 3c50f2f..0000000
--- a/apps/transaction/migrations/0001_initial.py
+++ /dev/null
@@ -1,209 +0,0 @@
-# Generated by Django 4.2.27 on 2026-01-22 10:48
-
-import apps.transaction.models
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-import phonenumber_field.modelfields
-import utils.validators
-
-
-class Migration(migrations.Migration):
- initial = True
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ("course", "0001_initial"),
- ]
-
- operations = [
- migrations.CreateModel(
- name="TransactionParticipant",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "payment_method",
- models.CharField(
- choices=[
- ("receipt", "Receipt"),
- ("free", "Free"),
- ("Payment_Gateway", "Payment Gateway"),
- ],
- default="Payment_Gateway",
- max_length=20,
- verbose_name="Transaction Payment Method",
- ),
- ),
- (
- "price",
- models.DecimalField(
- decimal_places=2,
- default=0.0,
- max_digits=10,
- verbose_name="Transaction Price",
- ),
- ),
- (
- "status",
- models.CharField(
- choices=[
- ("pending", "Pending"),
- ("waiting_approval", "Waiting for Approval"),
- ("success", "Success"),
- ("failed", "Failed"),
- ],
- default="pending",
- max_length=20,
- verbose_name="Transaction Status",
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Updated at"),
- ),
- ("is_deleted", models.BooleanField(default=False)),
- (
- "course",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="course_transactions",
- to="course.course",
- ),
- ),
- (
- "user",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="transactions",
- to=settings.AUTH_USER_MODEL,
- ),
- ),
- ],
- ),
- migrations.CreateModel(
- name="TransactionReceipt",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "file",
- models.FileField(
- help_text="Upload payment receipt image or document",
- upload_to=apps.transaction.models.receipt_file_upload_to,
- verbose_name="Receipt File",
- ),
- ),
- (
- "uploaded_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Uploaded At"),
- ),
- (
- "description",
- models.TextField(
- blank=True,
- help_text="Optional description or notes about the receipt",
- null=True,
- verbose_name="Description",
- ),
- ),
- (
- "transaction",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="receipts",
- to="transaction.transactionparticipant",
- verbose_name="Transaction",
- ),
- ),
- ],
- options={
- "verbose_name": "Transaction Receipt",
- "verbose_name_plural": "Transaction Receipts",
- "ordering": ["-uploaded_at"],
- },
- ),
- migrations.CreateModel(
- name="ParticipantInfo",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "fullname",
- models.CharField(
- help_text="Enter the full name of the user.",
- max_length=255,
- verbose_name="Full Name",
- ),
- ),
- (
- "email",
- models.EmailField(
- help_text="Enter the user's email address.",
- max_length=254,
- verbose_name="Email Address",
- ),
- ),
- (
- "phone_number",
- phonenumber_field.modelfields.PhoneNumberField(
- blank=True,
- max_length=128,
- null=True,
- region=None,
- validators=[utils.validators.validate_possible_number],
- verbose_name="phone",
- ),
- ),
- (
- "gender",
- models.CharField(
- blank=True,
- choices=[("male", "Male"), ("female", "Female")],
- help_text="Select the user's gender.",
- max_length=20,
- null=True,
- verbose_name="Gender",
- ),
- ),
- (
- "birthdate",
- models.DateField(blank=True, null=True, verbose_name="birthdate"),
- ),
- (
- "transaction_participant",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="participant_infos",
- to="transaction.transactionparticipant",
- verbose_name="Transaction Participant",
- ),
- ),
- ],
- ),
- ]
diff --git a/apps/transaction/migrations/0002_alter_participantinfo_options_and_more.py b/apps/transaction/migrations/0002_alter_participantinfo_options_and_more.py
deleted file mode 100644
index 3bc0270..0000000
--- a/apps/transaction/migrations/0002_alter_participantinfo_options_and_more.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# Generated by Django 5.2.12 on 2026-05-03 14:09
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('course', '0004_alter_lessoncompletion_options_and_more'),
- ('transaction', '0001_initial'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.AlterModelOptions(
- name='participantinfo',
- options={'verbose_name': 'Participant Info', 'verbose_name_plural': 'Participant Infos'},
- ),
- migrations.AlterModelOptions(
- name='transactionparticipant',
- options={'verbose_name': 'Transaction Participant', 'verbose_name_plural': 'Transaction Participants'},
- ),
- migrations.AlterField(
- model_name='transactionparticipant',
- name='course',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_transactions', to='course.course', verbose_name='Course'),
- ),
- migrations.AlterField(
- model_name='transactionparticipant',
- name='is_deleted',
- field=models.BooleanField(default=False, verbose_name='Is Deleted'),
- ),
- migrations.AlterField(
- model_name='transactionparticipant',
- name='user',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to=settings.AUTH_USER_MODEL, verbose_name='User'),
- ),
- ]
diff --git a/apps/transaction/migrations/0003_alter_transactionparticipant_status.py b/apps/transaction/migrations/0003_alter_transactionparticipant_status.py
deleted file mode 100644
index a5c364d..0000000
--- a/apps/transaction/migrations/0003_alter_transactionparticipant_status.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Generated by Django 5.2.12 on 2026-07-08 13:01
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('transaction', '0002_alter_participantinfo_options_and_more'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='transactionparticipant',
- name='status',
- field=models.CharField(choices=[('pending', 'Pending'), ('waiting_approval', 'Waiting for Approval'), ('success', 'Success'), ('failed', 'Failed'), ('refunded', 'Refunded')], default='pending', max_length=20, verbose_name='Transaction Status'),
- ),
- ]
diff --git a/apps/transaction/migrations/__init__.py b/apps/transaction/migrations/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/transaction/models.py b/apps/transaction/models.py
deleted file mode 100644
index d37ee61..0000000
--- a/apps/transaction/models.py
+++ /dev/null
@@ -1,128 +0,0 @@
-from django.db import models
-import os
-
-from django.utils.translation import gettext_lazy as _
-
-from apps.account.models import StudentUser, User
-from apps.course.models import Course
-from phonenumber_field.modelfields import PhoneNumberField
-from utils.validators import validate_possible_number
-
-
-def receipt_file_upload_to(instance, filename):
- return os.path.join(f"receipts/{instance.transaction.id}/{filename}")
-
-
-class TransactionParticipant(models.Model):
-
-
- class TransactionStatus(models.TextChoices):
- PENDING = 'pending', _('Pending')
- WAITING_APPROVAL = 'waiting_approval', _('Waiting for Approval')
- SUCCESS = 'success', _('Success')
- FAILED = 'failed', _('Failed')
- REFUNDED = 'refunded', _('Refunded')
-
- class PaymentMethods(models.TextChoices):
- RECEIPT = 'receipt', _('Receipt')
- FREE = 'free', _('Free')
- PAYMENT_GATEWAY = 'Payment_Gateway', _('Payment Gateway')
-
- user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='transactions', verbose_name=_('User'))
- course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_transactions', verbose_name=_('Course'))
- payment_method=models.CharField(max_length=20, choices=PaymentMethods.choices, default=PaymentMethods.PAYMENT_GATEWAY, verbose_name=_('Transaction Payment Method'))
- # is_paid = models.BooleanField(default=False, verbose_name='Payment Status', help_text='Indicates whether the payment has been completed or not')
- price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name=_('Transaction Price'))
- status = models.CharField(max_length=20, choices=TransactionStatus.choices, default=TransactionStatus.PENDING, verbose_name=_('Transaction Status'))
- created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
- updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at"))
- is_deleted = models.BooleanField(default=False, verbose_name=_('Is Deleted'))
-
- def __str__(self):
- return f"{self.user.email} - {self.course.title} ({self.status})"
-
- def is_participant_enrolled(self):
- """بررسی اینکه آیا کاربر در دوره ثبتنام شده یا نه"""
- from apps.course.models import Participant
- return Participant.objects.filter(
- student=self.user,
- course=self.course
- ).exists()
-
- def get_participant(self):
- """دریافت شرکتکننده دوره اگر وجود داشته باشد"""
- from apps.course.models import Participant
- return Participant.objects.filter(
- student=self.user,
- course=self.course
- ).first()
-
- class Meta:
- verbose_name = _('Transaction Participant')
- verbose_name_plural = _('Transaction Participants')
-
-
-class ParticipantInfo(models.Model):
- class GenderChoices(models.TextChoices):
- MALE = 'male', _('Male')
- FEMALE = 'female', _('Female')
-
- transaction_participant = models.ForeignKey(
- TransactionParticipant,
- on_delete=models.CASCADE,
- related_name='participant_infos',
- verbose_name=_("Transaction Participant")
- )
- fullname = models.CharField(max_length=255, verbose_name=_("Full Name"), help_text=_("Enter the full name of the user."))
- email = models.EmailField(verbose_name=_("Email Address"), help_text=_("Enter the user's email address."))
- phone_number = PhoneNumberField(validators=[validate_possible_number], null=True, blank=True, verbose_name=_('phone'))
- gender = models.CharField(
- max_length=20, choices=GenderChoices.choices, null=True, blank=True, verbose_name=_('Gender'), help_text=_("Select the user's gender.")
- )
- birthdate = models.DateField(verbose_name=_('birthdate'), null=True, blank=True)
-
- def __str__(self):
- return f"{self.fullname} (Transaction: {self.transaction_participant.id}) - {self.email}"
-
- class Meta:
- verbose_name = _('Participant Info')
- verbose_name_plural = _('Participant Infos')
-
-
-class TransactionReceipt(models.Model):
- """
- Model for storing payment receipts uploaded by users for transactions
- """
- transaction = models.ForeignKey(
- TransactionParticipant,
- on_delete=models.CASCADE,
- related_name='receipts',
- verbose_name=_('Transaction')
- )
- file = models.FileField(
- upload_to=receipt_file_upload_to,
- verbose_name=_('Receipt File'),
- help_text=_('Upload payment receipt image or document')
- )
- uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Uploaded At'))
- description = models.TextField(
- blank=True,
- null=True,
- verbose_name=_('Description'),
- help_text=_('Optional description or notes about the receipt')
- )
-
- class Meta:
- verbose_name = _('Transaction Receipt')
- verbose_name_plural = _('Transaction Receipts')
- ordering = ['-uploaded_at']
-
- def __str__(self):
- return f"Receipt for Transaction #{self.transaction.id} - {self.uploaded_at.strftime('%Y-%m-%d %H:%M')}"
-
-
-
-
-
-
-
diff --git a/apps/transaction/serializers.py b/apps/transaction/serializers.py
deleted file mode 100644
index 1e49e32..0000000
--- a/apps/transaction/serializers.py
+++ /dev/null
@@ -1,155 +0,0 @@
-
-from rest_framework import serializers
-
-from apps.transaction.models import TransactionParticipant, ParticipantInfo, TransactionReceipt
-from apps.course.serializers import CourseDetailSerializer
-from utils import FileFieldSerializer
-
-
-
-
-class ParticipantInfoSerializer(serializers.ModelSerializer):
- phone_number = serializers.CharField(max_length=30)
-
- class Meta:
- model = ParticipantInfo
- fields = ['fullname', 'email', 'phone_number', 'gender', 'birthdate']
-
- def validate_phone_number(self, value):
- return value
-
-
-class TransactionParticipantSerializer(serializers.ModelSerializer):
- participant_infos = ParticipantInfoSerializer(many=True)
-
- class Meta:
- model = TransactionParticipant
- fields = ['participant_infos']
-
-
- def create(self, validated_data):
- participant_infos_data = validated_data.pop('participant_infos', [])
- transaction_participant = TransactionParticipant.objects.create(**validated_data)
-
- for participant_info_data in participant_infos_data:
- ParticipantInfo.objects.create(transaction_participant=transaction_participant, **participant_info_data)
-
- return transaction_participant
-
-
-
-class TransactionListSerializer(serializers.ModelSerializer):
- course = serializers.SerializerMethodField()
- receipts = serializers.SerializerMethodField()
-
- class Meta:
- model = TransactionParticipant
- fields = ['id', 'course', 'status', 'price', 'receipts', 'created_at', 'updated_at']
-
- def get_course(self, obj):
- return CourseDetailSerializer(obj.course, context=self.context).data
-
- def get_receipts(self, obj):
- receipts = obj.receipts.all()
- return TransactionReceiptSerializer(receipts, many=True, context=self.context).data
-
-
-class TransactionReceiptSerializer(serializers.ModelSerializer):
- """
- Serializer for uploading payment receipts
- Uses FileFieldSerializer to handle pre-uploaded files from /upload-tmp-media/
- """
- file = FileFieldSerializer()
-
- class Meta:
- model = TransactionReceipt
- fields = ['id', 'file', 'description', 'uploaded_at']
- read_only_fields = ['id', 'uploaded_at']
-
-
-class UploadReceiptsSerializer(serializers.Serializer):
- """
- Serializer for uploading multiple receipt files for a transaction.
- Files should be pre-uploaded using /upload-tmp-media/ endpoint,
- then their URLs should be sent here.
- """
- files = serializers.ListField(
- child=FileFieldSerializer(),
- allow_empty=False,
- max_length=10,
- help_text="List of file URLs (max 10 files) - files should be pre-uploaded via /upload-tmp-media/"
- )
- description = serializers.CharField(
- required=False,
- allow_blank=True,
- max_length=1000,
- help_text="Optional description for the receipts"
- )
-
- def validate_files(self, files):
- """
- Validate uploaded file URLs
- """
- if len(files) > 10:
- raise serializers.ValidationError("You can upload a maximum of 10 files.")
-
- return files
-
-
-# =============================================================================
-# Admin Panel Serializers
-# =============================================================================
-
-class AdminTransactionReceiptSerializer(serializers.ModelSerializer):
- file = serializers.SerializerMethodField()
-
- class Meta:
- model = TransactionReceipt
- fields = ['id', 'file', 'description', 'uploaded_at']
-
- def get_file(self, obj):
- request = self.context.get('request')
- if obj.file:
- if request:
- return request.build_absolute_uri(obj.file.url)
- return obj.file.url
- return None
-
-
-class AdminParticipantInfoSerializer(serializers.ModelSerializer):
- phone_number = serializers.CharField(read_only=True)
-
- class Meta:
- model = ParticipantInfo
- fields = ['id', 'fullname', 'email', 'phone_number', 'gender', 'birthdate']
-
-
-class AdminTransactionSerializer(serializers.ModelSerializer):
- user_name = serializers.CharField(source='user.fullname', read_only=True)
- user_email = serializers.CharField(source='user.email', read_only=True)
- course_title = serializers.SerializerMethodField()
- receipts = AdminTransactionReceiptSerializer(many=True, read_only=True)
- participant_infos = AdminParticipantInfoSerializer(many=True, read_only=True)
-
- class Meta:
- model = TransactionParticipant
- fields = [
- 'id', 'user', 'user_name', 'user_email',
- 'course', 'course_title',
- 'price', 'status', 'payment_method',
- 'participant_infos', 'receipts',
- 'created_at', 'updated_at'
- ]
- read_only_fields = [
- 'id', 'user', 'user_name', 'user_email',
- 'course', 'course_title', 'price', 'payment_method',
- 'participant_infos', 'receipts', 'created_at', 'updated_at'
- ]
-
- def get_course_title(self, obj):
- if not obj.course:
- return None
- request = self.context.get('request')
- lang = getattr(request, 'LANGUAGE_CODE', None) or 'en'
- from apps.course.models.course import get_localized_field
- return get_localized_field(lang, obj.course.title)
\ No newline at end of file
diff --git a/apps/transaction/signals.py b/apps/transaction/signals.py
deleted file mode 100644
index 13677ec..0000000
--- a/apps/transaction/signals.py
+++ /dev/null
@@ -1,123 +0,0 @@
-from django.db.models.signals import post_save, pre_save
-from django.dispatch import receiver
-from django.db import transaction
-
-from apps.transaction.models import TransactionParticipant
-from apps.course.models import Participant
-
-
-@receiver(pre_save, sender=TransactionParticipant)
-def store_previous_status(sender, instance, **kwargs):
- """
- Store the previous status before saving to compare with new status
- """
- if instance.pk:
- try:
- previous_instance = TransactionParticipant.objects.get(pk=instance.pk)
- instance._previous_status = previous_instance.status
- except TransactionParticipant.DoesNotExist:
- instance._previous_status = None
- else:
- instance._previous_status = None
-
-
-@receiver(post_save, sender=TransactionParticipant)
-def create_participant_on_success(sender, instance, created, **kwargs):
- """
- Create course participant when transaction status changes to SUCCESS
- """
- # اگر تراکنش جدید ایجاد شده و وضعیت آن SUCCESS است
- if created and instance.status == TransactionParticipant.TransactionStatus.SUCCESS:
- create_course_participant(instance)
-
- # اگر تراکنش موجود بوده و وضعیت آن از حالت دیگری به SUCCESS تغییر کرده
- elif not created and hasattr(instance, '_previous_status'):
- if (instance._previous_status != TransactionParticipant.TransactionStatus.SUCCESS and
- instance.status == TransactionParticipant.TransactionStatus.SUCCESS):
- create_course_participant(instance)
-
-
-def create_course_participant(transaction_instance):
- """
- Create course participant for successful transaction
- """
- try:
- with transaction.atomic():
- # بررسی اینکه آیا کاربر نقش student دارد یا نه
- if not transaction_instance.user.has_role('student'):
- transaction_instance.user.add_role('student')
-
- # بررسی اینکه آیا قبلاً participant ایجاد شده یا نه
- existing_participant = Participant.objects.filter(
- student_id=transaction_instance.user.id,
- course=transaction_instance.course
- ).first()
-
- if not existing_participant:
- # ایجاد participant جدید
- participant = Participant.objects.create(
- student=transaction_instance.user,
- course=transaction_instance.course
- )
- print(f"Course participant created: {participant}")
- else:
- print(f"Course participant already exists: {existing_participant}")
-
- except Exception as e:
- print(f"Error creating course participant: {e}")
-
-
-from apps.account.notification_service import create_and_send_notification
-from apps.course.models.course import get_localized_field
-
-@receiver(post_save, sender=TransactionParticipant)
-def notify_transaction_status_changed(sender, instance, created, **kwargs):
- """
- Send notifications when transaction status transitions to SUCCESS, FAILED, or REFUNDED.
- """
- status_changed = False
-
- if created:
- status_changed = True
- elif hasattr(instance, '_previous_status') and instance._previous_status != instance.status:
- status_changed = True
-
- if not status_changed:
- return
-
- course_title_en = get_localized_field('en', instance.course.title)
- course_title_fa = get_localized_field('fa', instance.course.title)
-
- # 1. SUCCESS
- if instance.status == TransactionParticipant.TransactionStatus.SUCCESS:
- create_and_send_notification(
- user=instance.user,
- title_en="Payment Successful",
- body_en=f"Your payment for course '{course_title_en}' was successful.",
- title_fa="پرداخت موفقیتآمیز",
- body_fa=f"پرداخت شما برای دوره «{course_title_fa}» با موفقیت انجام شد.",
- service='imam-javad',
- data={'type': 'payment_successful', 'transaction_id': instance.id, 'course_id': instance.course.id}
- )
- # 2. FAILED
- elif instance.status == TransactionParticipant.TransactionStatus.FAILED:
- create_and_send_notification(
- user=instance.user,
- title_en="Payment Failed",
- body_en=f"Your payment attempt for course '{course_title_en}' failed.",
- title_fa="خطا در پرداخت",
- body_fa=f"عملیات پرداخت شما برای دوره «{course_title_fa}» ناموفق بود.",
- service='imam-javad',
- data={'type': 'payment_failed', 'transaction_id': instance.id, 'course_id': instance.course.id}
- )
- # 3. REFUNDED
- elif instance.status == TransactionParticipant.TransactionStatus.REFUNDED:
- create_and_send_notification(
- user=instance.user,
- title_en="Refund Completed",
- body_en=f"Your refund for course '{course_title_en}' has been processed.",
- title_fa="بازگشت وجه انجام شد",
- body_fa=f"مبلغ پرداختی شما برای دوره «{course_title_fa}» بازگردانده (ریفاند) شد.",
- service='imam-javad',
- data={'type': 'refund_completed', 'transaction_id': instance.id, 'course_id': instance.course.id}
- )
\ No newline at end of file
diff --git a/apps/transaction/tests.py b/apps/transaction/tests.py
deleted file mode 100644
index 08f1014..0000000
--- a/apps/transaction/tests.py
+++ /dev/null
@@ -1,137 +0,0 @@
-from django.test import TestCase
-from django.contrib.auth import get_user_model
-from apps.account.models import StudentUser
-from apps.course.models import Course, CourseCategory, Participant
-from apps.transaction.models import TransactionParticipant
-from apps.account.models import ProfessorUser
-
-User = get_user_model()
-
-
-class TransactionParticipantSignalTest(TestCase):
- def setUp(self):
- """تنظیمات اولیه برای تست"""
- # ایجاد کاربر
- self.user = User.objects.create_user(
- email='test@example.com',
- password='testpass123'
- )
-
- # ایجاد استاد
- self.professor = ProfessorUser.objects.create(
- email='professor@example.com',
- password='testpass123'
- )
-
- # ایجاد دستهبندی دوره
- self.category = CourseCategory.objects.create(
- name='Test Category',
- slug='test-category'
- )
-
- # ایجاد دوره
- self.course = Course.objects.create(
- title='Test Course',
- slug='test-course',
- category=self.category,
- professor=self.professor,
- video_type='youtube_link',
- level='beginner',
- duration=10,
- lessons_count=5,
- description='Test course description',
- is_free=False,
- price=100.00,
- final_price=100.00
- )
-
- def test_participant_created_on_success_status(self):
- """تست ایجاد participant هنگام تغییر وضعیت به SUCCESS"""
- # ایجاد تراکنش با وضعیت PENDING
- transaction = TransactionParticipant.objects.create(
- user=self.user,
- course=self.course,
- price=100.00,
- status=TransactionParticipant.TransactionStatus.PENDING
- )
-
- # بررسی که participant ایجاد نشده
- self.assertFalse(
- Participant.objects.filter(student=self.user, course=self.course).exists()
- )
-
- # تغییر وضعیت به SUCCESS
- transaction.status = TransactionParticipant.TransactionStatus.SUCCESS
- transaction.save()
-
- # بررسی که participant ایجاد شده
- self.assertTrue(
- Participant.objects.filter(student=self.user, course=self.course).exists()
- )
-
- # بررسی که کاربر نقش student دارد
- self.assertTrue(self.user.has_role('student'))
-
- def test_participant_created_on_direct_success(self):
- """تست ایجاد participant هنگام ایجاد تراکنش با وضعیت SUCCESS"""
- # ایجاد تراکنش مستقیماً با وضعیت SUCCESS
- transaction = TransactionParticipant.objects.create(
- user=self.user,
- course=self.course,
- price=100.00,
- status=TransactionParticipant.TransactionStatus.SUCCESS
- )
-
- # بررسی که participant ایجاد شده
- self.assertTrue(
- Participant.objects.filter(student=self.user, course=self.course).exists()
- )
-
- # بررسی که کاربر نقش student دارد
- self.assertTrue(self.user.has_role('student'))
-
- def test_no_duplicate_participant(self):
- """تست عدم ایجاد participant تکراری"""
- # ایجاد participant دستی
- existing_participant = Participant.objects.create(
- student=self.user,
- course=self.course
- )
-
- # ایجاد تراکنش با وضعیت SUCCESS
- transaction = TransactionParticipant.objects.create(
- user=self.user,
- course=self.course,
- price=100.00,
- status=TransactionParticipant.TransactionStatus.SUCCESS
- )
-
- # بررسی که فقط یک participant وجود دارد
- self.assertEqual(
- Participant.objects.filter(student=self.user, course=self.course).count(),
- 1
- )
-
- def test_model_helper_methods(self):
- """تست متدهای کمکی مدل"""
- # ایجاد تراکنش
- transaction = TransactionParticipant.objects.create(
- user=self.user,
- course=self.course,
- price=100.00,
- status=TransactionParticipant.TransactionStatus.PENDING
- )
-
- # بررسی که participant وجود ندارد
- self.assertFalse(transaction.is_participant_enrolled())
- self.assertIsNone(transaction.get_participant())
-
- # ایجاد participant
- participant = Participant.objects.create(
- student=self.user,
- course=self.course
- )
-
- # بررسی که participant وجود دارد
- self.assertTrue(transaction.is_participant_enrolled())
- self.assertEqual(transaction.get_participant(), participant)
diff --git a/apps/transaction/urls.py b/apps/transaction/urls.py
deleted file mode 100644
index 49862f7..0000000
--- a/apps/transaction/urls.py
+++ /dev/null
@@ -1,23 +0,0 @@
-
-from django.urls import path, re_path, include
-from rest_framework.routers import DefaultRouter, SimpleRouter
-
-from . import views
-from .views import AdminTransactionViewSet
-
-router = SimpleRouter()
-router.register(r'admin/transactions', AdminTransactionViewSet, basename='admin-transactions')
-
-# Hide admin viewsets from swagger
-for prefix, viewset, basename in router.registry:
- viewset.swagger_schema = None
-
-urlpatterns = [
- path('', include(router.urls)),
- re_path(r'(?P[\w-]+)/join/$', views.TransactionParticipantCreateView.as_view(), name='transaction-participant-create'),
- path('list/', views.TransactiontListView.as_view(), name='transaction-list'),
- path('/delete/', views.SoftDeleteTransactionParticipantView.as_view(), name='soft-delete-transaction-participant'),
- path('/receipts/upload/', views.UploadTransactionReceiptsView.as_view(), name='upload-transaction-receipts'),
- path('/receipts/', views.TransactionReceiptsListView.as_view(), name='transaction-receipts-list'),
-
-]
\ No newline at end of file
diff --git a/apps/transaction/views.py b/apps/transaction/views.py
deleted file mode 100644
index 66d6a61..0000000
--- a/apps/transaction/views.py
+++ /dev/null
@@ -1,556 +0,0 @@
-from rest_framework import generics, status
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.response import Response
-from rest_framework.views import APIView
-from rest_framework.authentication import TokenAuthentication
-from django.db.models import Q
-from apps.course.models import Participant, Course
-from apps.course.models.course import extract_text_from_json
-from apps.transaction.models import TransactionParticipant, TransactionReceipt
-from apps.transaction.serializers import (
- TransactionParticipantSerializer,
- TransactionListSerializer,
- UploadReceiptsSerializer,
- TransactionReceiptSerializer
-)
-from utils.exceptions import AppAPIException
-from apps.account.models import User
-from drf_yasg.utils import swagger_auto_schema
-from drf_yasg import openapi
-from apps.transaction.models import TransactionParticipant
-from apps.transaction.doc import (
- doc_upload_transaction_receipts,
- doc_list_transaction_receipts,
- doc_transaction_list,
- doc_create_transaction
-)
-
-from utils.ip_helper import get_client_ip, get_country_code
-
-
-def find_course_by_multilingual_identifier(identifier: str):
- normalized = (identifier or "").strip()
- if not normalized:
- return None
-
- course = Course.objects.filter(slug__contains=[{"title": normalized}]).first()
- if course:
- return course
-
- course = Course.objects.filter(
- Q(title__icontains=normalized) |
- Q(slug__icontains=normalized)
- ).first()
- if course:
- return course
-
- normalized_lower = normalized.lower()
- for item in Course.objects.all().only("id", "title", "slug"):
- title_text = extract_text_from_json(item.title).lower()
- slug_text = extract_text_from_json(item.slug).lower()
- if normalized_lower in title_text or normalized_lower in slug_text:
- return item
-
- return None
-
-
-
-class TransactionParticipantCreateView(generics.CreateAPIView):
- queryset = TransactionParticipant.objects.all()
- serializer_class = TransactionParticipantSerializer
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- operation_description=doc_create_transaction(),
- responses={
- status.HTTP_201_CREATED: openapi.Response(
- description="Transaction participant created successfully",
- examples={
- "application/json": {
- "message": "Transaction Participant created successfully.",
- "transaction_id": 374,
- "payment_method": "Payment_Gateway",
- "payment_link": "https://russia-payment.com/pay/374",
- "participant_infos": [
- {
- "fullname": "string",
- "email": "admin@gmail.com",
- "phone_number": "string",
- "gender": "male",
- "birthdate": "2025-12-28"
- }
- ]
- }
- },
- schema=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'message': openapi.Schema(type=openapi.TYPE_STRING, description="Success message"),
- 'transaction_id': openapi.Schema(type=openapi.TYPE_INTEGER, description="Unique transaction identifier"),
- 'payment_method': openapi.Schema(
- type=openapi.TYPE_STRING,
- enum=['Payment_Gateway', 'receipt'],
- description="Payment method: 'Payment_Gateway' for online payment, 'receipt' for WhatsApp upload"
- ),
- 'payment_link': openapi.Schema(
- type=openapi.TYPE_STRING,
- nullable=True,
- description="Payment gateway URL (only present when payment_method is 'Payment_Gateway')"
- ),
- 'participant_infos': openapi.Schema(
- type=openapi.TYPE_ARRAY,
- items=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'fullname': openapi.Schema(type=openapi.TYPE_STRING),
- 'email': openapi.Schema(type=openapi.TYPE_STRING),
- 'phone_number': openapi.Schema(type=openapi.TYPE_STRING),
- 'gender': openapi.Schema(type=openapi.TYPE_STRING, enum=['male', 'female']),
- 'birthdate': openapi.Schema(type=openapi.TYPE_STRING, format='date'),
- }
- ),
- description="List of participant information"
- ),
- },
- required=['message', 'transaction_id', 'participant_infos']
- )
- ),
- status.HTTP_400_BAD_REQUEST: openapi.Response(
- description="Invalid data provided",
- schema=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'detail': openapi.Schema(type=openapi.TYPE_STRING),
- }
- )
- ),
- status.HTTP_404_NOT_FOUND: openapi.Response(
- description="Course not found",
- schema=openapi.Schema(
- type=openapi.TYPE_OBJECT,
- properties={
- 'message': openapi.Schema(type=openapi.TYPE_STRING),
- }
- )
- ),
- },
- tags=['Imam-Javad - Transaction']
- )
- def post(self, request, *args, **kwargs):
- # Simply call the create method
- return self.create(request, *args, **kwargs)
-
- def create(self, request, *args, **kwargs):
- user = request.user
- course_slug = self.kwargs.get('slug')
-
- # 1. Retrieve Course
- course = find_course_by_multilingual_identifier(course_slug)
- if not course:
- raise AppAPIException({'message': "Course not found"})
-
- participant_infos = request.data.get('participant_infos', [])
-
- # 2. Validate and Initialize
- serializer = self.get_serializer(data=request.data)
- serializer.is_valid(raise_exception=True)
- statis = TransactionParticipant.TransactionStatus.PENDING
-
- # 3. Handle Free/Self-Enrollment Logic
- if len(participant_infos) == 1 and (course.final_price == 0 or course.is_free):
- participant = participant_infos[0]
- if participant.get('email') != user.email:
- raise AppAPIException({'message': "The email must be for the requesting user"})
-
- if not user.has_role('student'):
- user.add_role('student')
-
- existing_participant = Participant.objects.filter(student=user, course=course).first()
- if existing_participant:
- participant = existing_participant
- else:
- participant = Participant.objects.create(student=user, course=course)
- statis = TransactionParticipant.TransactionStatus.SUCCESS
-
- # 4. Save Transaction
- transaction_participant = serializer.save(
- user=user,
- course=course,
- price=course.final_price,
- status=statis
- )
- print(f'---> {type(transaction_participant)}/ {transaction_participant}')
-
- # 5. Update user birthdate based on participant infos
- validated_participant_infos = serializer.validated_data.get('participant_infos', [])
- for info in validated_participant_infos:
- email = info.get('email')
- birthdate = info.get('birthdate')
- if email and birthdate:
- try:
- target_user = User.objects.filter(email__iexact=email.strip()).first()
- if target_user:
- target_user.birthdate = birthdate
- target_user.save(update_fields=['birthdate'])
- except Exception as e:
- print(f"Error updating user birthdate: {e}")
-
- # =======================================================
- # NEW LOGIC: HYBRID GEOLOCATION CHECK (Cloudflare + Local DB)
- # =======================================================
-
- payment_link = None
-
- payment_method = TransactionParticipant.PaymentMethods.FREE
- if statis == TransactionParticipant.TransactionStatus.PENDING:
-
- # Step A: Fast Path - Check Cloudflare Header
- # Cloudflare sends the 2-letter code (e.g., 'RU', 'US') in this header
- country_code = request.META.get('HTTP_CF_IPCOUNTRY')
-
- # Step B: Slow Path - Fallback to Local DB
- # If header is missing (e.g., Localhost, direct connection, or CF failed)
- if not country_code:
- try:
- client_ip =get_client_ip(request)
- # "188.93.104.1"
- # get_client_ip(request)
- # Assuming your helper handles errors gracefully and returns None
- country_code = get_country_code(client_ip)
- except Exception as e:
- print(f"GeoIP Lookup Failed: {e}")
- country_code = None
- payment_method = TransactionParticipant.PaymentMethods.RECEIPT
- # Step C: Apply Logic
- if country_code != 'RU':
- payment_method = TransactionParticipant.PaymentMethods.PAYMENT_GATEWAY
- payment_link = f"https://russia-payment.com/pay/{transaction_participant.id}"
-
- # Uncomment if you want a global fallback link
- # else:
- # payment_link = f"https://global-payment.com/pay/{transaction_participant.id}"
-
- # =======================================================
-
- return Response({
- 'message': 'Transaction Participant created successfully.',
- 'transaction_id': transaction_participant.id,
- 'payment_method':payment_method,
- 'payment_link': payment_link,
- 'participant_infos': serializer.data['participant_infos']
- }, status=status.HTTP_201_CREATED)
-
-
-
-
-from utils.pagination import StandardResultsSetPagination
-
-
-
-class TransactiontListView(generics.ListAPIView):
- queryset = TransactionParticipant.objects.all()
- serializer_class = TransactionListSerializer
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- pagination_class = StandardResultsSetPagination
-
- @swagger_auto_schema(
- operation_description=doc_transaction_list(),
- tags=['Imam-Javad - Transaction']
- )
- def get(self, request, *args, **kwargs):
- return self.list(request, *args, **kwargs)
- def get_queryset(self):
- queryset = super().get_queryset()
- queryset = queryset.filter(user=self.request.user, is_deleted=False)
- return queryset
-
-
-
-class SoftDeleteTransactionParticipantView(APIView):
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- operation_summary="Soft delete a transaction participant",
- operation_description="Marks a transaction participant as deleted without removing it from the database",
- tags=['Imam-Javad - Transaction'],
- manual_parameters=[
- openapi.Parameter(
- 'id',
- openapi.IN_PATH,
- description="Transaction Participant ID",
- type=openapi.TYPE_INTEGER,
- required=True
- )
- ],
- responses={
- 200: openapi.Response(
- description="Transaction participant successfully marked as deleted",
- examples={
- "application/json": {
- "success": True,
- "message": "Transaction participant successfully marked as deleted"
- }
- }
- ),
- 404: "Transaction participant not found",
- 403: "Permission denied"
- }
- )
- def delete(self, request, pk):
- try:
- transaction = TransactionParticipant.objects.get(pk=pk)
- if transaction.user == request.user:
- transaction.is_deleted = True
- transaction.save()
- return Response({
- "success": True,
- "message": "Transaction participant successfully marked as deleted"
- }, status=status.HTTP_200_OK)
- else:
- raise AppAPIException(
- detail={'message': "You don't have permission to delete this transaction"},
- status_code=status.HTTP_403_FORBIDDEN
- )
-
- except TransactionParticipant.DoesNotExist:
- raise AppAPIException({'message': "Transaction participant not found"})
-
-
-class UploadTransactionReceiptsView(APIView):
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
-
- @swagger_auto_schema(
- operation_summary="Upload payment receipts for a transaction",
- operation_description=doc_upload_transaction_receipts(),
- tags=['Imam-Javad - Transaction'],
- request_body=UploadReceiptsSerializer,
- responses={
- 201: openapi.Response(
- description="Receipts uploaded successfully",
- examples={
- "application/json": {
- "success": True,
- "message": "Receipts uploaded successfully",
- "transaction_status": "waiting_approval",
- "receipts": [
- {
- "id": 1,
- "file": "http://example.com/media/receipts/1/receipt.jpg",
- "description": "Payment receipt",
- "uploaded_at": "2025-12-03T10:30:00Z"
- }
- ]
- }
- }
- ),
- 400: "Invalid data or transaction cannot accept receipts",
- 403: "Permission denied",
- 404: "Transaction not found"
- }
- )
- def post(self, request, transaction_id):
- try:
- transaction = TransactionParticipant.objects.get(pk=transaction_id, is_deleted=False)
- except TransactionParticipant.DoesNotExist:
- raise AppAPIException({'message': "Transaction not found"})
-
- # Check if user owns this transaction
- if transaction.user != request.user:
- raise AppAPIException(
- detail={'message': "You don't have permission to upload receipts for this transaction"},
- status_code=status.HTTP_403_FORBIDDEN
- )
-
- # Check if transaction is in a state that can accept receipts
- if transaction.status not in [
- TransactionParticipant.TransactionStatus.PENDING,
- TransactionParticipant.TransactionStatus.WAITING_APPROVAL
- ]:
- raise AppAPIException(
- detail={'message': f"Cannot upload receipts for transaction with status '{transaction.status}'"},
- status_code=status.HTTP_400_BAD_REQUEST
- )
-
- # Validate using serializer
- serializer = UploadReceiptsSerializer(data=request.data, context={'request': request})
- serializer.is_valid(raise_exception=True)
-
- # Create receipt records
- receipts = []
- description = serializer.validated_data.get('description', '')
- file_urls = serializer.validated_data.get('files', [])
-
- for file_url in file_urls:
- receipt = TransactionReceipt.objects.create(
- transaction=transaction,
- file=file_url,
- description=description
- )
- receipts.append(receipt)
-
- # Update transaction status to waiting_approval
- transaction.status = TransactionParticipant.TransactionStatus.WAITING_APPROVAL
- transaction.save()
-
- # Serialize receipts for response
- receipts_data = TransactionReceiptSerializer(receipts, many=True, context={'request': request}).data
-
- return Response({
- 'success': True,
- 'message': 'Receipts uploaded successfully',
- 'transaction_status': transaction.status,
- 'receipts': receipts_data
- }, status=status.HTTP_201_CREATED)
-
-
-class TransactionReceiptsListView(generics.ListAPIView):
- serializer_class = TransactionReceiptSerializer
- permission_classes = [IsAuthenticated]
- authentication_classes = [TokenAuthentication]
- pagination_class = StandardResultsSetPagination
-
- @swagger_auto_schema(
- operation_summary="List receipts for a transaction",
- operation_description=doc_list_transaction_receipts(),
- tags=['Imam-Javad - Transaction'],
- manual_parameters=[
- openapi.Parameter(
- 'transaction_id',
- openapi.IN_PATH,
- description="Transaction ID",
- type=openapi.TYPE_INTEGER,
- required=True
- )
- ],
- responses={
- 200: TransactionReceiptSerializer(many=True),
- 403: "Permission denied",
- 404: "Transaction not found"
- }
- )
- def get(self, request, *args, **kwargs):
- return self.list(request, *args, **kwargs)
-
- def get_queryset(self):
- transaction_id = self.kwargs.get('transaction_id')
-
- try:
- transaction = TransactionParticipant.objects.get(pk=transaction_id, is_deleted=False)
- except TransactionParticipant.DoesNotExist:
- raise AppAPIException({'message': "Transaction not found"})
-
- # Check if user owns this transaction
- if transaction.user != self.request.user:
- raise AppAPIException(
- detail={'message': "You don't have permission to view receipts for this transaction"},
- status_code=status.HTTP_403_FORBIDDEN
- )
-
- return TransactionReceipt.objects.filter(transaction=transaction)
-
-
-# =============================================================================
-# Admin Panel ViewSet
-# =============================================================================
-
-from rest_framework.viewsets import ModelViewSet
-from apps.transaction.serializers import AdminTransactionSerializer
-from apps.account.permissions import IsPanelUser, IsSuperAdmin
-from django.db.models import Q
-from utils.pagination import StandardResultsSetPagination
-
-
-def is_professor(request):
- return getattr(request.user, 'user_type', None) == 'professor'
-
-
-class AdminTransactionViewSet(ModelViewSet):
- """
- Admin-only viewset for viewing and updating transactions.
- Only GET (list/retrieve) and PATCH (update status) are allowed.
- Professors only see transactions for their own courses.
- """
- queryset = TransactionParticipant.objects.all()
- serializer_class = AdminTransactionSerializer
-
- permission_classes = [IsAuthenticated, IsSuperAdmin]
- authentication_classes = [TokenAuthentication]
- pagination_class = StandardResultsSetPagination
- http_method_names = ['get', 'patch', 'head', 'options']
-
- def get_queryset(self):
- queryset = TransactionParticipant.objects.all().select_related('user', 'course').prefetch_related('receipts', 'participant_infos')
-
- # Professors can only see transactions for their own courses
- if is_professor(self.request):
- queryset = queryset.filter(course__professors=self.request.user)
-
- # Search
- search_query = self.request.query_params.get('search', None)
- has_search = False
- if search_query:
- has_search = True
- matching_course_ids = list(
- Course.objects.filter(
- Q(title__icontains=search_query) |
- Q(slug__icontains=search_query)
- ).values_list("id", flat=True)
- )
- if not matching_course_ids:
- normalized_search = search_query.lower().strip()
- matching_course_ids = [
- course.id
- for course in Course.objects.all().only("id", "title", "slug")
- if normalized_search in extract_text_from_json(course.title).lower()
- or normalized_search in extract_text_from_json(course.slug).lower()
- ]
-
- from django.db.models import Case, When, Value, IntegerField
- queryset = queryset.filter(
- Q(user__fullname__icontains=search_query) |
- Q(user__email__icontains=search_query) |
- Q(course_id__in=matching_course_ids)
- ).annotate(
- search_relevance=Case(
- When(user__fullname__icontains=search_query, then=Value(3)),
- When(user__email__icontains=search_query, then=Value(2)),
- When(course_id__in=matching_course_ids, then=Value(1)),
- default=Value(0),
- output_field=IntegerField()
- )
- )
-
- # Status filter
- status_param = self.request.query_params.get('status', None)
- if status_param:
- queryset = queryset.filter(status=status_param)
-
- # Course filter
- course_id = self.request.query_params.get('course', None)
- if course_id:
- queryset = queryset.filter(course_id=course_id)
-
- # Payment method filter
- payment_method = self.request.query_params.get('payment_method', None)
- if payment_method:
- queryset = queryset.filter(payment_method=payment_method)
-
- transaction_date = self.request.query_params.get('date', None)
- if transaction_date:
- queryset = queryset.filter(created_at__date=transaction_date)
-
- date_after = self.request.query_params.get('date_after', None)
- if date_after:
- queryset = queryset.filter(created_at__date__gte=date_after)
-
- date_before = self.request.query_params.get('date_before', None)
- if date_before:
- queryset = queryset.filter(created_at__date__lte=date_before)
-
- if has_search:
- return queryset.order_by('-search_relevance', '-created_at')
- return queryset.order_by('-created_at')
diff --git a/config/middleware/site_middleware.py b/config/middleware/site_middleware.py
index 9e3a169..24cc65d 100644
--- a/config/middleware/site_middleware.py
+++ b/config/middleware/site_middleware.py
@@ -20,13 +20,6 @@ class SiteMiddleware:
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'
-
+ # Force Dovoodi URL configuration for all domains
+ request.urlconf = 'config.urls_dovoodi'
return self.get_response(request)
diff --git a/config/settings/base.py b/config/settings/base.py
index 23fd839..d7525f9 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -46,11 +46,6 @@ X_FRAME_OPTIONS = 'SAMEORIGIN'
LOCAL_APPS = [
'apps.account.apps.AccountConfig',
'apps.api.apps.ApiConfig',
- 'apps.course.apps.CourseConfig',
- 'apps.chat.apps.ChatConfig',
- 'apps.quiz.apps.QuizConfig',
- 'apps.transaction.apps.TransactionConfig',
- 'apps.certificate.apps.CertificateConfig',
'apps.hadis.apps.HadisConfig',
'apps.library.apps.LibraryConfig',
'apps.video.apps.VideoConfig',
@@ -58,7 +53,6 @@ LOCAL_APPS = [
'apps.bookmark.apps.BookmarkConfig',
'apps.article.apps.ArticleConfig',
'apps.dobodbi_calendar.apps.DobodbiCalendarConfig',
- 'apps.blog.apps.BlogConfig',
'apps.agent.apps.AgentConfig',
'dynamic_preferences',
'apps.geolocation_package',
@@ -391,21 +385,7 @@ LOGIN_REDIRECT_URL = reverse_lazy("home")
######################################################################
from utils.admin import admin_url_generator , is_dovoodi_panel , is_main_panel
-# --- ENHANCED DYNAMIC BADGE FUNCTION ---
-def get_pending_certificates_badge(request):
- try:
- from apps.certificate.models import Certificate
- qs = Certificate.objects.filter(status='pending')
-
- # If user is a professor (not staff/admin), only show their pending certificates
- if request.user.is_authenticated and not request.user.is_staff and not getattr(request.user, 'is_superuser', False):
- qs = qs.filter(course__professor=request.user)
-
- count = qs.count()
- return str(count) if count > 0 else None
- except Exception as e:
- print(f"Badge Error: {e}") # Fails safely in terminal if DB isn't migrated yet
- return None
+# get_pending_certificates_badge removed
UNFOLD = {
# "SITE_TITLE": _("Imam Jawad Admin"),
@@ -536,78 +516,7 @@ UNFOLD = {
},
],
},
- {
- "page": "courses",
- "models": [
- "course.course",
- "course.courselesson",
- "course.courseglossary",
- "course.courseattachment",
- "quiz.quiz",
- "quiz.quizparticipant",
- ],
- "items": [
- {
- "title": _("Courses"),
- "icon": "school",
- "link": lambda request: admin_url_generator(request, "course_course_changelist"),
- "active": lambda request: request.path.startswith(str(lambda request: admin_url_generator(request, "course_course_changelist"))),
- },
- {
- "title": _("Course Lessons"),
- "icon": "menu_book",
- "link": lambda request: admin_url_generator(request, "course_courselesson_changelist"),
- "active": lambda request: request.path.startswith(admin_url_generator(request, "course_courselesson_changelist")),
- },
- {
- "title": _("Course Attachments"),
- "icon": "attach_file",
- "link": lambda request: admin_url_generator(request, "course_courseattachment_changelist"),
- "active": lambda request: request.path.startswith(admin_url_generator(request, "course_courseattachment_changelist")),
- },
- {
- "title": _("Course Glossary"),
- "icon": "book",
- "link": lambda request: admin_url_generator(request, "course_courseglossary_changelist"),
- "active": lambda request: request.path.startswith(admin_url_generator(request, "course_courseglossary_changelist")),
- },
- {
- "title": _("Quizzes"),
- "icon": "quiz",
- "link": lambda request: admin_url_generator(request, "quiz_quiz_changelist"),
- "active": lambda request: request.path.startswith(admin_url_generator(request, "quiz_quiz_changelist")),
- },
-
- ],
- },
- {
- "page": "course_online",
- "models": [
- "course.courselivesession",
- "course.livesessionuser",
- "course.livesessionrecording",
- ],
- "items": [
- {
- "title": _("Live Sessions"),
- "icon": "video_call",
- "link": lambda request: admin_url_generator(request, "course_courselivesession_changelist"),
- "active": lambda request: request.path.startswith(admin_url_generator(request, "course_courselivesession_changelist")),
- },
- {
- "title": _("Session Users"),
- "icon": "groups",
- "link": lambda request: admin_url_generator(request, "course_livesessionuser_changelist"),
- "active": lambda request: request.path.startswith(admin_url_generator(request, "course_livesessionuser_changelist")),
- },
- {
- "title": _("Session Recordings"),
- "icon": "play_circle",
- "link": lambda request: admin_url_generator(request, "course_livesessionrecording_changelist"),
- "active": lambda request: request.path.startswith(admin_url_generator(request, "course_livesessionrecording_changelist")),
- },
- ],
- },
+ # Courses and online course tabs removed
{
"page": "podcast",
"models": ["podcast.podcastcollection", "podcast.pinnedpodcastcollection", "podcast.middlepodcastcollection"],
@@ -681,74 +590,7 @@ UNFOLD = {
},
],
},
- # --- 3. ACADEMICS (Collapsible) ---
- {
- "title": _("Courses"),
- "collapsible": True,
- "separator": True,
- "permission": is_main_panel,
- "items": [
- {
- "title": _("Categories"),
- "icon": "category",
- "link": lambda request: admin_url_generator(request, "course_coursecategory_changelist"),
- "permission": is_main_panel,
- },
- {
- "title": _("Courses"),
- "icon": "school",
- "link": lambda request: admin_url_generator(request, "course_course_changelist"),
- "permission": is_main_panel,
- },
- {
- "title": _("Live Sessions"),
- "icon": "video_camera_front",
- "link": lambda request: admin_url_generator(request, "course_courselivesession_changelist"),
- "permission": is_main_panel,
- },
- {
- "title": _("Certificates"),
- "icon": "workspace_premium",
- "link": lambda request: admin_url_generator(request, "certificate_certificate_changelist"),
- "permission": is_main_panel,
- "badge": "utils.admin.get_pending_certificates_badge",
- },
- ]
- },
-
- # --- 4. ASSESSMENTS ---
- {
- "title": _(""),
- "separator": True,
- "permission": is_main_panel,
- "items": [
- {
- "title": _("Transactions"),
- "icon": "payments",
- "link": lambda request: admin_url_generator(request, "transaction_transactionparticipant_changelist"),
- "permission": is_main_panel,
- },
- {
- "title": _("Chat Rooms"),
- "icon": "forum",
- "link": lambda request: admin_url_generator(request, "chat_roommessage_changelist"),
- "permission": is_main_panel,
- },
- {
- "title": _("Blogs"),
- "icon": "article",
- "link": lambda request: admin_url_generator(request, "blog_blog_changelist"),
- "permission": is_main_panel,
- },
- {
- "title": _("Live Chat"),
- "icon": "forum", # آیکون چت
- "link": lambda request: admin_url_generator(request, "live_chat_dashboard"),
- "permission": is_main_panel,
- "badge": "utils.admin.get_unread_chat_messages_badge",
- },
- ]
- },
+ # Academics and Assessments sidebar sections removed
# --- DOVOODI SECTIONS ---
{
"title": _("Libraries"),
diff --git a/config/urls.py b/config/urls.py
index 0cdf656..7ede0fe 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -38,7 +38,7 @@ import requests
from filer import views
# Import custom API views
-from apps.api.views import CustomAPIDocumentationView, CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth ,admin_dashboard, ProfessorDashboardStatsView
+from apps.api.views import CustomAPIDocumentationView, CustomSwaggerView, SwaggerTokenAuthView, clear_swagger_auth
from apps.api.permissions import IsAdminOrSwaggerToken
from apps.api.decorators import swagger_auth_required
@@ -72,10 +72,6 @@ api_patterns = [
path('list/', include('apps.api.urls')),
path('account/', include('apps.account.urls')),
- path('courses/', include('apps.course.urls')),
- path('quiz/', include('apps.quiz.urls')),
- path('transaction/', include('apps.transaction.urls')),
- path('certificates/', include('apps.certificate.urls')),
path('hadis/', include('apps.hadis.urls')),
path('library/', include('apps.library.urls')),
path('videos/', include('apps.video.urls')),
@@ -84,10 +80,6 @@ api_patterns = [
path('agent/', include('apps.agent.urls')),
path('bookmarks/', include('apps.bookmark.urls')),
path('calendar/', include('apps.dobodbi_calendar.urls')),
- path('blog/', include('apps.blog.urls')),
- path('chat/', include('apps.chat.urls')),
- path('admin/dashboard-stats/' , admin_dashboard.AdminDashboardStatsView.as_view()),
- path('professor/dashboard-stats/' , ProfessorDashboardStatsView.as_view()),
path('settings/', include('dynamic_preferences.urls')),
diff --git a/utils/admin.py b/utils/admin.py
index 097ec96..9b7890e 100644
--- a/utils/admin.py
+++ b/utils/admin.py
@@ -32,6 +32,41 @@ def slugify(value):
return _unicode_slugify(value)
# -------------------------------------------------------
+# Unfold Imports
+import json
+import random
+from functools import lru_cache
+
+from django import forms
+from django.conf import settings
+from django.contrib.humanize.templatetags.humanize import intcomma
+from django.urls import reverse, path
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import RedirectView
+from django.utils.translation import get_language
+from django.http import JsonResponse
+from django.utils.html import format_html
+from django.views.decorators.http import require_POST
+
+# --- AGGRESSIVE UNICODE PATCH FOR UNFOLD 0.64.1 TABS ---
+import django.utils.text
+from django.template.defaultfilters import register
+
+_original_slugify = django.utils.text.slugify
+
+def _unicode_slugify(value, allow_unicode=True):
+ # We forcefully pass allow_unicode=True to preserve Russian characters for tab IDs
+ return _original_slugify(value, allow_unicode=True)
+
+django.utils.text.slugify = _unicode_slugify
+
+# We must also override the template filter explicitly because Unfold calls it directly in 0.64.1
+@register.filter(is_safe=True)
+def slugify(value):
+ return _unicode_slugify(value)
+# -------------------------------------------------------
+
# Unfold Imports
from unfold.sites import UnfoldAdminSite
@@ -41,29 +76,21 @@ from unfold.sites import UnfoldAdminSite
def is_dovoodi_panel(request):
"""
- Returns True if the user is accessing the Dovoodi admin panel.
- Checks if 'dovodi' or 'dovoodi' is in the host domain, or if '/dovoodi/'
- exists anywhere in the path.
+ Returns True unconditionally as the system is now locked to Dovoodi domain features.
"""
- host = request.get_host()
- return 'dovodi' in host or 'dovoodi' in host or '/dovoodi/' in request.path
+ return True
def is_main_panel(request):
"""Returns True if the user is accessing the Main (Imam Javad) admin panel."""
- return not is_dovoodi_panel(request)
+ return False
def admin_url_generator(request, url_name):
"""
Dynamically generates admin URLs based on the current active panel.
"""
- _ = project_admin_site.urls
_ = dovoodi_admin_site.urls
- if is_dovoodi_panel(request):
- namespace = 'dovoodi_admin'
- else:
- namespace = 'imam_javad_admin'
-
+ namespace = 'dovoodi_admin'
full_view_name = f"{namespace}:{url_name}"
try:
@@ -72,50 +99,10 @@ def admin_url_generator(request, url_name):
return "#"
def get_pending_certificates_badge(request):
- """Generates the integer for the sidebar badge"""
- try:
- from apps.certificate.models import Certificate
- qs = Certificate.objects.filter(status='pending')
-
- if request.user.is_authenticated and not request.user.is_staff and not getattr(request.user, 'is_superuser', False):
- qs = qs.filter(course__professor=request.user)
-
- count = qs.count()
- return count if count > 0 else None
- except Exception as e:
- print(f"Badge Error: {e}")
- return None
+ return None
def get_unread_chat_messages_badge(request):
- """Generates the integer for the Live Chat sidebar badge"""
- try:
- from apps.chat.models import RoomMessage, ChatMessage
- from django.db.models import Q
-
- user = request.user
- if not user.is_authenticated:
- return None
-
- personal_q = Q(initiator=user) | Q(recipient=user)
-
- if user.is_superuser or getattr(user, 'has_role', lambda x: False)('admin') or getattr(user, 'has_role', lambda x: False)('super_admin') or getattr(user, 'has_role', lambda x: False)('consultant'):
- room_filter = personal_q | Q(room_type=RoomMessage.RoomTypeChoices.GROUP)
- else:
- room_filter = personal_q | \
- Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__professor=user) | \
- Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__participants__student=user, course__participants__is_active=True)
-
- rooms = RoomMessage.objects.filter(room_filter)
-
- count = ChatMessage.objects.filter(
- room__in=rooms,
- is_read=False
- ).exclude(sender=user).count()
-
- return count if count > 0 else None
- except Exception as e:
- print(f"Chat Badge Error: {e}")
- return None
+ return None
def variables(request):
return {"plausible_domain": getattr(settings, 'PLAUSIBLE_DOMAIN', '')}
@@ -187,19 +174,8 @@ class FormulaAdminSite(UnfoldAdminSite):
def get_urls(self):
urls = super().get_urls()
- from apps.chat.admin_views import live_chat_dashboard_view, api_get_rooms, api_get_messages, api_send_message, api_create_or_get_room , api_get_users
custom_urls = [
path('toggle_sidebar/', toggle_sidebar, name='toggle_sidebar'),
-
- # روت اصلی صفحه:
- path('live-chat/', self.admin_view(live_chat_dashboard_view), name='live_chat_dashboard'),
-
- # روتهای API برای Polling جاوااسکریپت:
- path('live-chat/api/rooms/', self.admin_view(api_get_rooms), name='live_chat_api_rooms'),
- path('live-chat/api/messages//', self.admin_view(api_get_messages), name='live_chat_api_messages'),
- path('live-chat/api/send//', self.admin_view(api_send_message), name='live_chat_api_send'),
- path('live-chat/api/create-or-get-room/', self.admin_view(api_create_or_get_room), name='live_chat_api_create_room'),
- path('live-chat/api/get-users/', self.admin_view(api_get_users), name='live_chat_api_users'),
]
return custom_urls + urls
@@ -500,8 +476,6 @@ class HomeView(RedirectView):
def dashboard_callback(request, context):
from django.apps import apps
- from django.db.models import Count, Sum
- from django.utils import timezone
if context is None:
context = {}
@@ -510,118 +484,28 @@ def dashboard_callback(request, context):
"navigation": [{"title": _("Dashboard"), "link": "/", "active": True}],
"kpi": [],
"top_courses": [],
- "tx_stats": {}, # New dict for our multi-segment donut chart
+ "tx_stats": {},
})
if not hasattr(request, "user") or not request.user.is_authenticated:
return context
- # -------------------------------------------------------------
- # 1. IMAM JAVAD PANEL STATS
- # -------------------------------------------------------------
- if is_main_panel(request):
- try:
- StudentUser = apps.get_model('account', 'StudentUser')
- ProfessorUser = apps.get_model('account', 'ProfessorUser')
- Course = apps.get_model('course', 'Course')
- Blog = apps.get_model('blog', 'Blog')
- Certificate = apps.get_model('certificate', 'Certificate')
- Transaction = apps.get_model('transaction', 'TransactionParticipant')
-
- # --- 1. Basic Counts ---
- active_students = StudentUser.objects.filter(is_active=True).count()
- active_professors = ProfessorUser.objects.filter(is_active=True).count()
- active_courses = Course.objects.exclude(status='inactive').count()
- total_blogs = Blog.objects.count()
-
- # --- 2. Certificates ---
- certs_qs = Certificate.objects.filter(status='pending')
- if not request.user.is_staff and not getattr(request.user, 'is_superuser', False):
- certs_qs = certs_qs.filter(course__professor=request.user)
- pending_certs = certs_qs.count()
-
- # --- 3. Revenue (Last 30 Days) ---
- thirty_days_ago = timezone.now() - timezone.timedelta(days=30)
- revenue_data = Transaction.objects.filter(
- status='success',
- created_at__gte=thirty_days_ago
- ).aggregate(Sum('price'))
- revenue = revenue_data['price__sum'] or 0
-
- # --- 4. Transaction Multi-Status Breakdown ---
- total_tx = Transaction.objects.count()
- if total_tx > 0:
- success_count = Transaction.objects.filter(status='success').count()
- pending_count = Transaction.objects.filter(status='pending').count()
- waiting_count = Transaction.objects.filter(status='waiting_approval').count()
- failed_count = Transaction.objects.filter(status='failed').count()
-
- # Calculate percentages
- pct_success = (success_count / total_tx) * 100
- pct_pending = (pending_count / total_tx) * 100
- pct_waiting = (waiting_count / total_tx) * 100
- pct_failed = (failed_count / total_tx) * 100
-
- # Calculate SVG Dash Offsets (Accumulative for overlapping circles)
- offset_success = 100 - pct_success
- offset_pending = 100 - (pct_success + pct_pending)
- offset_waiting = 100 - (pct_success + pct_pending + pct_waiting)
- offset_failed = 100 - (pct_success + pct_pending + pct_waiting + pct_failed) # Should be 0
- else:
- pct_success = pct_pending = pct_waiting = pct_failed = 0
- offset_success = offset_pending = offset_waiting = offset_failed = 100
-
- context["tx_stats"] = {
- "total": total_tx,
- "pct_success": round(pct_success, 1),
- "pct_pending": round(pct_pending, 1),
- "pct_waiting": round(pct_waiting, 1),
- "pct_failed": round(pct_failed, 1),
- # Format as strings to prevent Django from converting dots to commas in Russian
- "offset_success": f"{offset_success:.2f}",
- "offset_pending": f"{offset_pending:.2f}",
- "offset_waiting": f"{offset_waiting:.2f}",
- }
+ try:
+ Video = apps.get_model('video', 'Video')
+ Book = apps.get_model('library', 'Book')
+ Article = apps.get_model('article', 'Article')
+ Hadis = apps.get_model('hadis', 'Hadis')
+ Podcast = apps.get_model('podcast', 'Podcast')
+
+ total_multimedia = Video.objects.count() + Podcast.objects.count()
+ total_reading = Book.objects.count() + Article.objects.count()
- # --- 5. Top 5 Courses ---
- top_courses = Course.objects.select_related('professor').annotate(
- participant_count=Count('participants')
- ).order_by('-participant_count')[:5]
-
- context["top_courses"] = top_courses
-
- # --- Map to KPIs ---
- context["kpi"] = [
- {"title": _("Active Students"), "metric": f"{active_students:,}"},
- {"title": _("Professors"), "metric": f"{active_professors:,}"},
- {"title": _("Active Courses"), "metric": f"{active_courses:,}"},
- {"title": _("Total Blogs"), "metric": f"{total_blogs:,}"},
- {"title": _("30-Day Revenue"), "metric": f"${revenue:,.2f}", "footer": format_html('+ {}', _("Updated Today"))},
- {"title": _("Pending Certificates"), "metric": f"{pending_certs:,}", "footer": format_html('{}', _("Requires Action")) if pending_certs > 0 else ""},
- ]
- except Exception as e:
- print(f"Dashboard KPI Error (Main Panel): {e}")
-
- # -------------------------------------------------------------
- # 2. DOVOODI PANEL STATS
- # -------------------------------------------------------------
- else:
- try:
- Video = apps.get_model('video', 'Video')
- Book = apps.get_model('library', 'Book')
- Article = apps.get_model('article', 'Article')
- Hadis = apps.get_model('hadis', 'Hadis')
- Podcast = apps.get_model('podcast', 'Podcast')
-
- total_multimedia = Video.objects.count() + Podcast.objects.count()
- total_reading = Book.objects.count() + Article.objects.count()
-
- context["kpi"] = [
- {"title": _("Hadith Database"), "metric": f"{Hadis.objects.count():,}"},
- {"title": _("Books & Articles"), "metric": f"{total_reading:,}"},
- {"title": _("Multimedia"), "metric": f"{total_multimedia:,}"},
- ]
- except Exception as e:
- print(f"Dashboard KPI Error (Dovoodi Panel): {e}")
-
+ context["kpi"] = [
+ {"title": _("Hadith Database"), "metric": f"{Hadis.objects.count():,}"},
+ {"title": _("Books & Articles"), "metric": f"{total_reading:,}"},
+ {"title": _("Multimedia"), "metric": f"{total_multimedia:,}"},
+ ]
+ except Exception as e:
+ print(f"Dashboard KPI Error (Dovoodi Panel): {e}")
+
return context