From a0b52ed41a7ee5d854b0b225cd4da5e4087ecb5c Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Thu, 11 Jun 2026 12:14:04 +0330 Subject: [PATCH] quiz multilanguage added --- apps/quiz/admin/question.py | 8 +- apps/quiz/admin/quiz.py | 25 +++- .../0005_multilingual_quiz_fields.py | 137 ++++++++++++++++++ apps/quiz/models/quiz.py | 24 ++- apps/quiz/serializers/admin.py | 46 ++++++ apps/quiz/serializers/quiz.py | 35 ++++- apps/quiz/views/admin.py | 21 ++- 7 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 apps/quiz/migrations/0005_multilingual_quiz_fields.py diff --git a/apps/quiz/admin/question.py b/apps/quiz/admin/question.py index dacebac..ef4e464 100644 --- a/apps/quiz/admin/question.py +++ b/apps/quiz/admin/question.py @@ -1,9 +1,11 @@ from django import forms from django.contrib import admin +from django.utils.translation import gettext_lazy as _ from unfold.admin import ModelAdmin, TabularInline, StackedInline from unfold.forms import forms +from apps.course.models.course import extract_text_from_json from apps.quiz.models import Question from utils.admin import project_admin_site @@ -35,7 +37,7 @@ from utils.admin import project_admin_site # ) @admin.register(Question) class QuestionAdmin(ModelAdmin): - list_display = ('question', 'correct_answer', 'quiz', 'priority') + list_display = ('question_display', 'correct_answer', 'quiz', 'priority') ordering = ("priority", "id",) search_fields = ('question', 'quiz__title') list_filter = ('quiz',) @@ -59,6 +61,10 @@ class QuestionAdmin(ModelAdmin): ) ) + @admin.display(description=_('Question')) + def question_display(self, obj): + return extract_text_from_json(obj.question) + class QuestionAdminInline(StackedInline): model = Question ordering = ("priority", "id",) diff --git a/apps/quiz/admin/quiz.py b/apps/quiz/admin/quiz.py index 33ac9b2..dfe1f70 100644 --- a/apps/quiz/admin/quiz.py +++ b/apps/quiz/admin/quiz.py @@ -20,7 +20,7 @@ from utils.admin import project_admin_site, admin_url_generator class QuizAdmin(ModelAdmin): search_fields = ['title', 'description'] - list_display = ['title', 'description', 'lesson', 'each_question_timing', 'status_display', 'questions_display'] + list_display = ['title_display', 'description_display', 'lesson', 'each_question_timing', 'status_display', 'questions_display'] list_filter = ['each_question_timing', 'status'] inlines = [QuestionAdminInline, QuizParticipantInline] compressed_fields = True @@ -117,14 +117,33 @@ class QuizAdmin(ModelAdmin): queryset, use_distinct = super().get_search_results(request, queryset, search_term) matching_course_ids = self._find_matching_course_ids(search_term) matching_lesson_ids = self._find_matching_lesson_ids(search_term) + matching_quiz_ids = [] - if matching_course_ids or matching_lesson_ids: + normalized = (search_term or "").strip().lower() + if normalized: + for quiz in self.model.objects.all().only("id", "title", "description"): + title_text = extract_text_from_json(quiz.title).lower() + description_text = extract_text_from_json(quiz.description).lower() + if normalized in title_text or normalized in description_text: + matching_quiz_ids.append(quiz.id) + + if matching_course_ids or matching_lesson_ids or matching_quiz_ids: queryset = queryset | self.model.objects.filter( - Q(course_id__in=matching_course_ids) | Q(lesson_id__in=matching_lesson_ids) + Q(id__in=matching_quiz_ids) | + Q(course_id__in=matching_course_ids) | + Q(lesson_id__in=matching_lesson_ids) ) use_distinct = True return queryset, use_distinct + + @display(description=_('Title')) + def title_display(self, obj): + return extract_text_from_json(obj.title) + + @display(description=_('Description')) + def description_display(self, obj): + return extract_text_from_json(obj.description) @display(description=_('Status'), ordering='status') def status_display(self, obj): diff --git a/apps/quiz/migrations/0005_multilingual_quiz_fields.py b/apps/quiz/migrations/0005_multilingual_quiz_fields.py new file mode 100644 index 0000000..449ba27 --- /dev/null +++ b/apps/quiz/migrations/0005_multilingual_quiz_fields.py @@ -0,0 +1,137 @@ +# Generated by Codex on 2026-06-11 + +from django.db import migrations, models + + +def _is_multilingual_list(value): + return isinstance(value, list) and all( + isinstance(item, dict) and item.get("language_code") + for item in value + ) + + +def _normalize_multilingual(value): + if value in (None, "", []): + return [] + + if _is_multilingual_list(value): + normalized_items = [] + for item in value: + title = item.get("title") or item.get("text") or item.get("value") or item.get("name") + language_code = item.get("language_code") or "en" + if title: + normalized_items.append({"title": title, "language_code": language_code}) + return normalized_items + + if isinstance(value, dict): + language_map_keys = {"fa", "en", "ru", "ar", "tr"} + if any(key in value for key in language_map_keys): + return [ + {"title": title, "language_code": language_code} + for language_code, title in value.items() + if title + ] + + title = value.get("title") or value.get("text") or value.get("value") or value.get("name") + language_code = value.get("language_code") or "en" + return [{"title": title, "language_code": language_code}] if title else [] + + if isinstance(value, list): + normalized_items = [] + for item in value: + if isinstance(item, dict): + title = item.get("title") or item.get("text") or item.get("value") or item.get("name") + language_code = item.get("language_code") or "en" + if title: + normalized_items.append({"title": title, "language_code": language_code}) + elif item not in (None, ""): + normalized_items.append({"title": str(item), "language_code": "en"}) + return normalized_items + + return [{"title": str(value), "language_code": "en"}] + + +def forwards(apps, schema_editor): + Quiz = apps.get_model("quiz", "Quiz") + Question = apps.get_model("quiz", "Question") + + for quiz in Quiz.objects.all().iterator(): + quiz.title_i18n = _normalize_multilingual(quiz.title) + quiz.description_i18n = _normalize_multilingual(quiz.description) + quiz.save(update_fields=["title_i18n", "description_i18n"]) + + for question in Question.objects.all().iterator(): + question.question_i18n = _normalize_multilingual(question.question) + question.option1_i18n = _normalize_multilingual(question.option1) + question.option2_i18n = _normalize_multilingual(question.option2) + question.option3_i18n = _normalize_multilingual(question.option3) + question.option4_i18n = _normalize_multilingual(question.option4) + question.save( + update_fields=[ + "question_i18n", + "option1_i18n", + "option2_i18n", + "option3_i18n", + "option4_i18n", + ] + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("quiz", "0004_alter_question_options"), + ] + + operations = [ + migrations.AddField( + model_name="quiz", + name="title_i18n", + field=models.JSONField(default=list, help_text="Quiz Title", verbose_name="Title"), + ), + migrations.AddField( + model_name="quiz", + name="description_i18n", + field=models.JSONField(blank=True, default=list, verbose_name="Description"), + ), + migrations.AddField( + model_name="question", + name="question_i18n", + field=models.JSONField(default=list, verbose_name="Question"), + ), + migrations.AddField( + model_name="question", + name="option1_i18n", + field=models.JSONField(default=list, verbose_name="Option 1"), + ), + migrations.AddField( + model_name="question", + name="option2_i18n", + field=models.JSONField(default=list, verbose_name="Option 2"), + ), + migrations.AddField( + model_name="question", + name="option3_i18n", + field=models.JSONField(default=list, verbose_name="Option 3"), + ), + migrations.AddField( + model_name="question", + name="option4_i18n", + field=models.JSONField(default=list, verbose_name="Option 4"), + ), + migrations.RunPython(forwards, migrations.RunPython.noop), + migrations.RemoveField(model_name="quiz", name="title"), + migrations.RemoveField(model_name="quiz", name="description"), + migrations.RemoveField(model_name="question", name="question"), + migrations.RemoveField(model_name="question", name="option1"), + migrations.RemoveField(model_name="question", name="option2"), + migrations.RemoveField(model_name="question", name="option3"), + migrations.RemoveField(model_name="question", name="option4"), + migrations.RenameField(model_name="quiz", old_name="title_i18n", new_name="title"), + migrations.RenameField(model_name="quiz", old_name="description_i18n", new_name="description"), + migrations.RenameField(model_name="question", old_name="question_i18n", new_name="question"), + migrations.RenameField(model_name="question", old_name="option1_i18n", new_name="option1"), + migrations.RenameField(model_name="question", old_name="option2_i18n", new_name="option2"), + migrations.RenameField(model_name="question", old_name="option3_i18n", new_name="option3"), + migrations.RenameField(model_name="question", old_name="option4_i18n", new_name="option4"), + ] diff --git a/apps/quiz/models/quiz.py b/apps/quiz/models/quiz.py index e7ce23c..c7597c7 100644 --- a/apps/quiz/models/quiz.py +++ b/apps/quiz/models/quiz.py @@ -1,6 +1,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from apps.account.models import User +from apps.course.models.course import extract_text_from_json, format_multilingual_field @@ -8,8 +9,8 @@ class Quiz(models.Model): lesson = models.ForeignKey("course.CourseLesson", verbose_name=_('Lesson'), related_name='quizzes', on_delete=models.CASCADE) course = models.ForeignKey("course.Course", verbose_name=_('Course'), related_name='quizzes', on_delete=models.CASCADE , null=True, blank=True) - title = models.CharField(max_length=255, verbose_name=_('Title'), help_text=_("Quiz Title")) - description = models.CharField(max_length=55, blank=True, null=True, verbose_name=_("Description")) + title = models.JSONField(default=list, verbose_name=_('Title'), help_text=_("Quiz Title")) + description = models.JSONField(default=list, blank=True, verbose_name=_("Description")) each_question_timing = models.PositiveIntegerField(verbose_name=_("Each Question Timing")) status = models.BooleanField(default=True, verbose_name=_("Status")) @@ -29,6 +30,8 @@ class Quiz(models.Model): # Automatically set the course based on the selected lesson if self.lesson_id and hasattr(self.lesson, 'course'): self.course = self.lesson.course + self.title = format_multilingual_field(self.title) + self.description = format_multilingual_field(self.description) super().save(*args, **kwargs) @@ -41,11 +44,11 @@ class Question(models.Model): ] quiz = models.ForeignKey(Quiz, verbose_name=_('Quiz'), on_delete=models.CASCADE, related_name='questions') - question = models.CharField(max_length=255, verbose_name=_('Question')) - option1 = models.CharField(max_length=255, verbose_name=_('Option 1')) - option2 = models.CharField(max_length=255, verbose_name=_('Option 2')) - option3 = models.CharField(max_length=255, verbose_name=_('Option 3')) - option4 = models.CharField(max_length=255, verbose_name=_('Option 4')) + question = models.JSONField(default=list, verbose_name=_('Question')) + option1 = models.JSONField(default=list, verbose_name=_('Option 1')) + option2 = models.JSONField(default=list, verbose_name=_('Option 2')) + option3 = models.JSONField(default=list, verbose_name=_('Option 3')) + option4 = models.JSONField(default=list, verbose_name=_('Option 4')) correct_answer = models.PositiveSmallIntegerField(choices=CHOICES, verbose_name=_('Correct Answer')) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) priority = models.IntegerField(null=True, blank=True, verbose_name=_('Priority')) @@ -57,7 +60,7 @@ class Question(models.Model): ordering = ("priority", "id",) def __str__(self): - return self.question + return extract_text_from_json(self.question) def __repr__(self): return f"Question(id={self.id})" @@ -66,6 +69,11 @@ class Question(models.Model): if self.priority is None: max_priority = Question.objects.filter(quiz=self.quiz).aggregate(models.Max('priority'))['priority__max'] self.priority = (max_priority or 0) + 1 + self.question = format_multilingual_field(self.question) + self.option1 = format_multilingual_field(self.option1) + self.option2 = format_multilingual_field(self.option2) + self.option3 = format_multilingual_field(self.option3) + self.option4 = format_multilingual_field(self.option4) super().save(*args, **kwargs) diff --git a/apps/quiz/serializers/admin.py b/apps/quiz/serializers/admin.py index c5c21d2..51799ea 100644 --- a/apps/quiz/serializers/admin.py +++ b/apps/quiz/serializers/admin.py @@ -40,13 +40,17 @@ class AdminQuizListSerializer(serializers.ModelSerializer): participants_count = serializers.SerializerMethodField() course_title = serializers.SerializerMethodField() lesson_title = serializers.SerializerMethodField() + title_i18n = serializers.JSONField(source='title', read_only=True) + description_i18n = serializers.JSONField(source='description', read_only=True) class Meta: model = Quiz fields = [ 'id', 'title', + 'title_i18n', 'description', + 'description_i18n', 'each_question_timing', 'status', 'course', @@ -76,19 +80,30 @@ class AdminQuizListSerializer(serializers.ModelSerializer): raw_title = obj.lesson.title or (obj.lesson.lesson.title if obj.lesson.lesson else None) return get_localized_field(lang, raw_title) + def to_representation(self, instance): + ret = super().to_representation(instance) + lang = get_request_lang(self) + ret['title'] = get_localized_field(lang, instance.title) + ret['description'] = get_localized_field(lang, instance.description) + return ret + class AdminQuizDetailSerializer(serializers.ModelSerializer): questions_count = serializers.SerializerMethodField() participants_count = serializers.SerializerMethodField() course_title = serializers.SerializerMethodField() lesson_title = serializers.SerializerMethodField() + title_i18n = serializers.JSONField(source='title', read_only=True) + description_i18n = serializers.JSONField(source='description', read_only=True) class Meta: model = Quiz fields = [ 'id', 'title', + 'title_i18n', 'description', + 'description_i18n', 'each_question_timing', 'status', 'course', @@ -119,22 +134,53 @@ class AdminQuizDetailSerializer(serializers.ModelSerializer): raw_title = obj.lesson.title or (obj.lesson.lesson.title if obj.lesson.lesson else None) return get_localized_field(lang, raw_title) + def to_representation(self, instance): + ret = super().to_representation(instance) + lang = get_request_lang(self) + ret['title'] = get_localized_field(lang, instance.title) + ret['description'] = get_localized_field(lang, instance.description) + return ret + class AdminQuestionSerializer(serializers.ModelSerializer): + question_i18n = serializers.JSONField(source='question', read_only=True) + option1_i18n = serializers.JSONField(source='option1', read_only=True) + option2_i18n = serializers.JSONField(source='option2', read_only=True) + option3_i18n = serializers.JSONField(source='option3', read_only=True) + option4_i18n = serializers.JSONField(source='option4', read_only=True) + class Meta: model = Question fields = [ 'id', 'quiz', 'question', + 'question_i18n', 'option1', + 'option1_i18n', 'option2', + 'option2_i18n', 'option3', + 'option3_i18n', 'option4', + 'option4_i18n', 'correct_answer', 'priority' ] + def _get_localized_value(self, value): + lang = get_request_lang(self) + return get_localized_field(lang, value) + + def to_representation(self, instance): + ret = super().to_representation(instance) + ret['question'] = self._get_localized_value(instance.question) + ret['option1'] = self._get_localized_value(instance.option1) + ret['option2'] = self._get_localized_value(instance.option2) + ret['option3'] = self._get_localized_value(instance.option3) + ret['option4'] = self._get_localized_value(instance.option4) + return ret + class AdminParticipantAnswerSerializer(serializers.ModelSerializer): class Meta: diff --git a/apps/quiz/serializers/quiz.py b/apps/quiz/serializers/quiz.py index e0074b1..d48369e 100644 --- a/apps/quiz/serializers/quiz.py +++ b/apps/quiz/serializers/quiz.py @@ -1,6 +1,7 @@ from rest_framework import serializers from apps.course.access import user_has_course_access +from apps.course.models.course import get_localized_field from apps.quiz.models import Question, Quiz, QuizParticipant @@ -12,11 +13,23 @@ from apps.quiz.models import Question, Quiz, QuizParticipant class QuizListSerializer(serializers.ModelSerializer): is_complated = serializers.SerializerMethodField() permission = serializers.SerializerMethodField() + title = serializers.SerializerMethodField() + description = serializers.SerializerMethodField() class Meta: model = Quiz fields = ['id', 'title', 'description', 'permission', 'each_question_timing', 'is_complated'] + def get_title(self, obj): + request = self.context.get('request') + lang = getattr(request, 'LANGUAGE_CODE', None) or 'en' + return get_localized_field(lang, obj.title) + + def get_description(self, obj): + request = self.context.get('request') + lang = getattr(request, 'LANGUAGE_CODE', None) or 'en' + return get_localized_field(lang, obj.description) + def get_permission(self, obj): request = self.context.get('request') if not request or not request.user.is_authenticated: @@ -48,12 +61,20 @@ class QuizListSerializer(serializers.ModelSerializer): class QuestionSerializer(serializers.ModelSerializer): options = serializers.SerializerMethodField() + question = serializers.SerializerMethodField() + + def get_question(self, obj): + request = self.context.get('request') + lang = getattr(request, 'LANGUAGE_CODE', None) or 'en' + return get_localized_field(lang, obj.question) def get_options(self, obj) -> list: + request = self.context.get('request') + lang = getattr(request, 'LANGUAGE_CODE', None) or 'en' return [ { 'id': i, - 'title': getattr(obj, f"option{i}") + 'title': get_localized_field(lang, getattr(obj, f"option{i}")) } for i in range(1, 5) ] @@ -66,11 +87,23 @@ class QuizSerializer(serializers.ModelSerializer): lesson = serializers.PrimaryKeyRelatedField(read_only=True) questions = QuestionSerializer(many=True) permission = serializers.SerializerMethodField() + title = serializers.SerializerMethodField() + description = serializers.SerializerMethodField() class Meta: model = Quiz fields = ['id', 'permission', 'lesson', 'title', 'description', 'each_question_timing', 'questions'] + def get_title(self, obj): + request = self.context.get('request') + lang = getattr(request, 'LANGUAGE_CODE', None) or 'en' + return get_localized_field(lang, obj.title) + + def get_description(self, obj): + request = self.context.get('request') + lang = getattr(request, 'LANGUAGE_CODE', None) or 'en' + return get_localized_field(lang, obj.description) + def get_permission(self, obj): request = self.context.get('request') if not request or not request.user.is_authenticated: diff --git a/apps/quiz/views/admin.py b/apps/quiz/views/admin.py index 9e6b7d0..b046fd7 100644 --- a/apps/quiz/views/admin.py +++ b/apps/quiz/views/admin.py @@ -68,6 +68,21 @@ def find_matching_lesson_ids(search_term: str): return matching_ids +def find_matching_quiz_ids(search_term: str): + normalized = (search_term or "").strip().lower() + if not normalized: + return [] + + matching_ids = [] + for quiz in Quiz.objects.all().only("id", "title", "description"): + title_text = extract_text_from_json(quiz.title).lower() + description_text = extract_text_from_json(quiz.description).lower() + if normalized in title_text or normalized in description_text: + matching_ids.append(quiz.id) + + return matching_ids + + class AdminQuizViewSet(ModelViewSet): """ Admin CRUD ViewSet for Quiz management. @@ -96,15 +111,15 @@ class AdminQuizViewSet(ModelViewSet): has_search = True matching_course_ids = find_matching_course_ids(search_query) matching_lesson_ids = find_matching_lesson_ids(search_query) + matching_quiz_ids = find_matching_quiz_ids(search_query) from django.db.models import Case, When, Value, IntegerField queryset = queryset.filter( - Q(title__icontains=search_query) | - Q(description__icontains=search_query) | + Q(id__in=matching_quiz_ids) | Q(course_id__in=matching_course_ids) | Q(lesson_id__in=matching_lesson_ids) ).annotate( search_relevance=Case( - When(title__icontains=search_query, then=Value(2)), + When(id__in=matching_quiz_ids, then=Value(2)), default=Value(1), output_field=IntegerField() )