From f7e015f517b0e7b22414edff6870d2879e1cf657 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sun, 12 Jul 2026 12:52:28 +0330 Subject: [PATCH] dovodi codebase splitting --- apps/account/admin/user.py | 269 +-- .../management/commands/migrate_user_roles.py | 158 -- apps/account/notification_service.py | 144 +- apps/account/tasks.py | 237 --- apps/account/tests/notifications/__init__.py | 1 - .../test_communication_notifications.py | 242 --- .../notifications/test_live_notifications.py | 122 -- .../tests/notifications/test_notifications.py | 316 ---- .../notifications/test_quiz_notifications.py | 176 -- .../test_retention_notifications.py | 154 -- .../test_transaction_notifications.py | 103 - apps/account/tests/test_multiple_roles.py | 240 --- apps/api/views/__init__.py | 4 - apps/api/views/admin_dashboard.py | 163 -- apps/api/views/professor_dashboard.py | 139 -- apps/blog/__init__.py | 0 apps/blog/admin.py | 130 -- apps/blog/apps.py | 7 - .../commands/fix_empty_blog_fields.py | 30 - .../management/commands/seed_blog_data.py | 367 ---- apps/blog/migrations/0001_initial.py | 238 --- ...0002_alter_blog_slogan_alter_blog_title.py | 23 - ...r_blog_slug_alter_blog_summary_and_more.py | 76 - apps/blog/migrations/__init__.py | 0 apps/blog/models.py | 207 -- apps/blog/serializers.py | 142 -- apps/blog/serializers_admin.py | 87 - apps/blog/tests.py | 3 - apps/blog/urls.py | 39 - apps/blog/views.py | 183 -- apps/blog/views_admin.py | 128 -- apps/certificate/__init__.py | 0 apps/certificate/admin.py | 81 - apps/certificate/apps.py | 9 - apps/certificate/migrations/0001_initial.py | 69 - .../0002_alter_certificate_course_and_more.py | 36 - .../0003_alter_certificate_student.py | 21 - apps/certificate/migrations/__init__.py | 0 apps/certificate/models.py | 28 - apps/certificate/serializers.py | 73 - apps/certificate/signals.py | 43 - apps/certificate/tests.py | 3 - apps/certificate/urls.py | 15 - apps/certificate/views.py | 151 -- apps/chat/__init__.py | 0 apps/chat/admin.py | 410 ---- apps/chat/admin_views.py | 285 --- apps/chat/apps.py | 9 - apps/chat/management/__init__.py | 1 - apps/chat/management/commands/README.md | 62 - apps/chat/management/commands/__init__.py | 1 - .../management/commands/clear_chat_data.py | 79 - apps/chat/migrations/0001_initial.py | 221 --- .../migrations/0002_roommessage_is_locked.py | 18 - ...0003_alter_chatmessage_options_and_more.py | 35 - apps/chat/migrations/0004_adminroomseen.py | 30 - apps/chat/migrations/__init__.py | 0 apps/chat/models.py | 216 --- apps/chat/signals.py | 100 - apps/chat/tests.py | 3 - apps/chat/urls.py | 6 - apps/chat/views.py | 26 - apps/course/__init__.py | 0 apps/course/access.py | 18 - apps/course/admin/__init__.py | 4 - apps/course/admin/course.py | 603 ------ apps/course/admin/lesson.py | 120 -- apps/course/admin/live_session.py | 177 -- apps/course/admin/participant.py | 33 - apps/course/admin/professor_base.py | 181 -- apps/course/apps.py | 9 - apps/course/data/category.json | 42 - apps/course/doc.py | 480 ----- apps/course/management/__init__.py | 0 apps/course/management/commands/__init__.py | 0 .../management/commands/assign_professors.py | 0 .../auto_close_professorless_live_sessions.py | 127 -- .../management/commands/clear_course_data.py | 134 -- .../commands/ensure_course_rooms.py | 63 - .../commands/fill_english_translations.py | 65 - .../commands/migrate_lessons_to_chapters.py | 43 - .../redistribute_lessons_to_chapters.py | 117 -- .../commands/set_default_category_icons.py | 16 - .../smoke_test_online_class_webhooks.py | 517 ----- .../commands/sync_course_lessons_count.py | 42 - .../commands/sync_quiz_lesson_numbers.py | 86 - apps/course/migrations/0001_initial.py | 1007 ---------- ...hat_group_lock_course_is_prof_chat_lock.py | 23 - ...ck_course_is_group_chat_locked_and_more.py | 23 - ...alter_lessoncompletion_options_and_more.py | 58 - .../0005_alter_course_discount_percentage.py | 19 - ...fessor_alter_course_video_file_and_more.py | 31 - ...ter_alter_courselesson_options_and_more.py | 65 - ..._course_cour_status_57ffd9_idx_and_more.py | 247 --- .../0009_alter_course_description_and_more.py | 38 - .../migrations/0010_course_rub_prices.py | 21 - .../migrations/0011_coursecategory_icon.py | 16 - .../0012_courselivesession_recording_title.py | 18 - ...013_courselivesession_auto_close_fields.py | 38 - ...014_courselivesession_web_egress_fields.py | 78 - ...course_cour_ended_a_32eaaa_idx_and_more.py | 28 - ...move_course_professor_course_professors.py | 23 - .../0017_alter_coursecategory_options.py | 17 - ...urse_notified_new_registration_and_more.py | 28 - apps/course/migrations/__init__.py | 0 apps/course/models/__init__.py | 4 - apps/course/models/course.py | 699 ------- apps/course/models/lesson.py | 188 -- apps/course/models/live_session.py | 270 --- apps/course/models/participant.py | 34 - apps/course/serializers/__init__.py | 5 - apps/course/serializers/admin.py | 672 ------- apps/course/serializers/course.py | 729 -------- apps/course/serializers/lesson.py | 176 -- apps/course/serializers/online.py | 71 - apps/course/serializers/participant.py | 17 - apps/course/serializers/professor.py | 29 - apps/course/services/__init__.py | 8 - apps/course/services/api.md | 1660 ----------------- apps/course/services/plugnmeet.py | 155 -- apps/course/services/web_egress.py | 192 -- apps/course/signals.py | 529 ------ .../templates/course/add_student_form.html | 29 - apps/course/tests.py | 3 - apps/course/tests/__init__.py | 1 - .../course/tests/test_course_access_guards.py | 133 -- apps/course/tests/test_lessons_count_sync.py | 174 -- apps/course/tests/test_live_session_api.py | 273 --- .../tests/test_live_session_subjects.py | 133 -- apps/course/tests/test_multiple_roles_api.py | 254 --- apps/course/tests/test_my_courses_api.py | 145 -- apps/course/tests/test_professor_api.py | 113 -- .../course/tests/test_webhook_live_session.py | 201 -- apps/course/token-join-guide.md | 312 ---- apps/course/urls.py | 58 - apps/course/views/__init__.py | 7 - apps/course/views/admin.py | 827 -------- apps/course/views/course.py | 1207 ------------ apps/course/views/lesson.py | 379 ---- apps/course/views/live_session.py | 1299 ------------- apps/course/views/participant.py | 79 - apps/course/views/professor.py | 206 -- apps/course/views/webhook.py | 491 ----- apps/quiz/__init__.py | 0 apps/quiz/admin/__init__.py | 5 - apps/quiz/admin/participant.py | 111 -- apps/quiz/admin/question.py | 91 - apps/quiz/admin/quiz.py | 194 -- apps/quiz/admin/user_rank_quiz.py | 132 -- apps/quiz/apps.py | 6 - apps/quiz/doc.py | 131 -- apps/quiz/management/__init__.py | 0 apps/quiz/management/commands/__init__.py | 0 .../management/commands/clear_quiz_data.py | 78 - apps/quiz/migrations/0001_initial.py | 220 --- ...articipantanswer_answer_timing_and_more.py | 147 -- apps/quiz/migrations/0003_quiz_course.py | 20 - .../migrations/0004_alter_question_options.py | 17 - .../0005_multilingual_quiz_fields.py | 137 -- ...06_quiz_lesson_number_alter_quiz_lesson.py | 25 - apps/quiz/migrations/__init__.py | 0 apps/quiz/models/__init__.py | 2 - apps/quiz/models/participant.py | 68 - apps/quiz/models/quiz.py | 102 - apps/quiz/serializers/__init__.py | 2 - apps/quiz/serializers/admin.py | 224 --- apps/quiz/serializers/participant.py | 69 - apps/quiz/serializers/quiz.py | 130 -- apps/quiz/tests.py | 3 - apps/quiz/urls.py | 27 - apps/quiz/views.py | 3 - apps/quiz/views/__init__.py | 3 - apps/quiz/views/admin.py | 298 --- apps/quiz/views/participant.py | 25 - apps/quiz/views/quiz.py | 45 - apps/transaction/__init__.py | 0 apps/transaction/admin.py | 165 -- apps/transaction/apps.py | 9 - apps/transaction/doc.py | 711 ------- apps/transaction/management/__init__.py | 1 - .../management/commands/__init__.py | 1 - .../commands/sync_successful_transactions.py | 201 -- apps/transaction/migrations/0001_initial.py | 209 --- ..._alter_participantinfo_options_and_more.py | 40 - ...003_alter_transactionparticipant_status.py | 18 - apps/transaction/migrations/__init__.py | 0 apps/transaction/models.py | 128 -- apps/transaction/serializers.py | 155 -- apps/transaction/signals.py | 123 -- apps/transaction/tests.py | 137 -- apps/transaction/urls.py | 23 - apps/transaction/views.py | 556 ------ config/middleware/site_middleware.py | 11 +- config/settings/base.py | 164 +- config/urls.py | 10 +- utils/admin.py | 234 +-- 196 files changed, 69 insertions(+), 27718 deletions(-) delete mode 100644 apps/account/management/commands/migrate_user_roles.py delete mode 100644 apps/account/tests/notifications/__init__.py delete mode 100644 apps/account/tests/notifications/test_communication_notifications.py delete mode 100644 apps/account/tests/notifications/test_live_notifications.py delete mode 100644 apps/account/tests/notifications/test_notifications.py delete mode 100644 apps/account/tests/notifications/test_quiz_notifications.py delete mode 100644 apps/account/tests/notifications/test_retention_notifications.py delete mode 100644 apps/account/tests/notifications/test_transaction_notifications.py delete mode 100644 apps/account/tests/test_multiple_roles.py delete mode 100644 apps/api/views/admin_dashboard.py delete mode 100644 apps/api/views/professor_dashboard.py delete mode 100644 apps/blog/__init__.py delete mode 100644 apps/blog/admin.py delete mode 100644 apps/blog/apps.py delete mode 100644 apps/blog/management/commands/fix_empty_blog_fields.py delete mode 100644 apps/blog/management/commands/seed_blog_data.py delete mode 100644 apps/blog/migrations/0001_initial.py delete mode 100644 apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py delete mode 100644 apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py delete mode 100644 apps/blog/migrations/__init__.py delete mode 100644 apps/blog/models.py delete mode 100644 apps/blog/serializers.py delete mode 100644 apps/blog/serializers_admin.py delete mode 100644 apps/blog/tests.py delete mode 100644 apps/blog/urls.py delete mode 100644 apps/blog/views.py delete mode 100644 apps/blog/views_admin.py delete mode 100644 apps/certificate/__init__.py delete mode 100644 apps/certificate/admin.py delete mode 100644 apps/certificate/apps.py delete mode 100644 apps/certificate/migrations/0001_initial.py delete mode 100644 apps/certificate/migrations/0002_alter_certificate_course_and_more.py delete mode 100644 apps/certificate/migrations/0003_alter_certificate_student.py delete mode 100644 apps/certificate/migrations/__init__.py delete mode 100644 apps/certificate/models.py delete mode 100644 apps/certificate/serializers.py delete mode 100644 apps/certificate/signals.py delete mode 100644 apps/certificate/tests.py delete mode 100644 apps/certificate/urls.py delete mode 100644 apps/certificate/views.py delete mode 100644 apps/chat/__init__.py delete mode 100644 apps/chat/admin.py delete mode 100644 apps/chat/admin_views.py delete mode 100644 apps/chat/apps.py delete mode 100644 apps/chat/management/__init__.py delete mode 100644 apps/chat/management/commands/README.md delete mode 100644 apps/chat/management/commands/__init__.py delete mode 100644 apps/chat/management/commands/clear_chat_data.py delete mode 100644 apps/chat/migrations/0001_initial.py delete mode 100644 apps/chat/migrations/0002_roommessage_is_locked.py delete mode 100644 apps/chat/migrations/0003_alter_chatmessage_options_and_more.py delete mode 100644 apps/chat/migrations/0004_adminroomseen.py delete mode 100644 apps/chat/migrations/__init__.py delete mode 100644 apps/chat/models.py delete mode 100644 apps/chat/signals.py delete mode 100644 apps/chat/tests.py delete mode 100644 apps/chat/urls.py delete mode 100644 apps/chat/views.py delete mode 100644 apps/course/__init__.py delete mode 100644 apps/course/access.py delete mode 100644 apps/course/admin/__init__.py delete mode 100644 apps/course/admin/course.py delete mode 100644 apps/course/admin/lesson.py delete mode 100644 apps/course/admin/live_session.py delete mode 100644 apps/course/admin/participant.py delete mode 100644 apps/course/admin/professor_base.py delete mode 100644 apps/course/apps.py delete mode 100644 apps/course/data/category.json delete mode 100644 apps/course/doc.py delete mode 100644 apps/course/management/__init__.py delete mode 100644 apps/course/management/commands/__init__.py delete mode 100644 apps/course/management/commands/assign_professors.py delete mode 100644 apps/course/management/commands/auto_close_professorless_live_sessions.py delete mode 100644 apps/course/management/commands/clear_course_data.py delete mode 100644 apps/course/management/commands/ensure_course_rooms.py delete mode 100644 apps/course/management/commands/fill_english_translations.py delete mode 100644 apps/course/management/commands/migrate_lessons_to_chapters.py delete mode 100644 apps/course/management/commands/redistribute_lessons_to_chapters.py delete mode 100644 apps/course/management/commands/set_default_category_icons.py delete mode 100644 apps/course/management/commands/smoke_test_online_class_webhooks.py delete mode 100644 apps/course/management/commands/sync_course_lessons_count.py delete mode 100644 apps/course/management/commands/sync_quiz_lesson_numbers.py delete mode 100644 apps/course/migrations/0001_initial.py delete mode 100644 apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py delete mode 100644 apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py delete mode 100644 apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py delete mode 100644 apps/course/migrations/0005_alter_course_discount_percentage.py delete mode 100644 apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py delete mode 100644 apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py delete mode 100644 apps/course/migrations/0008_remove_course_course_cour_status_57ffd9_idx_and_more.py delete mode 100644 apps/course/migrations/0009_alter_course_description_and_more.py delete mode 100644 apps/course/migrations/0010_course_rub_prices.py delete mode 100644 apps/course/migrations/0011_coursecategory_icon.py delete mode 100644 apps/course/migrations/0012_courselivesession_recording_title.py delete mode 100644 apps/course/migrations/0013_courselivesession_auto_close_fields.py delete mode 100644 apps/course/migrations/0014_courselivesession_web_egress_fields.py delete mode 100644 apps/course/migrations/0015_rename_course_cour_ended_a_0f47b4_idx_course_cour_ended_a_32eaaa_idx_and_more.py delete mode 100644 apps/course/migrations/0016_remove_course_professor_course_professors.py delete mode 100644 apps/course/migrations/0017_alter_coursecategory_options.py delete mode 100644 apps/course/migrations/0018_course_notified_new_registration_and_more.py delete mode 100644 apps/course/migrations/__init__.py delete mode 100644 apps/course/models/__init__.py delete mode 100644 apps/course/models/course.py delete mode 100644 apps/course/models/lesson.py delete mode 100644 apps/course/models/live_session.py delete mode 100644 apps/course/models/participant.py delete mode 100644 apps/course/serializers/__init__.py delete mode 100644 apps/course/serializers/admin.py delete mode 100644 apps/course/serializers/course.py delete mode 100644 apps/course/serializers/lesson.py delete mode 100644 apps/course/serializers/online.py delete mode 100644 apps/course/serializers/participant.py delete mode 100644 apps/course/serializers/professor.py delete mode 100644 apps/course/services/__init__.py delete mode 100644 apps/course/services/api.md delete mode 100644 apps/course/services/plugnmeet.py delete mode 100644 apps/course/services/web_egress.py delete mode 100644 apps/course/signals.py delete mode 100644 apps/course/templates/course/add_student_form.html delete mode 100644 apps/course/tests.py delete mode 100644 apps/course/tests/__init__.py delete mode 100644 apps/course/tests/test_course_access_guards.py delete mode 100644 apps/course/tests/test_lessons_count_sync.py delete mode 100644 apps/course/tests/test_live_session_api.py delete mode 100644 apps/course/tests/test_live_session_subjects.py delete mode 100644 apps/course/tests/test_multiple_roles_api.py delete mode 100644 apps/course/tests/test_my_courses_api.py delete mode 100644 apps/course/tests/test_professor_api.py delete mode 100644 apps/course/tests/test_webhook_live_session.py delete mode 100644 apps/course/token-join-guide.md delete mode 100644 apps/course/urls.py delete mode 100644 apps/course/views/__init__.py delete mode 100644 apps/course/views/admin.py delete mode 100644 apps/course/views/course.py delete mode 100644 apps/course/views/lesson.py delete mode 100644 apps/course/views/live_session.py delete mode 100644 apps/course/views/participant.py delete mode 100644 apps/course/views/professor.py delete mode 100644 apps/course/views/webhook.py delete mode 100644 apps/quiz/__init__.py delete mode 100644 apps/quiz/admin/__init__.py delete mode 100644 apps/quiz/admin/participant.py delete mode 100644 apps/quiz/admin/question.py delete mode 100644 apps/quiz/admin/quiz.py delete mode 100644 apps/quiz/admin/user_rank_quiz.py delete mode 100644 apps/quiz/apps.py delete mode 100644 apps/quiz/doc.py delete mode 100644 apps/quiz/management/__init__.py delete mode 100644 apps/quiz/management/commands/__init__.py delete mode 100644 apps/quiz/management/commands/clear_quiz_data.py delete mode 100644 apps/quiz/migrations/0001_initial.py delete mode 100644 apps/quiz/migrations/0002_alter_participantanswer_answer_timing_and_more.py delete mode 100644 apps/quiz/migrations/0003_quiz_course.py delete mode 100644 apps/quiz/migrations/0004_alter_question_options.py delete mode 100644 apps/quiz/migrations/0005_multilingual_quiz_fields.py delete mode 100644 apps/quiz/migrations/0006_quiz_lesson_number_alter_quiz_lesson.py delete mode 100644 apps/quiz/migrations/__init__.py delete mode 100644 apps/quiz/models/__init__.py delete mode 100644 apps/quiz/models/participant.py delete mode 100644 apps/quiz/models/quiz.py delete mode 100644 apps/quiz/serializers/__init__.py delete mode 100644 apps/quiz/serializers/admin.py delete mode 100644 apps/quiz/serializers/participant.py delete mode 100644 apps/quiz/serializers/quiz.py delete mode 100644 apps/quiz/tests.py delete mode 100644 apps/quiz/urls.py delete mode 100644 apps/quiz/views.py delete mode 100644 apps/quiz/views/__init__.py delete mode 100644 apps/quiz/views/admin.py delete mode 100644 apps/quiz/views/participant.py delete mode 100644 apps/quiz/views/quiz.py delete mode 100644 apps/transaction/__init__.py delete mode 100644 apps/transaction/admin.py delete mode 100644 apps/transaction/apps.py delete mode 100644 apps/transaction/doc.py delete mode 100644 apps/transaction/management/__init__.py delete mode 100644 apps/transaction/management/commands/__init__.py delete mode 100644 apps/transaction/management/commands/sync_successful_transactions.py delete mode 100644 apps/transaction/migrations/0001_initial.py delete mode 100644 apps/transaction/migrations/0002_alter_participantinfo_options_and_more.py delete mode 100644 apps/transaction/migrations/0003_alter_transactionparticipant_status.py delete mode 100644 apps/transaction/migrations/__init__.py delete mode 100644 apps/transaction/models.py delete mode 100644 apps/transaction/serializers.py delete mode 100644 apps/transaction/signals.py delete mode 100644 apps/transaction/tests.py delete mode 100644 apps/transaction/urls.py delete mode 100644 apps/transaction/views.py 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( - """ -
- {} - - visibility - -
- """, - 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( - """ -
- {} - - visibility - -
- """, - 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 %} -
-
- {% csrf_token %} - - {% for field in form %} - {% include "unfold/helpers/field.html" with field=field %} - {% endfor %} -
- -
- {% component "unfold/components/button.html" with submit=1 %} - {% trans "Submit form" %} - {% endcomponent %} -
-
-{% 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