From 0376e4400349d12b6f83758d129563f82398d763 Mon Sep 17 00:00:00 2001 From: alireza Date: Fri, 29 Nov 2024 20:50:36 +0330 Subject: [PATCH] update --- .env.prod | 2 +- .../0004_roommessage_unread_messages_count.py | 18 +++ apps/chat/models.py | 3 +- apps/course/admin/course.py | 3 +- apps/course/doc.py | 30 +++- .../0005_participant_unread_messages_count.py | 18 +++ apps/course/models/participant.py | 3 +- apps/course/serializers/lesson.py | 15 +- apps/course/views/lesson.py | 14 +- apps/quiz/admin.py | 3 - apps/quiz/admin/__init__.py | 5 + apps/quiz/admin/participant.py | 61 ++++++++ apps/quiz/admin/question.py | 63 +++++++++ apps/quiz/admin/quiz.py | 54 +++++++ apps/quiz/admin/user_rank_quiz.py | 132 ++++++++++++++++++ apps/quiz/apps.py | 2 +- apps/quiz/doc.py | 131 +++++++++++++++++ apps/quiz/migrations/0001_initial.py | 102 ++++++++++++++ apps/quiz/migrations/0002_quiz_description.py | 18 +++ apps/quiz/models/__init__.py | 2 + apps/quiz/models/participant.py | 6 +- apps/quiz/models/quiz.py | 14 +- apps/quiz/serializers/__init__.py | 2 + apps/quiz/serializers/participant.py | 45 ++++++ apps/quiz/serializers/quiz.py | 59 ++++++++ apps/quiz/urls.py | 13 ++ apps/quiz/views/__init__.py | 2 + apps/quiz/views/participant.py | 22 +++ apps/quiz/views/quiz.py | 33 +++++ config/settings/base.py | 1 + config/urls.py | 1 + 31 files changed, 849 insertions(+), 28 deletions(-) create mode 100644 apps/chat/migrations/0004_roommessage_unread_messages_count.py create mode 100644 apps/course/migrations/0005_participant_unread_messages_count.py delete mode 100644 apps/quiz/admin.py create mode 100644 apps/quiz/admin/__init__.py create mode 100644 apps/quiz/admin/participant.py create mode 100644 apps/quiz/admin/question.py create mode 100644 apps/quiz/admin/quiz.py create mode 100644 apps/quiz/admin/user_rank_quiz.py create mode 100644 apps/quiz/doc.py create mode 100644 apps/quiz/migrations/0001_initial.py create mode 100644 apps/quiz/migrations/0002_quiz_description.py create mode 100644 apps/quiz/serializers/__init__.py create mode 100644 apps/quiz/serializers/participant.py create mode 100644 apps/quiz/serializers/quiz.py create mode 100644 apps/quiz/urls.py create mode 100644 apps/quiz/views/__init__.py create mode 100644 apps/quiz/views/participant.py create mode 100644 apps/quiz/views/quiz.py diff --git a/.env.prod b/.env.prod index 5ee9c9b..034e084 100644 --- a/.env.prod +++ b/.env.prod @@ -1,4 +1,4 @@ -DJANGO_ALLOWED_HOSTS=127.0.0.1,imam-javad.nwhco.ir,www.imam-javad.nwhco.ir,*.nwhco.ir,188.40.92.124,88.99.212.243 +DJANGO_ALLOWED_HOSTS=127.0.0.1,imamjavad.nwhco.ir,www.imamjavad.nwhco.ir,*.nwhco.ir,188.40.92.124,88.99.212.243 DJANGO_SETTINGS_MODULE=config.settings.production diff --git a/apps/chat/migrations/0004_roommessage_unread_messages_count.py b/apps/chat/migrations/0004_roommessage_unread_messages_count.py new file mode 100644 index 0000000..5811ba2 --- /dev/null +++ b/apps/chat/migrations/0004_roommessage_unread_messages_count.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2024-11-26 01:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0003_auto_20241125_1737'), + ] + + operations = [ + migrations.AddField( + model_name='roommessage', + name='unread_messages_count', + field=models.IntegerField(default=0), + ), + ] diff --git a/apps/chat/models.py b/apps/chat/models.py index ecd8d89..d5263dc 100644 --- a/apps/chat/models.py +++ b/apps/chat/models.py @@ -46,7 +46,8 @@ class RoomMessage(models.Model): default=RoomTypeChoices.GROUP, verbose_name="Room Type" ) - + unread_messages_count = models.IntegerField(default=0) + def __str__(self): if self.room_type == self.RoomTypeChoices.GROUP: return f"Group Room: {self.course.title if self.course else 'N/A'}" diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py index b75ba99..f987c6f 100644 --- a/apps/course/admin/course.py +++ b/apps/course/admin/course.py @@ -42,7 +42,8 @@ class CourseAdmin(AjaxDatatable): search_fields = ('title', 'description') exclude = ('slug', ) - + # def has_change_permission(self, request, obj=None): + # return False # @admin.display(description='Add Student') # def _add_student(self, obj): diff --git a/apps/course/doc.py b/apps/course/doc.py index d7b2e39..55e929d 100644 --- a/apps/course/doc.py +++ b/apps/course/doc.py @@ -51,6 +51,11 @@ def doc_courses_lesson(): (مقدار is_complated مشخص میکند آیا کاربر این درس را گذرانده است ممکن است درس دارای کوعیز باشد که باید در زیر آ» مانند طرح نمایش داده شود ) + +دارای ابجکت کوعیز که لیستی از کوعیز های مربوط به یک درس را نمایش میدهد +بایستی مانند طرح در زیر درس قرار داده شود +و دارای مقدار permission +است که مشخص میکند ایا این کاربر کوعیز را از قبل شرکت کرده است --- ``` @@ -81,12 +86,27 @@ def doc_courses_lesson(): "content_file": null, "video_link": "https://example.com/videos/variables_intro.mp4", "is_complated": true, - "quiz": { - "title": "", - "description": "", - "is_complated": "", - } + "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 } + ] ``` """ diff --git a/apps/course/migrations/0005_participant_unread_messages_count.py b/apps/course/migrations/0005_participant_unread_messages_count.py new file mode 100644 index 0000000..acf281f --- /dev/null +++ b/apps/course/migrations/0005_participant_unread_messages_count.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2024-11-26 01:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0004_auto_20241122_1913'), + ] + + operations = [ + migrations.AddField( + model_name='participant', + name='unread_messages_count', + field=models.IntegerField(default=0), + ), + ] diff --git a/apps/course/models/participant.py b/apps/course/models/participant.py index 8dbd266..2e99a4e 100644 --- a/apps/course/models/participant.py +++ b/apps/course/models/participant.py @@ -18,7 +18,8 @@ class Participant(models.Model): related_name='participants' ) joined_date = models.DateTimeField(auto_now_add=True) - + unread_messages_count = models.IntegerField(default=0) + class Meta: unique_together = ('student', 'course') \ No newline at end of file diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py index 19dc90b..43c6f6b 100644 --- a/apps/course/serializers/lesson.py +++ b/apps/course/serializers/lesson.py @@ -1,8 +1,6 @@ - - from rest_framework import serializers from apps.course.models import Lesson, Participant, LessonCompletion - +from apps.quiz.serializers import QuizListSerializer @@ -10,11 +8,11 @@ from apps.course.models import Lesson, Participant, LessonCompletion class LessonSerializer(serializers.ModelSerializer): is_complated = serializers.SerializerMethodField() - quiz = serializers.SerializerMethodField() + quizs = serializers.SerializerMethodField() class Meta: model = Lesson - fields = ['id', 'title', 'priority', 'is_active', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quiz'] + fields = ['id', 'title', 'priority', 'is_active', 'duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs'] def get_is_complated(self, obj): request = self.context.get('request') @@ -35,5 +33,8 @@ class LessonSerializer(serializers.ModelSerializer): ).exists() - def get_quiz(self, obj): - return {} + def get_quizs(self, obj): + quizzes = obj.quizzes.all() # استفاده از related_name 'quizzes' برای دسترسی به کوییزهای درس + if quizzes.exists(): + return QuizListSerializer(quizzes, many=True, context=self.context).data + return None diff --git a/apps/course/views/lesson.py b/apps/course/views/lesson.py index 831d1cd..cec6a12 100644 --- a/apps/course/views/lesson.py +++ b/apps/course/views/lesson.py @@ -3,14 +3,14 @@ from rest_framework.generics import ListAPIView, RetrieveAPIView from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from django.shortcuts import get_object_or_404 - +from rest_framework import status from apps.course.serializers import ( LessonSerializer ) from apps.course.models import Course, Lesson from apps.course.doc import * - +from utils.exceptions import AppAPIException @@ -18,11 +18,17 @@ class LessonListView(ListAPIView): serializer_class = LessonSerializer queryset = Lesson.objects.filter(is_active=True) -# doc_courses_lesson @swagger_auto_schema( operation_description=doc_courses_lesson(), ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + def get_queryset(self): course_slug = self.kwargs.get('slug') - course = get_object_or_404(Course, slug=course_slug) + course = get_object_or_404(Course, ) + course = Course.objects.filter(slug=course_slug).first() + if not course: + raise AppAPIException({"message": "course not found"}, status_code=status.HTTP_404_NOT_FOUND) + return self.queryset.filter(course=course).order_by('priority','id') diff --git a/apps/quiz/admin.py b/apps/quiz/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/apps/quiz/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/apps/quiz/admin/__init__.py b/apps/quiz/admin/__init__.py new file mode 100644 index 0000000..42092ad --- /dev/null +++ b/apps/quiz/admin/__init__.py @@ -0,0 +1,5 @@ +from .quiz import * +from .question import * +from .participant import * +# from .prize import * +# from .user_rank_quiz import * \ No newline at end of file diff --git a/apps/quiz/admin/participant.py b/apps/quiz/admin/participant.py new file mode 100644 index 0000000..8775a8f --- /dev/null +++ b/apps/quiz/admin/participant.py @@ -0,0 +1,61 @@ +from ajaxdatatable.admin import AjaxDatatable +from django.contrib import admin +from django.db.models import F, Q +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ +from apps.quiz.models import QuizParticipant, ParticipantAnswer +from apps.account.models import User +import datetime + + +class ParticipantAnswerInline(admin.StackedInline): + model = ParticipantAnswer + + readonly_fields = ( + '_correct_answer', 'question', 'at_time', 'answer_timing', + ) + + def _correct_answer(self, obj): + return obj.correct_answer + + def has_add_permission(self, request, obj): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def get_queryset(self, request): + return super().get_queryset(request).annotate(correct_answer=F('question__correct_answer')) + + + + +class UserEmailFilter(SimpleListFilter): + title = _('User Email') + parameter_name = 'user_email' + + def lookups(self, request, model_admin): + users = User.objects.all() + return [(user.email, user.email) for user in users] + + def queryset(self, request, queryset): + if self.value(): + email = self.value().replace('%40', '@') + return queryset.filter(user__email=email) + return queryset + + +@admin.register(QuizParticipant) +class ParticipantAdmin(AjaxDatatable): + inlines = [ParticipantAnswerInline] + search_fields = ['user__username', 'user__fullname'] + list_display = ['quiz', 'user', 'started_at', 'ended_at', 'total_timing', 'question_score', 'timing_score', + 'total_score'] + latest_by = 'started_at' + list_filter = ['started_at', 'ended_at', 'quiz__status', UserEmailFilter] + + + + + + diff --git a/apps/quiz/admin/question.py b/apps/quiz/admin/question.py new file mode 100644 index 0000000..a46e063 --- /dev/null +++ b/apps/quiz/admin/question.py @@ -0,0 +1,63 @@ +from ajaxdatatable.admin import AjaxDatatable +from django import forms +from django.contrib import admin + +from apps.quiz.models import Question + + +class QuestionAdminForm(forms.ModelForm): + class Meta: + model = Question + exclude = () + widgets = { + 'correct_answer': forms.RadioSelect, + 'question': forms.Textarea + } + + +# @admin.register(Question) +# class QuestionAdmin(AjaxDatatable): +# list_display = ('question', 'correct_answer', 'quiz', 'priority') +# form = QuestionAdminForm +# ordering = ("priority", "id",) +# fieldsets = ( +# ( +# '', { +# 'fields': ( +# 'question', +# ('option1', 'option2'), +# ('option3', 'option4'), +# 'correct_answer', +# ) +# }, +# ), +# ( +# '', { +# 'fields': ('priority',) +# } +# ) +# ) + +class QuestionAdminInline(admin.StackedInline): + model = Question + list_display = ('question', 'correct_answer', 'quiz', 'priority') + form = QuestionAdminForm + ordering = ("priority", "id",) + extra = 0 + fieldsets = ( + ( + '', { + 'fields': ( + 'question', + ('option1', 'option2'), + ('option3', 'option4'), + 'correct_answer', + ) + }, + ), + ( + '', { + 'fields': ('priority',) + } + ) + ) diff --git a/apps/quiz/admin/quiz.py b/apps/quiz/admin/quiz.py new file mode 100644 index 0000000..9520416 --- /dev/null +++ b/apps/quiz/admin/quiz.py @@ -0,0 +1,54 @@ +from ajaxdatatable.admin import AjaxDatatable +from django.contrib import admin +from django.db.models import Count +from django.utils.safestring import mark_safe +from django.utils.html import format_html +from django.urls import reverse, path + +from apps.course.models import Lesson +from apps.quiz.models import Quiz +from .question import QuestionAdminInline + + + + +@admin.register(Quiz) +class QuizAdmin(AjaxDatatable): + search_fields = ['title', 'lesson__title'] + list_display = ['title', 'description','lesson','each_question_timing', '_status', '_questions',] + autocomplete_fields = ['lesson',] + list_filter = ['each_question_timing',] + inlines = [QuestionAdminInline,] + + + def get_queryset(self, request): + queryset = super().get_queryset(request) + if request.user.groups.filter(name="Professor Group").exists(): + return queryset.filter(lesson__course__professor=request.user) + + return queryset + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + if obj is None: + form.base_fields['lesson'].queryset = Lesson.objects.all() if request.user.is_staff else Lesson.objects.filter(course__professor=request.user) + form.base_fields['lesson'].widget.can_add_related = False + + return form + + + @admin.display(description='Status', ordering='status') + def _status(self, obj): + if obj.status: + return mark_safe("Active") + + return mark_safe("Inactive") + + @admin.display(description='Questions', ordering='questions_count') + def _questions(self, obj): + return mark_safe(f"Questions: {obj.questions_count}") + + def get_queryset(self, request): + return super().get_queryset(request).annotate( + questions_count=Count('questions') + ) diff --git a/apps/quiz/admin/user_rank_quiz.py b/apps/quiz/admin/user_rank_quiz.py new file mode 100644 index 0000000..e6b988d --- /dev/null +++ b/apps/quiz/admin/user_rank_quiz.py @@ -0,0 +1,132 @@ +# import calendar +# from django.utils import timezone + +# from ajaxdatatable.admin import AjaxDatatable +# from django.contrib import admin +# from django.utils.translation import gettext_lazy as _ +# from django.contrib.admin import SimpleListFilter +# from django.db.models.functions import Rank, Coalesce +# from django.db.models import Sum, F, Window, CharField +# from django.utils.html import format_html +# from apps.quiz.models import Quiz, QuizRankUser, Participant, QuizCategory +# from apps.account.models import User + + +# class QuizFilter(SimpleListFilter): +# title = _('Quiz') +# parameter_name = 'quiz' + +# def lookups(self, request, model_admin): +# quizzes = Quiz.objects.all() +# return [(quiz.id, quiz.video.title) for quiz in quizzes] + +# def queryset(self, request, queryset): +# if self.value(): +# return queryset.filter(uquizzes__quiz__id=self.value()) +# return queryset + +# class QuizCategoryFilter(SimpleListFilter): +# title = _('Quiz Category') +# parameter_name = 'quiz_category' + +# def lookups(self, request, model_admin): +# categories = QuizCategory.objects.all() +# return [(category.id, category.name) for category in categories] + +# def queryset(self, request, queryset): +# if self.value(): +# return queryset.filter(uquizzes__quiz__category__id=self.value()) +# return queryset + +# class MonthFilter(SimpleListFilter): +# title = _('Month') +# parameter_name = 'month' + +# def lookups(self, request, model_admin): +# return [(str(i), calendar.month_name[i]) for i in range(1, 13)] + +# def queryset(self, request, queryset): +# if self.value(): +# month = int(self.value()) +# year = timezone.now().year +# return queryset.filter(uquizzes__started_at__year=year, uquizzes__started_at__month=month) +# return queryset + + +# @admin.register(QuizRankUser) +# class QuizRankUserAdmin(AjaxDatatable): +# list_display = ('username_link', 'get_total_score', 'get_rank') +# list_filter = (QuizFilter, QuizCategoryFilter, MonthFilter) +# readonly_fields = ('date_joined', 'last_login') + + +# def get_queryset(self, request): +# queryset = super().get_queryset(request) + +# quiz_id = request.GET.get('quiz') +# category_id = request.GET.get('quiz_category') +# month = request.GET.get('month') + +# filters = {} +# if quiz_id: +# filters['uquizzes__quiz_id'] = quiz_id +# if category_id: +# filters['uquizzes__quiz__category_id'] = category_id +# if month: +# month = int(month) +# year = timezone.now().year +# filters['uquizzes__started_at__year'] = year +# filters['uquizzes__started_at__month'] = month + +# if filters: +# queryset = queryset.filter(**filters) + +# users_scores = Participant.objects.filter(**{k.replace('uquizzes__', ''): v for k, v in filters.items()}).select_related('user').values( +# username=Coalesce(F('user__username'), F('user__email'), output_field=CharField()) +# ).annotate( +# score=Sum('total_score') +# ).order_by('-score') + +# # Add rank to each user using window function +# users_scores = users_scores.annotate( +# rank=Window( +# expression=Rank(), +# order_by=F('score').desc() +# ) +# ).order_by("rank") + +# user_scores_dict = {user['username']: user for user in users_scores} +# for user in queryset: +# user.score = user_scores_dict.get(user.username, {}).get('score', 0) +# user.rank = user_scores_dict.get(user.username, {}).get('rank', 'N/A') +# self.queryset = queryset +# return queryset + +# def has_view_permission(self, request, obj=None): +# return True + +# def has_change_permission(self, request, obj=None): +# return False + +# def has_add_permission(self, request): +# return False + +# def has_delete_permission(self, request, obj=None): +# return False + +# def username_link(self, obj): +# return format_html('{}', obj.id, obj.username) +# username_link.short_description = 'Username' +# username_link.admin_order_field = 'username' + +# def get_total_score(self, obj): +# for user in self.queryset: +# if user.id == obj.id: +# return user.score +# get_total_score.short_description = 'Total Score' + +# def get_rank(self, obj): +# for user in self.queryset: +# if user.id == obj.id: +# return user.rank +# get_rank.short_description = 'Rank' diff --git a/apps/quiz/apps.py b/apps/quiz/apps.py index 3dc8afe..519127d 100644 --- a/apps/quiz/apps.py +++ b/apps/quiz/apps.py @@ -3,4 +3,4 @@ from django.apps import AppConfig class QuizConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'quiz' + name = 'apps.quiz' diff --git a/apps/quiz/doc.py b/apps/quiz/doc.py new file mode 100644 index 0000000..7099d7e --- /dev/null +++ b/apps/quiz/doc.py @@ -0,0 +1,131 @@ +def doc_quiz_submit(): + return """ +# 📝 ارسال پاسخ‌های کوییز + +این API برای ثبت شرکت کاربر در کوییز و ارسال پاسخ‌های مربوطه استفاده می‌شود. زمانی که کاربر در کوییز شرکت می‌کند، باید به همراه پاسخ‌های خود، زمان پاسخ‌دهی و اطلاعات دیگر را ارسال نماید. در این API، کاربر نمی‌تواند دوباره در همان کوییز شرکت کند. + +--- + +## 📄 توضیحات مقادیر پاسخ + +| کلید | نوع داده | توضیحات | +|------------------------|-----------------|---------------------------------------------------------| +| `quiz` | Integer | شناسه کوییز که کاربر در آن شرکت کرده است. | +| `started_at` | DateTime | زمان شروع کوییز. | +| `ended_at` | DateTime | زمان پایان کوییز. | +| `total_timing` | Integer | مدت زمان کلی که کاربر برای پاسخ‌دهی به کوییز صرف کرده است.| +| `question_score` | Integer | امتیاز به‌دست‌آمده توسط کاربر در پاسخ به سوالات کوییز. | +| `timing_score` | Integer | امتیاز به‌دست‌آمده توسط کاربر بر اساس زمان پاسخ‌دهی. | +| `total_score` | Integer | امتیاز کلی کاربر در کوییز (ترکیب امتیاز سوالات و زمان).| +| `answers` | Array | لیستی از پاسخ‌های کاربر به سوالات. | +| `answers.question` | Integer | شناسه سوالی که کاربر به آن پاسخ داده است. | +| `answers.option_num` | Integer | شماره گزینه‌ای که کاربر انتخاب کرده است. | +| `answers.at_time` | DateTime | زمانی که کاربر پاسخ به سوال را ارسال کرده است. | +| `answers.answer_timing`| Integer | مدت زمان پاسخ‌دهی به سوال (در ثانیه). | + +--- +## errors: +# کاربر از قبل کوعیز را شرکت کرده است +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "quiz", + "message": "you have already participated in the quiz" + } + ] +} +‍``` + +--- +## پاسخ موفق (201 Created) + +در صورتی که ثبت‌نام موفقیت‌آمیز باشد و پاسخ‌ها ذخیره شوند، یک شیء JSON مشابه با نمونه زیر برگشت داده می‌شود: + +### پاسخ: +```json +{ + "quiz": 1, + "started_at": "2024-11-29T12:00:00Z", + "ended_at": "2024-11-29T12:30:00Z", + "total_timing": 1800, + "question_score": 80, + "timing_score": 10, + "total_score": 90, + "answers": [ + { + "question": 1, + "option_num": 3, + "at_time": "2024-11-29T12:05:00Z", + "answer_timing": 30 + }, + { + "question": 2, + "option_num": 1, + "at_time": "2024-11-29T12:15:00Z", + "answer_timing": 45 + } + ] +} +""" + +def doc_quiz_detail(): + return """ +# 📋 Quiz Detail API + +با ایدی درس میتواند وارد یک کوعیز شوید +این api +سوالات کوعیز و جزعیاتش را برمیگرداند + + +## URL +`GET /path//` + +## پارامترها + +- `lesson_id`: شناسه درس برای دریافت کوییز مرتبط. + +## پاسخ موفق (200 OK) + +در صورتی که درس دارای کوییز باشد، یک شیء JSON با اطلاعات کوییز برگشت داده می‌شود. + +### پاسخ: + +```json +{ + "id": 1, + "permission": true, + "lesson": 101, + "title": "Quiz on Python Basics", + "description": "A quiz on the basics of Python programming.", + "each_question_timing": 30, + "questions": [ + { + "id": 1, + "question": "What is the output of print(2 + 3)?", + "options": [ + {"id": 1, "title": "5"}, + {"id": 2, "title": "6"}, + {"id": 3, "title": "7"}, + {"id": 4, "title": "8"} + ], + "correct_answer": 1 + }, + { + "id": 2, + "question": "What is the result of 2 * 3?", + "options": [ + {"id": 1, "title": "6"}, + {"id": 2, "title": "5"}, + {"id": 3, "title": "7"}, + {"id": 4, "title": "8"} + ], + "correct_answer": 1 + } + ] +} +""" diff --git a/apps/quiz/migrations/0001_initial.py b/apps/quiz/migrations/0001_initial.py new file mode 100644 index 0000000..44ea814 --- /dev/null +++ b/apps/quiz/migrations/0001_initial.py @@ -0,0 +1,102 @@ +# Generated by Django 3.2.4 on 2024-11-29 11:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('course', '0005_participant_unread_messages_count'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('account', '0004_user_skill'), + ] + + operations = [ + migrations.CreateModel( + name='Quiz', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(help_text='Quiz Title', max_length=255, verbose_name='title')), + ('each_question_timing', models.PositiveIntegerField()), + ('status', models.BooleanField(default=True)), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='course.lesson', verbose_name='lesson')), + ], + options={ + 'verbose_name': 'Quiz', + 'verbose_name_plural': 'Quizzes', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='QuizRankUser', + fields=[ + ], + options={ + 'verbose_name': 'Rank Quiz', + 'verbose_name_plural': 'Rank Quizzes', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='QuizParticipant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('started_at', models.DateTimeField(verbose_name='started at')), + ('ended_at', models.DateTimeField(verbose_name='ended at')), + ('total_timing', models.PositiveIntegerField(help_text='Seconds take to finish the quiz')), + ('question_score', models.PositiveIntegerField()), + ('timing_score', models.PositiveIntegerField()), + ('total_score', models.PositiveIntegerField()), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='quiz.quiz')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uquizzes', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'Participant', + 'verbose_name_plural': 'Participants', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question', models.CharField(max_length=255)), + ('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')), + ('correct_answer', models.PositiveSmallIntegerField(choices=[(1, 'Option 1'), (2, 'Option 2'), (3, 'Option 3'), (4, 'Option 4')])), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('priority', models.IntegerField(blank=True, null=True)), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='quiz.quiz', verbose_name='quiz')), + ], + options={ + 'verbose_name': 'Question', + 'verbose_name_plural': 'Questions', + 'ordering': ('-priority', '-id'), + }, + ), + migrations.CreateModel( + name='ParticipantAnswer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('option_num', models.PositiveSmallIntegerField(choices=[(1, 'Option 1'), (2, 'Option 2'), (3, 'Option 3'), (4, 'Option 4')], verbose_name='selected option')), + ('at_time', models.DateTimeField()), + ('answer_timing', models.PositiveSmallIntegerField(default=0, verbose_name='seconds take to answer')), + ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='quiz.quizparticipant')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='quiz.question')), + ], + options={ + 'verbose_name': 'User Quiz Answer', + 'verbose_name_plural': 'User Quiz Answers', + 'ordering': ('-id',), + }, + ), + ] diff --git a/apps/quiz/migrations/0002_quiz_description.py b/apps/quiz/migrations/0002_quiz_description.py new file mode 100644 index 0000000..a7b0f71 --- /dev/null +++ b/apps/quiz/migrations/0002_quiz_description.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2024-11-29 11:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('quiz', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='quiz', + name='description', + field=models.CharField(blank=True, max_length=55, null=True, verbose_name='Description'), + ), + ] diff --git a/apps/quiz/models/__init__.py b/apps/quiz/models/__init__.py index e69de29..16a10f1 100644 --- a/apps/quiz/models/__init__.py +++ b/apps/quiz/models/__init__.py @@ -0,0 +1,2 @@ +from .quiz import * +from .participant import * \ No newline at end of file diff --git a/apps/quiz/models/participant.py b/apps/quiz/models/participant.py index 8454bb5..305af43 100644 --- a/apps/quiz/models/participant.py +++ b/apps/quiz/models/participant.py @@ -5,7 +5,7 @@ from apps.account.models import User -class Participant(models.Model): +class QuizParticipant(models.Model): quiz = models.ForeignKey('quiz.Quiz', on_delete=models.CASCADE, related_name='participants') user = models.ForeignKey('account.User', on_delete=models.CASCADE, verbose_name='user', related_name='uquizzes') started_at = models.DateTimeField(verbose_name='started at') @@ -30,7 +30,7 @@ class Participant(models.Model): @staticmethod def get_user_ranks(quiz_id): - return Participant.objects.filter(quiz_id=quiz_id).annotate( + return QuizParticipant.objects.filter(quiz_id=quiz_id).annotate( rank=Window( expression=Rank(), order_by=F('total_score').desc() @@ -47,7 +47,7 @@ class ParticipantAnswer(models.Model): (4, 'Option 4'), ] - participant = models.ForeignKey(Participant, on_delete=models.CASCADE, related_name='answers') + participant = models.ForeignKey(QuizParticipant, on_delete=models.CASCADE, related_name='answers') question = models.ForeignKey("quiz.Question", on_delete=models.CASCADE) option_num = models.PositiveSmallIntegerField(choices=CHOICES, verbose_name='selected option') at_time = models.DateTimeField() diff --git a/apps/quiz/models/quiz.py b/apps/quiz/models/quiz.py index ec9972c..26bda6a 100644 --- a/apps/quiz/models/quiz.py +++ b/apps/quiz/models/quiz.py @@ -1,9 +1,13 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ +from apps.account.models import User class Quiz(models.Model): - course = models.ForeignKey("course.Course", verbose_name='course', related_name='quizzes', on_delete=models.CASCADE) + lesson = models.ForeignKey("course.Lesson", verbose_name=_('lesson'), related_name='quizzes', on_delete=models.CASCADE) + 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") each_question_timing = models.PositiveIntegerField() status = models.BooleanField(default=True) @@ -51,3 +55,11 @@ class Question(models.Model): def __repr__(self): return f"Question(id={self.id})" + + +class QuizRankUser(User): + class Meta: + proxy = True + verbose_name = 'Rank Quiz' + verbose_name_plural = 'Rank Quizzes' + diff --git a/apps/quiz/serializers/__init__.py b/apps/quiz/serializers/__init__.py new file mode 100644 index 0000000..16a10f1 --- /dev/null +++ b/apps/quiz/serializers/__init__.py @@ -0,0 +1,2 @@ +from .quiz import * +from .participant import * \ No newline at end of file diff --git a/apps/quiz/serializers/participant.py b/apps/quiz/serializers/participant.py new file mode 100644 index 0000000..2ec1280 --- /dev/null +++ b/apps/quiz/serializers/participant.py @@ -0,0 +1,45 @@ +from rest_framework import serializers + +from apps.quiz.models import QuizParticipant, ParticipantAnswer + + +class ParticipantAnswerSerializer(serializers.ModelSerializer): + class Meta: + model = ParticipantAnswer + fields = ['question', 'option_num', 'at_time', 'answer_timing'] + + + +class QuizParticipantSerializer(serializers.ModelSerializer): + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + answers = ParticipantAnswerSerializer(many=True) + + def validate_quiz(self, obj): + if QuizParticipant.objects.filter(quiz=obj, user=self.context['request'].user).exists(): + raise serializers.ValidationError('you have already participated in the quiz') + + return obj + + class Meta: + model = QuizParticipant + fields = [ + 'quiz', 'user', 'started_at', 'ended_at', 'total_timing', + 'question_score', 'timing_score', 'total_score', + 'answers', + ] + + def create(self, validated_data): + answers = validated_data.pop('answers', []) + obj = super().create(validated_data) + answers_objs = [] + for ans in answers: + answers_objs.append( + ParticipantAnswer( + participant=obj, + **ans, + ) + ) + + ParticipantAnswer.objects.bulk_create(answers_objs) + + return obj diff --git a/apps/quiz/serializers/quiz.py b/apps/quiz/serializers/quiz.py new file mode 100644 index 0000000..08c6c55 --- /dev/null +++ b/apps/quiz/serializers/quiz.py @@ -0,0 +1,59 @@ +from rest_framework import serializers + +from apps.quiz.models import Question, Quiz, QuizParticipant +from apps.course.models import Lesson + + + + + + + +class QuizListSerializer(serializers.ModelSerializer): + permission = serializers.SerializerMethodField() + + class Meta: + model = Quiz + fields = ['id', 'title', 'description', 'permission', 'each_question_timing',] + + def get_permission(self, obj): + # Check if the user has participated in this quiz + user = self.context['request'].user + participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists() + return not participated + + + + +class QuestionSerializer(serializers.ModelSerializer): + options = serializers.SerializerMethodField() + + def get_options(self, obj) -> list: + return [ + { + 'id': i, + 'title': getattr(obj, f"option{i}") + } 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) + questions = QuestionSerializer(many=True) + permission = serializers.SerializerMethodField() + + class Meta: + model = Quiz + fields = ['id', 'permission', 'lesson', 'title', 'description', 'each_question_timing', 'questions'] + + def get_permission(self, obj): + # Check if the user has participated in this quiz + user = self.context['request'].user + participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists() + return not participated + + diff --git a/apps/quiz/urls.py b/apps/quiz/urls.py new file mode 100644 index 0000000..1245fea --- /dev/null +++ b/apps/quiz/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + # path('prizes/', PrizeListAPIView.as_view()), + # path('ranked-list/', RankedListAPIView.as_view()), + # path('self-rank/', SelfRankAPIView.as_view()), + # path('my-quizzes/', UserQuizScores.as_view()), + path('submit-quiz/', views.QuizParticipantCreateAPIView.as_view()), + path('/', views.QuizDetailAPIView.as_view()), + +] diff --git a/apps/quiz/views/__init__.py b/apps/quiz/views/__init__.py new file mode 100644 index 0000000..16a10f1 --- /dev/null +++ b/apps/quiz/views/__init__.py @@ -0,0 +1,2 @@ +from .quiz import * +from .participant import * \ No newline at end of file diff --git a/apps/quiz/views/participant.py b/apps/quiz/views/participant.py new file mode 100644 index 0000000..2a0504d --- /dev/null +++ b/apps/quiz/views/participant.py @@ -0,0 +1,22 @@ +from django.db.models import Value +from rest_framework.generics import RetrieveAPIView, CreateAPIView +from rest_framework.permissions import IsAuthenticated + +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from apps.quiz.serializers import QuizParticipantSerializer +from apps.quiz.doc import * + + + +class QuizParticipantCreateAPIView(CreateAPIView): + serializer_class = QuizParticipantSerializer + permission_classes = [IsAuthenticated] + + + @swagger_auto_schema( + operation_description=doc_quiz_submit(), + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) diff --git a/apps/quiz/views/quiz.py b/apps/quiz/views/quiz.py new file mode 100644 index 0000000..507d25c --- /dev/null +++ b/apps/quiz/views/quiz.py @@ -0,0 +1,33 @@ +from django.db.models import Value +from rest_framework.generics import RetrieveAPIView +from rest_framework.permissions import IsAuthenticated + +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from apps.quiz.models import Quiz +from apps.quiz.serializers.quiz import QuizSerializer +from apps.quiz.doc import * + + + +class QuizDetailAPIView(RetrieveAPIView): + serializer_class = QuizSerializer + permission_classes = [IsAuthenticated] + + + @swagger_auto_schema( + operation_description=doc_quiz_detail(), + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_object(self): + return Quiz.objects.filter( + lesson__id=self.kwargs['lesson_id'], + ).annotate( + lesson__has_quiz=Value(True) + ).select_related('lesson').first() + + + + diff --git a/config/settings/base.py b/config/settings/base.py index 99005bc..8df4188 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -44,6 +44,7 @@ LOCAL_APPS = [ 'apps.api.apps.ApiConfig', 'apps.course.apps.CourseConfig', 'apps.chat.apps.ChatConfig', + 'apps.quiz.apps.QuizConfig', ] THIRD_PARTY_APPS = [ diff --git a/config/urls.py b/config/urls.py index 0eaccb1..34860d6 100644 --- a/config/urls.py +++ b/config/urls.py @@ -36,6 +36,7 @@ api_patterns = [ path('account/', include('apps.account.urls')), path('courses/', include('apps.course.urls')), + path('quiz/', include('apps.quiz.urls')), path('upload-tmp-media/', UploadTmpMedia.as_view()), ]