From 94919083c441cda476729edfaaab3913ccc137c0 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Tue, 16 Jun 2026 16:53:05 +0330 Subject: [PATCH] quiz logic changed with courses --- apps/course/serializers/lesson.py | 38 ++++- apps/course/signals.py | 22 +++ apps/course/views/admin.py | 135 +++++++++++++++++- .../migrations/0003_alter_book_author.py | 19 +++ ...06_quiz_lesson_number_alter_quiz_lesson.py | 25 ++++ apps/quiz/models/quiz.py | 18 ++- apps/quiz/serializers/admin.py | 16 ++- apps/quiz/serializers/quiz.py | 30 ++-- apps/quiz/views/admin.py | 17 ++- 9 files changed, 299 insertions(+), 21 deletions(-) create mode 100644 apps/library/migrations/0003_alter_book_author.py create mode 100644 apps/quiz/migrations/0006_quiz_lesson_number_alter_quiz_lesson.py diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py index 95b1fd8..18615d9 100644 --- a/apps/course/serializers/lesson.py +++ b/apps/course/serializers/lesson.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from django.db.models import Q from apps.course.models import Lesson, CourseLesson, Participant, LessonCompletion from apps.course.access import user_has_course_access from apps.quiz.serializers import QuizListSerializer @@ -100,11 +101,38 @@ class CourseLessonSerializer(serializers.ModelSerializer): ).exists() def get_quizs(self, obj): - # Now quizzes are directly related to CourseLesson - # print(f'--> type:{type(obj)} obj:{obj.quizzes.all()}') - quizzes = obj.quizzes.all() if hasattr(obj, 'quizzes') else [] - if quizzes: - return QuizListSerializer(quizzes, many=True, context=self.context).data + context_key = f'course_lessons_{obj.course_id}' + if context_key not in self.context: + lessons_list = list(CourseLesson.objects.filter( + course_id=obj.course_id, + is_active=True + ).filter( + Q(chapter__isnull=True) | Q(chapter__is_active=True) + ).order_by('chapter__priority', 'priority')) + self.context[context_key] = lessons_list + else: + lessons_list = self.context[context_key] + + try: + pos = lessons_list.index(obj) + 1 + except ValueError: + pos = None + + quizzes_key = f'course_quizzes_{obj.course_id}' + if quizzes_key not in self.context: + from apps.quiz.models import Quiz + all_quizzes = list(Quiz.objects.filter(course_id=obj.course_id, status=True)) + self.context[quizzes_key] = all_quizzes + else: + all_quizzes = self.context[quizzes_key] + + matching_quizzes = [] + for quiz in all_quizzes: + if (quiz.lesson_id == obj.id) or (pos is not None and quiz.lesson_number == pos): + matching_quizzes.append(quiz) + + if matching_quizzes: + return QuizListSerializer(matching_quizzes, many=True, context=self.context).data return None diff --git a/apps/course/signals.py b/apps/course/signals.py index 4aca55f..56567f0 100644 --- a/apps/course/signals.py +++ b/apps/course/signals.py @@ -94,6 +94,26 @@ def _recalculate_course_lessons_count(course_id): Course.recalculate_lessons_count_for_course(course_id) +def _sync_quiz_lesson_numbers(course_id): + if not course_id: + return + from apps.quiz.models import Quiz + from apps.course.models import CourseLesson + + active_lessons_count = CourseLesson.objects.filter( + course_id=course_id, + is_active=True + ).filter( + Q(chapter__isnull=True) | Q(chapter__is_active=True) + ).count() + + # Update quizzes where lesson_number > active_lessons_count to 0 + Quiz.objects.filter( + course_id=course_id, + lesson_number__gt=active_lessons_count + ).update(lesson_number=0) + + @receiver(pre_save, sender=CourseLesson) def capture_previous_course_lesson_state(sender, instance, **kwargs): if not instance.pk: @@ -124,11 +144,13 @@ def sync_lessons_count_on_course_lesson_save(sender, instance, **kwargs): for course_id in affected_course_ids: _recalculate_course_lessons_count(course_id) + _sync_quiz_lesson_numbers(course_id) @receiver(post_delete, sender=CourseLesson) def sync_lessons_count_on_course_lesson_delete(sender, instance, **kwargs): _recalculate_course_lessons_count(instance.course_id) + _sync_quiz_lesson_numbers(instance.course_id) @receiver(post_save, sender=LiveSessionRecording) diff --git a/apps/course/views/admin.py b/apps/course/views/admin.py index d0f2af3..9f5810f 100644 --- a/apps/course/views/admin.py +++ b/apps/course/views/admin.py @@ -1,8 +1,9 @@ import logging +from django.db import transaction from django.db.models import Q from django.utils import timezone -from rest_framework import viewsets +from rest_framework import serializers, viewsets from rest_framework.permissions import IsAuthenticated from rest_framework.authentication import TokenAuthentication from rest_framework.decorators import action @@ -40,6 +41,7 @@ from apps.course.serializers.admin import ( AdminLiveSessionListSerializer, AdminLiveSessionDetailSerializer ) +from utils import FileFieldSerializer logger = logging.getLogger(__name__) @@ -247,6 +249,76 @@ class AdminCourseAttachmentViewSet(viewsets.ModelViewSet): queryset = queryset.filter(course_id=course_id) return queryset.order_by('-id') + def create(self, request, *args, **kwargs): + attachment_ids = request.data.get('attachment_ids') + new_attachments = request.data.get('new_attachments') + course_id = request.data.get('course') + + if not course_id or (not attachment_ids and not new_attachments): + return super().create(request, *args, **kwargs) + + if is_professor(request): + course = Course.objects.filter(id=course_id, professor_id=request.user.id).first() + else: + course = Course.objects.filter(id=course_id).first() + + if not course: + raise PermissionDenied("Course not found or access denied.") + + created_links = [] + seen_attachment_ids = set() + file_field = FileFieldSerializer() + file_field.bind('file', None) + + with transaction.atomic(): + for attachment_id in attachment_ids or []: + try: + parsed_id = int(attachment_id) + except (TypeError, ValueError): + continue + + if parsed_id in seen_attachment_ids: + continue + seen_attachment_ids.add(parsed_id) + + attachment = Attachment.objects.filter(pk=parsed_id).first() + if not attachment: + continue + + course_attachment, created = CourseAttachment.objects.get_or_create( + course=course, + attachment=attachment, + ) + if created: + created_links.append(course_attachment) + + for item in new_attachments or []: + title = item.get('title') + file_data = item.get('file') + if not title or not file_data: + continue + + try: + normalized_file = file_field.to_internal_value(file_data) + except serializers.ValidationError as exc: + raise serializers.ValidationError({ + 'new_attachments': [exc.detail] + }) + + attachment = Attachment.objects.create( + title=title, + file=normalized_file, + ) + created_links.append( + CourseAttachment.objects.create( + course=course, + attachment=attachment, + ) + ) + + serializer = self.get_serializer(created_links, many=True) + return Response(serializer.data, status=201) + def perform_destroy(self, instance): # Prevent orphans: delete base Attachment along with junction model attachment = instance.attachment @@ -277,6 +349,67 @@ class AdminCourseGlossaryViewSet(viewsets.ModelViewSet): queryset = queryset.filter(course_id=course_id) return queryset.order_by('-id') + def create(self, request, *args, **kwargs): + glossary_ids = request.data.get('glossary_ids') + new_glossaries = request.data.get('new_glossaries') + course_id = request.data.get('course') + + if not course_id or (not glossary_ids and not new_glossaries): + return super().create(request, *args, **kwargs) + + if is_professor(request): + course = Course.objects.filter(id=course_id, professor_id=request.user.id).first() + else: + course = Course.objects.filter(id=course_id).first() + + if not course: + raise PermissionDenied("Course not found or access denied.") + + created_links = [] + seen_glossary_ids = set() + + with transaction.atomic(): + for glossary_id in glossary_ids or []: + try: + parsed_id = int(glossary_id) + except (TypeError, ValueError): + continue + + if parsed_id in seen_glossary_ids: + continue + seen_glossary_ids.add(parsed_id) + + glossary = Glossary.objects.filter(pk=parsed_id).first() + if not glossary: + continue + + course_glossary, created = CourseGlossary.objects.get_or_create( + course=course, + glossary=glossary, + ) + if created: + created_links.append(course_glossary) + + for item in new_glossaries or []: + title = item.get('title') + description = item.get('description') + if not title or not description: + continue + + glossary = Glossary.objects.create( + title=title, + description=description, + ) + created_links.append( + CourseGlossary.objects.create( + course=course, + glossary=glossary, + ) + ) + + serializer = self.get_serializer(created_links, many=True) + return Response(serializer.data, status=201) + def perform_destroy(self, instance): # Prevent orphans: delete base Glossary along with junction model glossary = instance.glossary diff --git a/apps/library/migrations/0003_alter_book_author.py b/apps/library/migrations/0003_alter_book_author.py new file mode 100644 index 0000000..828053c --- /dev/null +++ b/apps/library/migrations/0003_alter_book_author.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.12 on 2026-06-16 16:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0002_author_and_book_author_fk'), + ] + + operations = [ + migrations.AlterField( + model_name='book', + name='author', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='books', to='library.author', verbose_name='Author'), + ), + ] diff --git a/apps/quiz/migrations/0006_quiz_lesson_number_alter_quiz_lesson.py b/apps/quiz/migrations/0006_quiz_lesson_number_alter_quiz_lesson.py new file mode 100644 index 0000000..6c5e13b --- /dev/null +++ b/apps/quiz/migrations/0006_quiz_lesson_number_alter_quiz_lesson.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.12 on 2026-06-16 16:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0015_rename_course_cour_ended_a_0f47b4_idx_course_cour_ended_a_32eaaa_idx_and_more'), + ('quiz', '0005_multilingual_quiz_fields'), + ] + + operations = [ + migrations.AddField( + model_name='quiz', + name='lesson_number', + field=models.PositiveIntegerField(default=0, verbose_name='Lesson Number'), + ), + migrations.AlterField( + model_name='quiz', + name='lesson', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='quizzes', to='course.courselesson', verbose_name='Lesson'), + ), + ] diff --git a/apps/quiz/models/quiz.py b/apps/quiz/models/quiz.py index c7597c7..4616ba3 100644 --- a/apps/quiz/models/quiz.py +++ b/apps/quiz/models/quiz.py @@ -6,8 +6,9 @@ from apps.course.models.course import extract_text_from_json, format_multilingua class Quiz(models.Model): - lesson = models.ForeignKey("course.CourseLesson", verbose_name=_('Lesson'), related_name='quizzes', on_delete=models.CASCADE) + lesson = models.ForeignKey("course.CourseLesson", verbose_name=_('Lesson'), related_name='quizzes', on_delete=models.SET_NULL, null=True, blank=True) course = models.ForeignKey("course.Course", verbose_name=_('Course'), related_name='quizzes', on_delete=models.CASCADE , null=True, blank=True) + lesson_number = models.PositiveIntegerField(default=0, verbose_name=_("Lesson Number")) title = models.JSONField(default=list, verbose_name=_('Title'), help_text=_("Quiz Title")) description = models.JSONField(default=list, blank=True, verbose_name=_("Description")) @@ -26,6 +27,21 @@ class Quiz(models.Model): def __repr__(self): return f"Quiz(id={self.id})" + @property + def lesson_computed(self): + if self.lesson_number and self.lesson_number > 0 and self.course_id: + from apps.course.models import CourseLesson + from django.db.models import Q + lessons = list(CourseLesson.objects.filter( + course_id=self.course_id, + is_active=True + ).filter( + Q(chapter__isnull=True) | Q(chapter__is_active=True) + ).order_by('chapter__priority', 'priority')) + if len(lessons) >= self.lesson_number: + return lessons[self.lesson_number - 1] + return self.lesson + def save(self, *args, **kwargs): # Automatically set the course based on the selected lesson if self.lesson_id and hasattr(self.lesson, 'course'): diff --git a/apps/quiz/serializers/admin.py b/apps/quiz/serializers/admin.py index 51799ea..32e8512 100644 --- a/apps/quiz/serializers/admin.py +++ b/apps/quiz/serializers/admin.py @@ -56,6 +56,7 @@ class AdminQuizListSerializer(serializers.ModelSerializer): 'course', 'course_title', 'lesson', + 'lesson_number', 'lesson_title', 'questions_count', 'participants_count' @@ -74,10 +75,11 @@ class AdminQuizListSerializer(serializers.ModelSerializer): return get_localized_field(lang, obj.course.title) def get_lesson_title(self, obj): - if not obj.lesson: + lesson = obj.lesson_computed + if not lesson: return None lang = get_request_lang(self) - raw_title = obj.lesson.title or (obj.lesson.lesson.title if obj.lesson.lesson else None) + raw_title = lesson.title or (lesson.lesson.title if lesson.lesson else None) return get_localized_field(lang, raw_title) def to_representation(self, instance): @@ -85,6 +87,8 @@ class AdminQuizListSerializer(serializers.ModelSerializer): lang = get_request_lang(self) ret['title'] = get_localized_field(lang, instance.title) ret['description'] = get_localized_field(lang, instance.description) + computed = instance.lesson_computed + ret['lesson'] = computed.id if computed else None return ret @@ -109,6 +113,7 @@ class AdminQuizDetailSerializer(serializers.ModelSerializer): 'course', 'course_title', 'lesson', + 'lesson_number', 'lesson_title', 'questions_count', 'participants_count' @@ -128,10 +133,11 @@ class AdminQuizDetailSerializer(serializers.ModelSerializer): return get_localized_field(lang, obj.course.title) def get_lesson_title(self, obj): - if not obj.lesson: + lesson = obj.lesson_computed + if not lesson: return None lang = get_request_lang(self) - raw_title = obj.lesson.title or (obj.lesson.lesson.title if obj.lesson.lesson else None) + raw_title = lesson.title or (lesson.lesson.title if lesson.lesson else None) return get_localized_field(lang, raw_title) def to_representation(self, instance): @@ -139,6 +145,8 @@ class AdminQuizDetailSerializer(serializers.ModelSerializer): lang = get_request_lang(self) ret['title'] = get_localized_field(lang, instance.title) ret['description'] = get_localized_field(lang, instance.description) + computed = instance.lesson_computed + ret['lesson'] = computed.id if computed else None return ret diff --git a/apps/quiz/serializers/quiz.py b/apps/quiz/serializers/quiz.py index d48369e..48cf1ad 100644 --- a/apps/quiz/serializers/quiz.py +++ b/apps/quiz/serializers/quiz.py @@ -36,11 +36,14 @@ class QuizListSerializer(serializers.ModelSerializer): return False user = request.user - course_lesson = obj.lesson - if not course_lesson: + course = obj.course + if not course: + course_lesson = obj.lesson_computed + if course_lesson: + course = course_lesson.course + + if not course: return False - - course = course_lesson.course if not user_has_course_access(user, course): return False @@ -78,13 +81,14 @@ class QuestionSerializer(serializers.ModelSerializer): } for i in range(1, 5) ] + class Meta: model = Question fields = ['id', 'question', 'options', 'correct_answer'] class QuizSerializer(serializers.ModelSerializer): - lesson = serializers.PrimaryKeyRelatedField(read_only=True) + lesson = serializers.SerializerMethodField() questions = QuestionSerializer(many=True) permission = serializers.SerializerMethodField() title = serializers.SerializerMethodField() @@ -92,7 +96,7 @@ class QuizSerializer(serializers.ModelSerializer): class Meta: model = Quiz - fields = ['id', 'permission', 'lesson', 'title', 'description', 'each_question_timing', 'questions'] + fields = ['id', 'permission', 'lesson', 'lesson_number', 'title', 'description', 'each_question_timing', 'questions'] def get_title(self, obj): request = self.context.get('request') @@ -104,15 +108,23 @@ class QuizSerializer(serializers.ModelSerializer): lang = getattr(request, 'LANGUAGE_CODE', None) or 'en' return get_localized_field(lang, obj.description) + def get_lesson(self, obj): + computed = obj.lesson_computed + return computed.id if computed else None + def get_permission(self, obj): request = self.context.get('request') if not request or not request.user.is_authenticated: return False user = request.user - course_lesson = obj.lesson - if not course_lesson: + course = obj.course + if not course: + course_lesson = obj.lesson_computed + if course_lesson: + course = course_lesson.course + + if not course: return False - course = course_lesson.course return user_has_course_access(user, course) diff --git a/apps/quiz/views/admin.py b/apps/quiz/views/admin.py index b046fd7..fd1a7f4 100644 --- a/apps/quiz/views/admin.py +++ b/apps/quiz/views/admin.py @@ -133,7 +133,22 @@ class AdminQuizViewSet(ModelViewSet): # Handle Lesson Filter lesson_id = self.request.query_params.get('lesson', None) if lesson_id: - queryset = queryset.filter(lesson_id=lesson_id) + try: + lesson_obj = CourseLesson.objects.get(id=lesson_id) + course = lesson_obj.course + lessons = list(CourseLesson.objects.filter( + course=course, + is_active=True + ).order_by('chapter__priority', 'priority')) + try: + pos = lessons.index(lesson_obj) + 1 + queryset = queryset.filter( + Q(lesson_id=lesson_id) | (Q(course=course) & Q(lesson_number=pos)) + ) + except ValueError: + queryset = queryset.filter(lesson_id=lesson_id) + except CourseLesson.DoesNotExist: + queryset = queryset.filter(lesson_id=lesson_id) # Handle Status Filter status_param = self.request.query_params.get('status', None)