196 changed files with 69 additions and 27718 deletions
-
269apps/account/admin/user.py
-
158apps/account/management/commands/migrate_user_roles.py
-
144apps/account/notification_service.py
-
237apps/account/tasks.py
-
1apps/account/tests/notifications/__init__.py
-
242apps/account/tests/notifications/test_communication_notifications.py
-
122apps/account/tests/notifications/test_live_notifications.py
-
316apps/account/tests/notifications/test_notifications.py
-
176apps/account/tests/notifications/test_quiz_notifications.py
-
154apps/account/tests/notifications/test_retention_notifications.py
-
103apps/account/tests/notifications/test_transaction_notifications.py
-
240apps/account/tests/test_multiple_roles.py
-
4apps/api/views/__init__.py
-
163apps/api/views/admin_dashboard.py
-
139apps/api/views/professor_dashboard.py
-
0apps/blog/__init__.py
-
130apps/blog/admin.py
-
7apps/blog/apps.py
-
30apps/blog/management/commands/fix_empty_blog_fields.py
-
367apps/blog/management/commands/seed_blog_data.py
-
238apps/blog/migrations/0001_initial.py
-
23apps/blog/migrations/0002_alter_blog_slogan_alter_blog_title.py
-
76apps/blog/migrations/0003_alter_blog_slogan_alter_blog_slug_alter_blog_summary_and_more.py
-
0apps/blog/migrations/__init__.py
-
207apps/blog/models.py
-
142apps/blog/serializers.py
-
87apps/blog/serializers_admin.py
-
3apps/blog/tests.py
-
39apps/blog/urls.py
-
183apps/blog/views.py
-
128apps/blog/views_admin.py
-
0apps/certificate/__init__.py
-
81apps/certificate/admin.py
-
9apps/certificate/apps.py
-
69apps/certificate/migrations/0001_initial.py
-
36apps/certificate/migrations/0002_alter_certificate_course_and_more.py
-
21apps/certificate/migrations/0003_alter_certificate_student.py
-
0apps/certificate/migrations/__init__.py
-
28apps/certificate/models.py
-
73apps/certificate/serializers.py
-
43apps/certificate/signals.py
-
3apps/certificate/tests.py
-
15apps/certificate/urls.py
-
151apps/certificate/views.py
-
0apps/chat/__init__.py
-
410apps/chat/admin.py
-
285apps/chat/admin_views.py
-
9apps/chat/apps.py
-
1apps/chat/management/__init__.py
-
62apps/chat/management/commands/README.md
-
1apps/chat/management/commands/__init__.py
-
79apps/chat/management/commands/clear_chat_data.py
-
221apps/chat/migrations/0001_initial.py
-
18apps/chat/migrations/0002_roommessage_is_locked.py
-
35apps/chat/migrations/0003_alter_chatmessage_options_and_more.py
-
30apps/chat/migrations/0004_adminroomseen.py
-
0apps/chat/migrations/__init__.py
-
216apps/chat/models.py
-
100apps/chat/signals.py
-
3apps/chat/tests.py
-
6apps/chat/urls.py
-
26apps/chat/views.py
-
0apps/course/__init__.py
-
18apps/course/access.py
-
4apps/course/admin/__init__.py
-
603apps/course/admin/course.py
-
120apps/course/admin/lesson.py
-
177apps/course/admin/live_session.py
-
33apps/course/admin/participant.py
-
181apps/course/admin/professor_base.py
-
9apps/course/apps.py
-
42apps/course/data/category.json
-
480apps/course/doc.py
-
0apps/course/management/__init__.py
-
0apps/course/management/commands/__init__.py
-
0apps/course/management/commands/assign_professors.py
-
127apps/course/management/commands/auto_close_professorless_live_sessions.py
-
134apps/course/management/commands/clear_course_data.py
-
63apps/course/management/commands/ensure_course_rooms.py
-
65apps/course/management/commands/fill_english_translations.py
-
43apps/course/management/commands/migrate_lessons_to_chapters.py
-
117apps/course/management/commands/redistribute_lessons_to_chapters.py
-
16apps/course/management/commands/set_default_category_icons.py
-
517apps/course/management/commands/smoke_test_online_class_webhooks.py
-
42apps/course/management/commands/sync_course_lessons_count.py
-
86apps/course/management/commands/sync_quiz_lesson_numbers.py
-
1007apps/course/migrations/0001_initial.py
-
23apps/course/migrations/0002_course_is_chat_group_lock_course_is_prof_chat_lock.py
-
23apps/course/migrations/0003_rename_is_chat_group_lock_course_is_group_chat_locked_and_more.py
-
58apps/course/migrations/0004_alter_lessoncompletion_options_and_more.py
-
19apps/course/migrations/0005_alter_course_discount_percentage.py
-
31apps/course/migrations/0006_alter_course_professor_alter_course_video_file_and_more.py
-
65apps/course/migrations/0007_coursechapter_alter_courselesson_options_and_more.py
-
247apps/course/migrations/0008_remove_course_course_cour_status_57ffd9_idx_and_more.py
-
38apps/course/migrations/0009_alter_course_description_and_more.py
-
21apps/course/migrations/0010_course_rub_prices.py
-
16apps/course/migrations/0011_coursecategory_icon.py
-
18apps/course/migrations/0012_courselivesession_recording_title.py
-
38apps/course/migrations/0013_courselivesession_auto_close_fields.py
-
78apps/course/migrations/0014_courselivesession_web_egress_fields.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}') |
|
||||
@ -1 +0,0 @@ |
|||||
# Notifications tests package |
|
||||
@ -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) |
|
||||
@ -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) |
|
||||
@ -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()) |
|
||||
|
|
||||
@ -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()) |
|
||||
@ -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) |
|
||||
@ -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) |
|
||||
@ -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)) |
|
||||
@ -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 |
|
||||
} |
|
||||
}) |
|
||||
@ -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 |
|
||||
} |
|
||||
}) |
|
||||
@ -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) |
|
||||
@ -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' |
|
||||
@ -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.')) |
|
||||
@ -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.")) |
|
||||
@ -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"], |
|
||||
}, |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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 ''}" |
|
||||
@ -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()) |
|
||||
|
|
||||
|
|
||||
@ -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"] |
|
||||
@ -1,3 +0,0 @@ |
|||||
from django.test import TestCase |
|
||||
|
|
||||
# Create your tests here. |
|
||||
@ -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)), |
|
||||
] |
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
@ -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 |
|
||||
) |
|
||||
@ -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 |
|
||||
@ -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) |
|
||||
@ -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 |
|
||||
@ -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", |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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}" |
|
||||
@ -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) |
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
@ -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} |
|
||||
) |
|
||||
@ -1,3 +0,0 @@ |
|||||
from django.test import TestCase |
|
||||
|
|
||||
# Create your tests here. |
|
||||
@ -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'), |
|
||||
] |
|
||||
@ -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') |
|
||||
@ -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) |
|
||||
@ -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) |
|
||||
@ -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 +0,0 @@ |
|||||
|
|
||||
@ -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 +0,0 @@ |
|||||
|
|
||||
@ -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 |
|
||||
@ -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")}, |
|
||||
}, |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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')}, |
|
||||
}, |
|
||||
), |
|
||||
] |
|
||||
@ -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}" |
|
||||
|
|
||||
|
|
||||
@ -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} |
|
||||
) |
|
||||
@ -1,3 +0,0 @@ |
|||||
from django.test import TestCase |
|
||||
|
|
||||
# Create your tests here. |
|
||||
@ -1,6 +0,0 @@ |
|||||
from django.urls import path |
|
||||
from .views import ChatMessageNotifyWebhookView |
|
||||
|
|
||||
urlpatterns = [ |
|
||||
path('notify-message/', ChatMessageNotifyWebhookView.as_view(), name='chat-notify-message'), |
|
||||
] |
|
||||
@ -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'}) |
|
||||
@ -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() |
|
||||
@ -1,4 +0,0 @@ |
|||||
from .course import * |
|
||||
from .lesson import * |
|
||||
from .participant import * |
|
||||
from .live_session import * |
|
||||
@ -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) |
|
||||
@ -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) |
|
||||
@ -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) |
|
||||
@ -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 |
|
||||
|
|
||||
@ -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) |
|
||||
@ -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 |
|
||||
@ -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" |
|
||||
} |
|
||||
] |
|
||||
@ -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 |
|
||||
} |
|
||||
] |
|
||||
``` |
|
||||
""" |
|
||||
@ -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(), |
|
||||
) |
|
||||
@ -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'))) |
|
||||
@ -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).'))) |
|
||||
@ -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.")) |
|
||||
@ -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!" |
|
||||
) |
|
||||
) |
|
||||
@ -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!" |
|
||||
)) |
|
||||
@ -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".')) |
|
||||
@ -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, |
|
||||
) |
|
||||
@ -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." |
|
||||
) |
|
||||
) |
|
||||
@ -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
File diff suppressed because it is too large
View File
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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', |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
) |
|
||||
] |
|
||||
@ -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)'), |
|
||||
), |
|
||||
] |
|
||||
@ -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"), |
|
||||
), |
|
||||
] |
|
||||
@ -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'), |
|
||||
), |
|
||||
] |
|
||||
@ -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", |
|
||||
), |
|
||||
), |
|
||||
] |
|
||||
@ -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
Write
Preview
Loading…
Cancel
Save
Reference in new issue