Browse Source

dovodi codebase splitting

master
Mohsen Taba 1 week ago
parent
commit
f7e015f517
  1. 269
      apps/account/admin/user.py
  2. 158
      apps/account/management/commands/migrate_user_roles.py
  3. 144
      apps/account/notification_service.py
  4. 237
      apps/account/tasks.py
  5. 1
      apps/account/tests/notifications/__init__.py
  6. 242
      apps/account/tests/notifications/test_communication_notifications.py
  7. 122
      apps/account/tests/notifications/test_live_notifications.py
  8. 316
      apps/account/tests/notifications/test_notifications.py
  9. 176
      apps/account/tests/notifications/test_quiz_notifications.py
  10. 154
      apps/account/tests/notifications/test_retention_notifications.py
  11. 103
      apps/account/tests/notifications/test_transaction_notifications.py
  12. 240
      apps/account/tests/test_multiple_roles.py
  13. 4
      apps/api/views/__init__.py
  14. 163
      apps/api/views/admin_dashboard.py
  15. 139
      apps/api/views/professor_dashboard.py
  16. 0
      apps/blog/__init__.py
  17. 130
      apps/blog/admin.py
  18. 7
      apps/blog/apps.py
  19. 30
      apps/blog/management/commands/fix_empty_blog_fields.py
  20. 367
      apps/blog/management/commands/seed_blog_data.py
  21. 238
      apps/blog/migrations/0001_initial.py
  22. 23
      apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py
  23. 76
      apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py
  24. 0
      apps/blog/migrations/__init__.py
  25. 207
      apps/blog/models.py
  26. 142
      apps/blog/serializers.py
  27. 87
      apps/blog/serializers_admin.py
  28. 3
      apps/blog/tests.py
  29. 39
      apps/blog/urls.py
  30. 183
      apps/blog/views.py
  31. 128
      apps/blog/views_admin.py
  32. 0
      apps/certificate/__init__.py
  33. 81
      apps/certificate/admin.py
  34. 9
      apps/certificate/apps.py
  35. 69
      apps/certificate/migrations/0001_initial.py
  36. 36
      apps/certificate/migrations/0002_alter_certificate_course_and_more.py
  37. 21
      apps/certificate/migrations/0003_alter_certificate_student.py
  38. 0
      apps/certificate/migrations/__init__.py
  39. 28
      apps/certificate/models.py
  40. 73
      apps/certificate/serializers.py
  41. 43
      apps/certificate/signals.py
  42. 3
      apps/certificate/tests.py
  43. 15
      apps/certificate/urls.py
  44. 151
      apps/certificate/views.py
  45. 0
      apps/chat/__init__.py
  46. 410
      apps/chat/admin.py
  47. 285
      apps/chat/admin_views.py
  48. 9
      apps/chat/apps.py
  49. 1
      apps/chat/management/__init__.py
  50. 62
      apps/chat/management/commands/README.md
  51. 1
      apps/chat/management/commands/__init__.py
  52. 79
      apps/chat/management/commands/clear_chat_data.py
  53. 221
      apps/chat/migrations/0001_initial.py
  54. 18
      apps/chat/migrations/0002_roommessage_is_locked.py
  55. 35
      apps/chat/migrations/0003_alter_chatmessage_options_and_more.py
  56. 30
      apps/chat/migrations/0004_adminroomseen.py
  57. 0
      apps/chat/migrations/__init__.py
  58. 216
      apps/chat/models.py
  59. 100
      apps/chat/signals.py
  60. 3
      apps/chat/tests.py
  61. 6
      apps/chat/urls.py
  62. 26
      apps/chat/views.py
  63. 0
      apps/course/__init__.py
  64. 18
      apps/course/access.py
  65. 4
      apps/course/admin/__init__.py
  66. 603
      apps/course/admin/course.py
  67. 120
      apps/course/admin/lesson.py
  68. 177
      apps/course/admin/live_session.py
  69. 33
      apps/course/admin/participant.py
  70. 181
      apps/course/admin/professor_base.py
  71. 9
      apps/course/apps.py
  72. 42
      apps/course/data/category.json
  73. 480
      apps/course/doc.py
  74. 0
      apps/course/management/__init__.py
  75. 0
      apps/course/management/commands/__init__.py
  76. 0
      apps/course/management/commands/assign_professors.py
  77. 127
      apps/course/management/commands/auto_close_professorless_live_sessions.py
  78. 134
      apps/course/management/commands/clear_course_data.py
  79. 63
      apps/course/management/commands/ensure_course_rooms.py
  80. 65
      apps/course/management/commands/fill_english_translations.py
  81. 43
      apps/course/management/commands/migrate_lessons_to_chapters.py
  82. 117
      apps/course/management/commands/redistribute_lessons_to_chapters.py
  83. 16
      apps/course/management/commands/set_default_category_icons.py
  84. 517
      apps/course/management/commands/smoke_test_online_class_webhooks.py
  85. 42
      apps/course/management/commands/sync_course_lessons_count.py
  86. 86
      apps/course/management/commands/sync_quiz_lesson_numbers.py
  87. 1007
      apps/course/migrations/0001_initial.py
  88. 23
      apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py
  89. 23
      apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py
  90. 58
      apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py
  91. 19
      apps/course/migrations/0005_alter_course_discount_percentage.py
  92. 31
      apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py
  93. 65
      apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py
  94. 247
      apps/course/migrations/0008_remove_course_course_cour_status_57ffd9_idx_and_more.py
  95. 38
      apps/course/migrations/0009_alter_course_description_and_more.py
  96. 21
      apps/course/migrations/0010_course_rub_prices.py
  97. 16
      apps/course/migrations/0011_coursecategory_icon.py
  98. 18
      apps/course/migrations/0012_courselivesession_recording_title.py
  99. 38
      apps/course/migrations/0013_courselivesession_auto_close_fields.py
  100. 78
      apps/course/migrations/0014_courselivesession_web_egress_fields.py

269
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('<span title="{}">{}</span>', _("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(
"""
<div class="flex flex-row gap-2 items-center">
<span class="truncate">{}</span>
<a href="/admin/course/course/{}/change/" class="leading-none ml-auto">
<span class="material-symbols-outlined leading-none text-base-500">visibility</span>
</a>
</div>
""",
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(
'<a href="/admin/course/course/{}/change/" class="leading-none">'
'<span class="material-symbols-outlined leading-none text-base-500">visibility</span>'
'</a>',
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(
"""
<div class="flex flex-row gap-2 items-center">
<span class="truncate">{}</span>
<a href="/admin/course/course/{}/change/" class="leading-none ml-auto">
<span class="material-symbols-outlined leading-none text-base-500">visibility</span>
</a>
</div>
""",
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)

158
apps/account/management/commands/migrate_user_roles.py

@ -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}')

144
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',

237
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):

1
apps/account/tests/notifications/__init__.py

@ -1 +0,0 @@
# Notifications tests package

242
apps/account/tests/notifications/test_communication_notifications.py

@ -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)

122
apps/account/tests/notifications/test_live_notifications.py

@ -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)

316
apps/account/tests/notifications/test_notifications.py

@ -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())

176
apps/account/tests/notifications/test_quiz_notifications.py

@ -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())

154
apps/account/tests/notifications/test_retention_notifications.py

@ -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)

103
apps/account/tests/notifications/test_transaction_notifications.py

@ -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)

240
apps/account/tests/test_multiple_roles.py

@ -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))

4
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'
]

163
apps/api/views/admin_dashboard.py

@ -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
}
})

139
apps/api/views/professor_dashboard.py

@ -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
}
})

0
apps/blog/__init__.py

130
apps/blog/admin.py

@ -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)

7
apps/blog/apps.py

@ -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'

30
apps/blog/management/commands/fix_empty_blog_fields.py

@ -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.'))

367
apps/blog/management/commands/seed_blog_data.py

@ -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."))

238
apps/blog/migrations/0001_initial.py

@ -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"],
},
),
]

23
apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py

@ -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'),
),
]

76
apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py

@ -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'),
),
]

0
apps/blog/migrations/__init__.py

207
apps/blog/models.py

@ -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 ''}"

142
apps/blog/serializers.py

@ -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())

87
apps/blog/serializers_admin.py

@ -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"]

3
apps/blog/tests.py

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

39
apps/blog/urls.py

@ -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/<int:blog_id>/', RelatedBlogsAPIView.as_view(), name='related-blogs'),
# Blog detail by slug (using regex to support different languages)
re_path(r'^detail/(?P<slug>[\w\-\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u200C\u200D]+)/$',
BlogDetailBySlugAPIView.as_view(),
name='blog-detail'),
path('admin/', include(admin_router.urls)),
]

183
apps/blog/views.py

@ -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
)

128
apps/blog/views_admin.py

@ -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

0
apps/certificate/__init__.py

81
apps/certificate/admin.py

@ -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('<span class="{}">{}</span>', 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)

9
apps/certificate/apps.py

@ -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

69
apps/certificate/migrations/0001_initial.py

@ -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",
),
),
],
),
]

36
apps/certificate/migrations/0002_alter_certificate_course_and_more.py

@ -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'),
),
]

21
apps/certificate/migrations/0003_alter_certificate_student.py

@ -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'),
),
]

0
apps/certificate/migrations/__init__.py

28
apps/certificate/models.py

@ -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}"

73
apps/certificate/serializers.py

@ -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)

43
apps/certificate/signals.py

@ -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}
)

3
apps/certificate/tests.py

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

15
apps/certificate/urls.py

@ -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'),
]

151
apps/certificate/views.py

@ -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')

0
apps/chat/__init__.py

410
apps/chat/admin.py

@ -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('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Read"))
return format_html('<span class="bg-red-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("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(
'<span class="bg-primary-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center min-w-[2rem]">'
'{}</span>', count
)
@display(description=_("Room Type"))
def room_type_badge(self, obj):
if obj.room_type == 'group':
return format_html('<span class="bg-purple-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Group"))
return format_html('<span class="bg-indigo-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("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(
'<span class="{} text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center min-w-[4rem]">{}</span>',
bg_color, label
)
@display(description=_("Status"))
def is_deleted_status(self, obj):
if obj.is_deleted:
return format_html('<span class="bg-red-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Deleted"))
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("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('<span class="bg-blue-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("📷 Image"))
elif obj.file_attachment:
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("📎 File"))
elif obj.content and obj.content_type != 'text':
return format_html('<span class="bg-orange-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("🔗 Legacy"))
return "-"
@display(description=_("Attachment Preview"))
def attachment_preview(self, obj):
if obj.image_attachment:
return format_html(
'<div><strong>{}:</strong><br/>'
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-top: 10px;" />'
'<br/><a href="{}" target="_blank" style="margin-top: 10px; display: inline-block;">{}</a></div>',
_("Image"),
obj.image_attachment.url,
obj.image_attachment.url,
_("Open in new tab")
)
elif obj.file_attachment:
return format_html(
'<div><strong>{}:</strong><br/>'
'<a href="{}" target="_blank" style="margin-top: 10px; display: inline-block; padding: 8px 16px; background: #3b82f6; color: white; border-radius: 4px; text-decoration: none;">{}</a></div>',
_("File"),
obj.file_attachment.url,
_("📥 Download File")
)
elif obj.content and obj.content_type != 'text':
return format_html(
'<div><strong>{}:</strong><br/><code style="background: #f3f4f6; padding: 4px 8px; border-radius: 4px;">{}</code></div>',
_("Legacy URL"),
obj.content
)
return "-"
project_admin_site.register(RoomMessage, RoomMessageAdmin)
# project_admin_site.register(ChatMessage, ChatMessageAdmin)
project_admin_site.register(MessageReadStatus, MessageReadStatusAdmin)

285
apps/chat/admin_views.py

@ -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)

9
apps/chat/apps.py

@ -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

1
apps/chat/management/__init__.py

@ -1 +0,0 @@

62
apps/chat/management/commands/README.md

@ -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!
```

1
apps/chat/management/commands/__init__.py

@ -1 +0,0 @@

79
apps/chat/management/commands/clear_chat_data.py

@ -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

221
apps/chat/migrations/0001_initial.py

@ -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")},
},
),
]

18
apps/chat/migrations/0002_roommessage_is_locked.py

@ -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'),
),
]

35
apps/chat/migrations/0003_alter_chatmessage_options_and_more.py

@ -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'),
),
]

30
apps/chat/migrations/0004_adminroomseen.py

@ -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')},
},
),
]

0
apps/chat/migrations/__init__.py

216
apps/chat/models.py

@ -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}"

100
apps/chat/signals.py

@ -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}
)

3
apps/chat/tests.py

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

6
apps/chat/urls.py

@ -1,6 +0,0 @@
from django.urls import path
from .views import ChatMessageNotifyWebhookView
urlpatterns = [
path('notify-message/', ChatMessageNotifyWebhookView.as_view(), name='chat-notify-message'),
]

26
apps/chat/views.py

@ -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'})

0
apps/course/__init__.py

18
apps/course/access.py

@ -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()

4
apps/course/admin/__init__.py

@ -1,4 +0,0 @@
from .course import *
from .lesson import *
from .participant import *
from .live_session import *

603
apps/course/admin/course.py

@ -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(
'<a href="/admin/course/course/{}/change/" class="leading-none">'
'<span class="material-symbols-outlined leading-none text-base-500">visibility</span>'
'</a>',
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('<span class="badge badge-primary">{}</span>', 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('<span class="text-green-600 font-medium">{}</span>', _("Free"))
if instance.discount_percentage > 0:
return format_html(
'<span class="block text-xs text-gray-400">USD: <span class="line-through">${}</span> -> <span class="text-green-600 font-medium">${}</span></span>'
'<span class="block text-xs text-gray-400 mt-1">RUB: <span class="line-through">₽{}</span> -> <span class="text-green-600 font-medium">₽{}</span></span>',
instance.price,
instance.final_price,
instance.price_rub,
instance.final_price_rub,
)
return format_html(
'<span class="block">USD: ${}</span><span class="block mt-1">RUB: ₽{}</span>',
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)

120
apps/course/admin/lesson.py

@ -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('<span class="badge badge-info">{} {}</span>', 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('<span class="badge badge-info">{} {}</span>', 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)

177
apps/course/admin/live_session.py

@ -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)

33
apps/course/admin/participant.py

@ -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

181
apps/course/admin/professor_base.py

@ -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)

9
apps/course/apps.py

@ -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

42
apps/course/data/category.json

@ -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"
}
]

480
apps/course/doc.py

@ -1,480 +0,0 @@
def doc_course_participants():
return """
# 🐈 Scenario
🛠 لیست شرکتکنندگان دوره
---
## 🚀 درخواست API
### URL:
```
GET /api/courses/<slug>/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/<slug>/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
}
]
```
"""

0
apps/course/management/__init__.py

0
apps/course/management/commands/__init__.py

0
apps/course/management/commands/assign_professors.py

127
apps/course/management/commands/auto_close_professorless_live_sessions.py

@ -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(),
)

134
apps/course/management/commands/clear_course_data.py

@ -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')))

63
apps/course/management/commands/ensure_course_rooms.py

@ -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).')))

65
apps/course/management/commands/fill_english_translations.py

@ -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."))

43
apps/course/management/commands/migrate_lessons_to_chapters.py

@ -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!"
)
)

117
apps/course/management/commands/redistribute_lessons_to_chapters.py

@ -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!"
))

16
apps/course/management/commands/set_default_category_icons.py

@ -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".'))

517
apps/course/management/commands/smoke_test_online_class_webhooks.py

@ -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,
)

42
apps/course/management/commands/sync_course_lessons_count.py

@ -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."
)
)

86
apps/course/management/commands/sync_quiz_lesson_numbers.py

@ -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."
)
)

1007
apps/course/migrations/0001_initial.py
File diff suppressed because it is too large
View File

23
apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py

@ -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'),
),
]

23
apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py

@ -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',
),
]

58
apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py

@ -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'),
),
]

19
apps/course/migrations/0005_alter_course_discount_percentage.py

@ -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'),
),
]

31
apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py

@ -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'),
),
]

65
apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py

@ -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'),
),
]

247
apps/course/migrations/0008_remove_course_course_cour_status_57ffd9_idx_and_more.py

@ -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'),
),
]

38
apps/course/migrations/0009_alter_course_description_and_more.py

@ -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'),
),
]
)
]

21
apps/course/migrations/0010_course_rub_prices.py

@ -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)'),
),
]

16
apps/course/migrations/0011_coursecategory_icon.py

@ -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"),
),
]

18
apps/course/migrations/0012_courselivesession_recording_title.py

@ -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'),
),
]

38
apps/course/migrations/0013_courselivesession_auto_close_fields.py

@ -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",
),
),
]

78
apps/course/migrations/0014_courselivesession_web_egress_fields.py

@ -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",
),
),
]

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save