Browse Source

2. quizzes and rogress part of notification added

master
Mohsen Taba 2 weeks ago
parent
commit
6429f9e1d2
  1. 64
      apps/account/tasks.py
  2. 176
      apps/account/tests/notifications/test_quiz_notifications.py
  3. 27
      apps/hadis/management/commands/detach_edition_from_volume.py
  4. 27
      apps/hadis/management/commands/find_orphan_volumes.py
  5. 64
      apps/hadis/management/commands/seed_book_excerpts.py
  6. 4
      config/settings/base.py
  7. 4
      config/settings/production.py

64
apps/account/tasks.py

@ -278,5 +278,69 @@ def check_new_course_registration_task():
course.save(update_fields=['notified_new_registration']) course.save(update_fields=['notified_new_registration'])
@shared_task
def check_low_quiz_results_task():
"""
Checks for failed quiz attempts from exactly 2, 7, or 21 days ago (score < 70)
and suggests a retake to the student, if they haven't passed or retaken the quiz since.
"""
from django.utils import timezone
from datetime import timedelta
from apps.quiz.models import QuizParticipant
from apps.course.models.course import get_localized_field
from apps.account.notification_service import create_and_send_notification
from django.core.cache import cache
now = timezone.now()
today = now.date()
intervals = [2, 7, 21]
for days in intervals:
target_date = today - timedelta(days=days)
# Find attempts that ended on target_date with total_score < 70
candidates = QuizParticipant.objects.filter(
ended_at__date=target_date,
total_score__lt=70
).select_related('user', 'quiz')
for candidate in candidates:
# Check if user has retaken the quiz since this attempt
has_newer_attempt = QuizParticipant.objects.filter(
user=candidate.user,
quiz=candidate.quiz,
ended_at__gt=candidate.ended_at
).exists()
# Check if user has passed the quiz at least once
has_passed = QuizParticipant.objects.filter(
user=candidate.user,
quiz=candidate.quiz,
total_score__gte=70
).exists()
if has_newer_attempt or has_passed:
continue
# Prevent double notification on the same day for this interval
cache_key = f"notified_quiz_low_score_{candidate.id}_{days}"
if not cache.get(cache_key):
cache.set(cache_key, True, timeout=86400) # 24 hours
quiz_title_en = get_localized_field('en', candidate.quiz.title) or f"Quiz {candidate.quiz.id}"
quiz_title_fa = get_localized_field('fa', candidate.quiz.title) or f"آزمون {candidate.quiz.id}"
create_and_send_notification(
user=candidate.user,
title_en="Quiz Retake Suggestion",
body_en=f"You scored below the passing threshold on quiz '{quiz_title_en}'. We suggest taking it again to improve your score.",
title_fa="پیشنهاد شرکت مجدد در آزمون",
body_fa=f"امتیاز شما در آزمون «{quiz_title_fa}» کمتر از حد نصاب شده است. پیشنهاد می‌کنیم مجدداً در این آزمون شرکت کنید تا نمره خود را بهبود ببخشید.",
service='imam-javad',
data={'type': 'low_quiz_result', 'quiz_id': candidate.quiz.id, 'days_since_failure': days}
)

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

@ -0,0 +1,176 @@
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_fa, _ = Language.objects.update_or_create(
code='fa',
defaults={'name': 'Persian', '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_fa,
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": "دوره ۱", "language_code": "fa"}
],
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": "fa"}
],
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 (Persian 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())

27
apps/hadis/management/commands/detach_edition_from_volume.py

@ -0,0 +1,27 @@
# backend/apps/hadis/management/commands/make_books_independent.py
import random
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.hadis.models import BookReference, BookEdition
class Command(BaseCommand):
help = 'Makes 50% of BookReferences independent by deleting their BookEditions.'
def handle(self, *args, **options):
all_books = list(BookReference.objects.all())
if not all_books:
self.stdout.write(self.style.ERROR("No books found."))
return
# انتخاب ۵۰ درصد رندوم
num_to_detach = len(all_books) // 2
books_to_detach = random.sample(all_books, num_to_detach)
self.stdout.write(self.style.WARNING(f"Deleting editions from {num_to_detach} books..."))
with transaction.atomic():
# حذف تمام ادیشن‌های مرتبط با این ۵۰ درصد کتاب
BookEdition.objects.filter(book_reference__in=books_to_detach).delete()
self.stdout.write(self.style.SUCCESS(f"✅ {num_to_detach} books are now independent (no editions)."))

27
apps/hadis/management/commands/find_orphan_volumes.py

@ -0,0 +1,27 @@
# backend/apps/hadis/management/commands/find_orphan_volumes.py
from django.core.management.base import BaseCommand
from apps.hadis.models import BookReference
class Command(BaseCommand):
help = 'Prints the slugs of BookReferences that have volumes but no associated editions.'
def handle(self, *args, **options):
# فیلتر کردن رفرنس‌هایی که تعداد ادیشن‌های آن‌ها صفر است اما تعداد ولوم‌های آن‌ها بیشتر از صفر است
orphaned_references = BookReference.objects.filter(
editions__isnull=True,
volumes__isnull=False
).distinct()
if not orphaned_references.exists():
self.stdout.write(self.style.SUCCESS("✅ No orphaned volumes found. All volumes have associated editions."))
return
self.stdout.write(self.style.WARNING("Found the following BookReference slugs (have volumes, no edition):"))
self.stdout.write("-" * 40)
for book in orphaned_references:
self.stdout.write(f"Slug: {book.slug} | ID: {book.id}")
self.stdout.write("-" * 40)
self.stdout.write(self.style.SUCCESS(f"Total found: {orphaned_references.count()}"))

64
apps/hadis/management/commands/seed_book_excerpts.py

@ -0,0 +1,64 @@
# backend/apps/hadis/management/commands/seed_book_excerpts.py
import random
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.hadis.models import BookReference, Hadis, HadisReference
class Command(BaseCommand):
help = 'Assigns 4-5 random Hadis excerpts to all BookReferences.'
def handle(self, *args, **options):
self.stdout.write(self.style.WARNING("Scanning books and hadiths..."))
books = BookReference.objects.all()
# گرفتن تمام آیدی‌های احادیث به صورت یک لیست تخت برای انتخاب رندوم سریع
all_hadis_ids = list(Hadis.objects.filter(status=True).values_list('id', flat=True))
if not books.exists():
self.stdout.write(self.style.ERROR("❌ No BookReferences found in the database."))
return
if not all_hadis_ids:
self.stdout.write(self.style.ERROR("❌ No active Hadis found to assign as excerpts."))
return
new_references = []
total_books = books.count()
self.stdout.write(self.style.WARNING(f"Processing {total_books} books..."))
for book in books:
# انتخاب تصادفی ۴ یا ۵ اکسرپت
num_excerpts = random.randint(4, 5)
# در صورتی که تعداد کل احادیث کمتر از ۵ تا باشد
num_excerpts = min(num_excerpts, len(all_hadis_ids))
# انتخاب رندوم آیدی احادیث
selected_hadis_ids = random.sample(all_hadis_ids, num_excerpts)
# استخراج احادیثی که از قبل به این کتاب وصل بوده‌اند (برای جلوگیری از ثبت تکراری)
existing_hadis_ids = set(HadisReference.objects.filter(
book_reference=book,
hadis_id__in=selected_hadis_ids
).values_list('hadis_id', flat=True))
for h_id in selected_hadis_ids:
if h_id not in existing_hadis_ids:
new_references.append(
HadisReference(
book_reference=book,
hadis_id=h_id,
# تولید دیتای دامی برای فیلدهای متنی رفرنس
volume=f"Vol. {random.randint(1, 10)}",
pages=str(random.randint(15, 450)),
hadith_number=str(random.randint(100, 9999))
)
)
if new_references:
with transaction.atomic():
HadisReference.objects.bulk_create(new_references, batch_size=1000)
self.stdout.write(self.style.SUCCESS(f"🎉 Successfully created {len(new_references)} new excerpts for books!"))
else:
self.stdout.write(self.style.SUCCESS("✅ All books already have these excerpts or no new ones needed."))

4
config/settings/base.py

@ -263,6 +263,10 @@ CELERY_BEAT_SCHEDULE = {
'task': 'apps.account.tasks.check_new_course_registration_task', 'task': 'apps.account.tasks.check_new_course_registration_task',
'schedule': crontab(minute=0, hour=12, day_of_week='friday'), 'schedule': crontab(minute=0, hour=12, day_of_week='friday'),
}, },
'check_low_quiz_results_daily': {
'task': 'apps.account.tasks.check_low_quiz_results_task',
'schedule': crontab(minute=0, hour=10), # Daily at 10:00 AM
},
} }
# Password validation # Password validation

4
config/settings/production.py

@ -35,6 +35,10 @@ CELERY_BEAT_SCHEDULE = {
'task': 'apps.account.tasks.check_new_course_registration_task', 'task': 'apps.account.tasks.check_new_course_registration_task',
'schedule': crontab(minute=0, hour=12, day_of_week='friday'), 'schedule': crontab(minute=0, hour=12, day_of_week='friday'),
}, },
'check_low_quiz_results_daily': {
'task': 'apps.account.tasks.check_low_quiz_results_task',
'schedule': crontab(minute=0, hour=10), # Daily at 10:00 AM
},
} }
# CORS_ALLOWED_ORIGINS = [ # CORS_ALLOWED_ORIGINS = [

Loading…
Cancel
Save