From 2545b81fb111c52f19e3f3f278bda588c499a9a8 Mon Sep 17 00:00:00 2001 From: Yunho Ding Date: Mon, 27 Jan 2025 15:29:54 +0000 Subject: [PATCH 01/17] modify the behaviour of quiz_attempt, add quiz availability check --- ...empt_team_alter_quizattempt_time_finish.py | 32 +++ .../0011_alter_quizattempt_student.py | 27 ++ .../0012_alter_quizattempt_student.py | 26 ++ server/api/quiz/models.py | 26 +- server/api/quiz/serializers.py | 3 + server/api/quiz/views.py | 262 +++++++++++++++--- server/api/users/serializers.py | 29 +- 7 files changed, 344 insertions(+), 61 deletions(-) create mode 100644 server/api/quiz/migrations/0010_alter_quizattempt_team_alter_quizattempt_time_finish.py create mode 100644 server/api/quiz/migrations/0011_alter_quizattempt_student.py create mode 100644 server/api/quiz/migrations/0012_alter_quizattempt_student.py diff --git a/server/api/quiz/migrations/0010_alter_quizattempt_team_alter_quizattempt_time_finish.py b/server/api/quiz/migrations/0010_alter_quizattempt_team_alter_quizattempt_time_finish.py new file mode 100644 index 00000000..5fa71b95 --- /dev/null +++ b/server/api/quiz/migrations/0010_alter_quizattempt_team_alter_quizattempt_time_finish.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.5 on 2025-01-27 04:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("quiz", "0009_quizattempt_team"), + ("team", "0003_alter_teammember_student_alter_teammember_team"), + ] + + operations = [ + migrations.AlterField( + model_name="quizattempt", + name="team", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="quiz_attempts", + to="team.team", + ), + ), + migrations.AlterField( + model_name="quizattempt", + name="time_finish", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/server/api/quiz/migrations/0011_alter_quizattempt_student.py b/server/api/quiz/migrations/0011_alter_quizattempt_student.py new file mode 100644 index 00000000..882157bc --- /dev/null +++ b/server/api/quiz/migrations/0011_alter_quizattempt_student.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.5 on 2025-01-27 14:22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("quiz", "0010_alter_quizattempt_team_alter_quizattempt_time_finish"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="quizattempt", + name="student", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="quiz_attempts", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/server/api/quiz/migrations/0012_alter_quizattempt_student.py b/server/api/quiz/migrations/0012_alter_quizattempt_student.py new file mode 100644 index 00000000..2ee9a95f --- /dev/null +++ b/server/api/quiz/migrations/0012_alter_quizattempt_student.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.5 on 2025-01-27 14:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("quiz", "0011_alter_quizattempt_student"), + ("users", "0002_school_is_country_school_type_student_attendent_year"), + ] + + operations = [ + migrations.AlterField( + model_name="quizattempt", + name="student", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="quiz_attempts", + to="users.student", + ), + ), + ] diff --git a/server/api/quiz/models.py b/server/api/quiz/models.py index 440ed4c5..eb5b3b4f 100644 --- a/server/api/quiz/models.py +++ b/server/api/quiz/models.py @@ -2,6 +2,8 @@ from api.team.models import Team from api.users.models import Student from api.question.models import Question +from django.utils.timezone import now +from datetime import timedelta class Quiz(models.Model): @@ -87,14 +89,16 @@ class State(models.IntegerChoices): Student, on_delete=models.CASCADE, related_name="quiz_attempts", default=None, null=True ) current_page = models.IntegerField() - state = models.IntegerField(choices=State.choices, default=State.UNATTEMPTED) + state = models.IntegerField( + choices=State.choices, default=State.UNATTEMPTED) time_start = models.DateTimeField(auto_now_add=True) - time_finish = models.DateTimeField(null=True) + time_finish = models.DateTimeField(null=True, blank=True) time_modified = models.DateTimeField(auto_now=True) total_marks = models.IntegerField() team = models.ForeignKey( - Team, on_delete=models.CASCADE, related_name="quiz_attempts", default=None, null=True + Team, on_delete=models.CASCADE, related_name="quiz_attempts", default=None, null=True, blank=True ) + # dead_line = models.DateTimeField(default=None, null=True, blank=True) def __str__(self): return f"{self.id} {self.quiz} " @@ -104,6 +108,20 @@ def check_all_answer(self): question_attempt.check_answer() question_attempt.save() + @property + def is_available(self): + current_time = now() + end_time = self.quiz.open_time_date + \ + timedelta(minutes=self.quiz.time_limit) + \ + timedelta(minutes=self.quiz.time_window) + end_time = min(end_time, self.time_start + + timedelta(minutes=self.quiz.time_limit)) + is_available = self.quiz.open_time_date <= current_time <= end_time + if not is_available: + self.state = QuizAttempt.State.COMPLETED + self.save() + return is_available + class QuestionAttempt(models.Model): id = models.AutoField(primary_key=True) @@ -117,7 +135,7 @@ class QuestionAttempt(models.Model): is_correct = models.BooleanField(default=None) def __str__(self): - return f"{self.id} {self.question_id} {self.quiz_attempt_id}" + return f"{self.id} {self.question} {self.quiz_attempt}" def check_answer(self): if self.answer_student == self.question.answer: diff --git a/server/api/quiz/serializers.py b/server/api/quiz/serializers.py index cdca3d25..f8aa5f46 100644 --- a/server/api/quiz/serializers.py +++ b/server/api/quiz/serializers.py @@ -53,6 +53,9 @@ class Meta: class QuizAttemptSerializer(serializers.ModelSerializer): + current_page = serializers.IntegerField(default=0, required=False) + total_marks = serializers.IntegerField(default=0, required=False) + class Meta: model = QuizAttempt fields = '__all__' diff --git a/server/api/quiz/views.py b/server/api/quiz/views.py index 703a69ac..7a8301ba 100644 --- a/server/api/quiz/views.py +++ b/server/api/quiz/views.py @@ -1,7 +1,8 @@ -from rest_framework import viewsets +from rest_framework import viewsets, mixins from .models import Quiz, QuizSlot, QuizAttempt, QuestionAttempt +from rest_framework.viewsets import GenericViewSet from rest_framework.decorators import action, permission_classes -from rest_framework.permissions import IsAdminUser, AllowAny +from rest_framework.permissions import IsAdminUser, AllowAny, IsAuthenticated from .serializers import QuizSerializer, QuizSlotSerializer, QuizAttemptSerializer, QuestionAttemptSerializer, AdminQuizSerializer from rest_framework.response import Response from rest_framework import status, serializers @@ -79,9 +80,11 @@ def slots(self, request, pk=None): return Response({'error': 'Quiz not exist'}, status=status.HTTP_404_NOT_FOUND) +@permission_classes([IsAuthenticated]) class CompetistionQuizViewSet(viewsets.ReadOnlyModelViewSet): """ A viewset for retrieving competition quizzes that are visible and have a status of 1. + Need to be tested. Methods: slots: Retrieve slots for a specific competition quiz. @@ -101,29 +104,106 @@ def slots(self, request, pk=None): Returns: Response: The response object containing the slots data. """ - if request.method == 'GET': - quiz_instance = Quiz.objects.get(pk=pk) + user = request.user + student_id = user.student.id + existing_attempt = QuizAttempt.objects.filter( + quiz_id=pk, student_id=student_id, state=2).first() + + quiz_instance = Quiz.objects.get(pk=pk) + if quiz_instance.status == 3: + return Response({'error': 'Quiz has finished'}, status=status.HTTP_404_NOT_FOUND) + + is_available = self._is_available(quiz_instance, existing_attempt) + if is_available is True: + return self._get_slots_response(pk, existing_attempt, user) + else: + return is_available + + def _is_available(self, quiz_instance, attempt): + """ + Check if the quiz is available for the user. + + Args: + quiz_instance (Quiz): The quiz instance. + attempt (QuizAttempt): The existing quiz attempt. + + Returns: + bool or Response: True if available, otherwise a Response with an error message. + """ + if quiz_instance.visible: current_time = now() start_time = quiz_instance.open_time_date - end_time = start_time + timedelta(minutes=quiz_instance.time_limit) - print(quiz_instance.visible, quiz_instance.status) - - if quiz_instance.visible and (quiz_instance.status == 1 or quiz_instance.status == 3): - - # check if the quiz is opening - is_opening = start_time <= current_time <= end_time - - if is_opening: - self.serializer_class = QuizSlotSerializer - instance = QuizSlot.objects.filter(quiz_id=pk) - serializer = QuizSlotSerializer(instance, many=True) - return Response(serializer.data) - elif quiz_instance.status == 3: - return Response({'error': 'Quiz has finished'}, status=status.HTTP_404_NOT_FOUND) - else: - return Response({'error': 'Quiz is not accessible at this time'}, status=status.HTTP_403_FORBIDDEN) + end_time = start_time + \ + timedelta(minutes=quiz_instance.time_limit) + \ + timedelta(minutes=quiz_instance.time_window) + + if attempt is None: + if start_time <= current_time <= end_time: + return True + elif current_time < start_time: + return Response({'error': 'Quiz has not started yet'}, status=status.HTTP_403_FORBIDDEN) + elif current_time > end_time: + return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) else: - return Response({'error': 'Quiz not exist'}, status=status.HTTP_404_NOT_FOUND) + return self._check_attempt_state(attempt, start_time, end_time) + else: + return Response({'error': 'Quiz not exist'}, status=status.HTTP_404_NOT_FOUND) + + def _check_attempt_state(self, attempt, start_time, end_time): + """ + Check the state of the existing quiz attempt. + + Args: + attempt (QuizAttempt): The existing quiz attempt. + start_time (datetime): The start time of the quiz. + end_time (datetime): The end time of the quiz. + + Returns: + bool or Response: True if available, otherwise a Response with an error message. + """ + current_time = now() + if attempt.state == 2: + start_time = attempt.time_start + end_time = min( + start_time + timedelta(minutes=attempt.quiz.time_limit), end_time) + if start_time <= current_time <= end_time: + return True + elif current_time < start_time: + return Response({'error': 'Quiz has not started yet'}, status=status.HTTP_403_FORBIDDEN) + elif current_time > end_time: + attempt.state = 3 + attempt.save() + return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) + else: + attempt.state = 3 + attempt.save() + return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) + + def _get_slots_response(self, quiz_id, existing_attempt, user): + """ + Get the response containing the slots data. + + Args: + quiz_id (int): The primary key of the quiz. + existing_attempt (QuizAttempt): The existing quiz attempt. + user (User): The current user. + + Returns: + Response: The response object containing the slots data. + """ + if existing_attempt is None: + quiz_attempt_serializer = QuizAttemptSerializer(data={ + 'quiz': quiz_id, + 'student': user.student.id, + 'state': 2, + }) + quiz_attempt_serializer.is_valid(raise_exception=True) + quiz_attempt_serializer.save() + self.serializer_class = QuizSlotSerializer + instances = QuizSlot.objects.filter(quiz_id=quiz_id) + serializer = QuizSlotSerializer(instances, many=True) + + return Response(serializer.data, status=status.HTTP_200_OK) class QuizSlotViewSet(viewsets.ModelViewSet): @@ -134,6 +214,7 @@ class QuizSlotViewSet(viewsets.ModelViewSet): serializer_class = QuizSlotSerializer +@permission_classes([IsAuthenticated]) class QuizAttemptViewSet(viewsets.ModelViewSet): """ A viewset for managing quiz attempts. @@ -141,6 +222,23 @@ class QuizAttemptViewSet(viewsets.ModelViewSet): queryset = QuizAttempt.objects.all() serializer_class = QuizAttemptSerializer + def get_queryset(self): + if hasattr(self.request.user, "student"): + student_id = self.request.user.student.id + return QuizAttempt.objects.filter(student_id=student_id) + elif hasattr(self.request.user, "teacher"): + return QuizAttempt.objects.filter(student__school=self.request.user.teacher.school.id) + elif self.request.user.is_staff: + return QuizAttempt.objects.all() + else: + return QuizAttempt.objects.none() + + # def list(self, request, *args, **kwargs): + # if request.user.is_staff: + # return super().list(request, *args, **kwargs) + # else: + # return Response({'error': 'You are not authorized to perform this action.'}, status=status.HTTP_403_FORBIDDEN) + def create(self, request, *args, **kwargs): """ Create a new quiz attempt. Ensure that a user can only have one active attempt per quiz. @@ -148,14 +246,54 @@ def create(self, request, *args, **kwargs): quiz_id = request.data.get('quiz') student_id = request.data.get('student') existing_attempt = QuizAttempt.objects.filter( - quiz_id=quiz_id, student_id=student_id, state=1).first() + quiz_id=quiz_id, student_id=student_id, state=2).first() if existing_attempt: - return Response({'error': 'You already have an active attempt for this quiz.'}, status=status.HTTP_400_BAD_REQUEST) + serializer = self.get_serializer(existing_attempt) + data = serializer.data + + # switch cases by state + match existing_attempt.state: + case 2: + # return the existing attempt + return Response({'message': 'You already have an active attempt for this quiz.', 'data': data}, status=status.HTTP_200_OK) + case 3: # submitted + # prevent the user from creating a new attempt + return Response({'error': 'You have already submitted this quiz.'}, status=status.HTTP_403_FORBIDDEN) + case 4: # completed + # prevent the user from creating a new attempt + return Response({'error': 'The competition has ended.'}, status=status.HTTP_403_FORBIDDEN) + + def update(self, request, *args, **kwargs): + instance = self.get_object() + match instance.state: + case 2: + data = request.data.copy() + if int(data.get('state')) == 3: + # set the time_finish to the current time + data['time_finish'] = now() + + serializer = self.get_serializer(instance, data=data) + serializer.is_valid(raise_exception=True) + instance = serializer.save() + + return Response(serializer.data, status=status.HTTP_200_OK) + + case 3: + print(now()) + return Response({'error': 'You have already submitted this quiz.'}, status=status.HTTP_403_FORBIDDEN) + case 4: + return Response({'error': 'The competition has ended.'}, status=status.HTTP_403_FORBIDDEN) + + # if existing_attempt.state == 2: + # existing_attempt.state = 0 + # existing_attempt.save() + # existing_attempt.save() + # return Response({'message': 'Answer updated successfully.'}, status=status.HTTP_200_OK) return super().create(request, *args, **kwargs) - @action(detail=True, methods=['patch']) + @action(detail=True, methods=['get']) def submit(self, request, pk=None): """ Submit the quiz attempt, changing its state to 2 (submitted). @@ -167,44 +305,78 @@ def submit(self, request, pk=None): return Response({'status': 'Quiz attempt submitted successfully.'}) -class QuestionAttemptViewSet(viewsets.ModelViewSet): +@permission_classes([IsAuthenticated]) +class QuestionAttemptViewSet(mixins.CreateModelMixin, + mixins.ListModelMixin, + GenericViewSet): """ A viewset for managing question attempts. """ queryset = QuestionAttempt.objects.all() serializer_class = QuestionAttemptSerializer + def get_queryset(self): + if hasattr(self.request.user, "student"): + return QuestionAttempt.objects.filter(student=self.request.user.student.id) + elif self.request.user.is_staff: + return QuestionAttempt.objects.all() + else: + return QuestionAttempt.objects.none() + def create(self, request, *args, **kwargs): """ Create a new question attempt. Ensure that a user can continue answering questions upon re-login. """ + quiz_attempt_id = request.data.get('quiz_attempt') + comp_attempt = QuizAttempt.objects.get( + pk=quiz_attempt_id, student_id=request.user.student.id) + + # check if the quiz is available for the user + if comp_attempt.is_available: + return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) + question_id = request.data.get('question') - student_id = request.data.get('student') + student_id = int(request.data.get('student')) # TODO: Uncomment this line when using JWT authentication # student_id = request.user.id + current_user = request.user + + if int(student_id) != int(current_user.student.id) and not current_user.is_staff: + return Response({'error': 'You are not authorized to perform this action.'}, status=status.HTTP_403_FORBIDDEN) + existing_attempt = QuestionAttempt.objects.filter( - quiz_attempt_id=quiz_attempt_id, - question_id=question_id, - student_id=student_id + quiz_attempt=quiz_attempt_id, + question=question_id, + student=student_id ).first() if existing_attempt: - return Response( - {'error': 'You already have an attempt for this question in the current quiz attempt.'}, - status=status.HTTP_400_BAD_REQUEST) + # modify the answer + existing_attempt.answer_student = request.data.get( + 'answer_student') + existing_attempt.save() + new_answer = QuestionAttemptSerializer( + existing_attempt).data['answer_student'] + return Response({'message': 'Answer updated successfully.', + 'new_answer': new_answer}, status=status.HTTP_200_OK) return super().create(request, *args, **kwargs) - @action(detail=True, methods=['patch']) - def answer(self, request, pk=None): - """ - Record the answer for a question attempt. - """ - # TODO: Uncomment this line when using JWT authentication - # student_id = request.user.id - question_attempt = self.get_object() - question_attempt.answer_student = request.data.get('answer_student') - question_attempt.check_answer() - question_attempt.save() - return Response({'status': 'Answer recorded successfully.'}) + def _is_comp_available(self, quiz_instance): + # check if the quiz is available for the user + if quiz_instance.visible: + current_time = now() + start_time = quiz_instance.open_time_date + end_time = start_time + \ + timedelta(minutes=quiz_instance.time_limit) + \ + timedelta(minutes=quiz_instance.time_window) + + if start_time <= current_time <= end_time: + return True + elif current_time < start_time: + return Response({'error': 'Quiz has not started yet'}, status=status.HTTP_403_FORBIDDEN) + elif current_time > end_time: + return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) + else: + return Response({'error': 'Quiz not exist'}, status=status.HTTP_404_NOT_FOUND) diff --git a/server/api/users/serializers.py b/server/api/users/serializers.py index 3aba033a..53d0e78f 100644 --- a/server/api/users/serializers.py +++ b/server/api/users/serializers.py @@ -30,21 +30,26 @@ class Meta: read_only_fields = ['last_login', 'date_joined'] def create(self, validated_data): - """ - Creates a new User instance. - - Args: - validated_data (dict): The validated data for creating a User instance. - - first_name (str): The first name of the user. - - last_name (str): The last name of the user. - - password (str): The password for the user. + fullname = validated_data['first_name'] + validated_data['last_name'] + validated_data['username'] = fullname + password = validated_data.pop('password') + user = User(**validated_data) + user.set_password(password) + user.save() + print(user.password) + return user - Returns: - User: The created User instance. - """ + def update(self, instance, validated_data): + print(validated_data) fullname = validated_data['first_name'] + validated_data['last_name'] validated_data['username'] = fullname - return super().create(validated_data) + if 'password' in validated_data: + password = validated_data.pop('password') + instance.set_password(password) + instance.save() + validated_data['password'] = instance.password + print(instance.password, validated_data['password']) + return super().update(instance, validated_data) class SchoolSerializer(serializers.ModelSerializer): From 915dfcb96fe537ef16787ee9ce329d37786262b3 Mon Sep 17 00:00:00 2001 From: Yunho Ding Date: Tue, 28 Jan 2025 07:55:11 +0000 Subject: [PATCH 02/17] Add extension time to Student model and update QuizAttempt logic --- server/api/quiz/models.py | 4 + server/api/quiz/serializers.py | 17 +++ server/api/quiz/views.py | 137 +++++++++--------- .../0003_student_extenstion_time.py | 18 +++ server/api/users/models.py | 1 + 5 files changed, 109 insertions(+), 68 deletions(-) create mode 100644 server/api/users/migrations/0003_student_extenstion_time.py diff --git a/server/api/quiz/models.py b/server/api/quiz/models.py index eb5b3b4f..d8f8b2bf 100644 --- a/server/api/quiz/models.py +++ b/server/api/quiz/models.py @@ -116,6 +116,10 @@ def is_available(self): timedelta(minutes=self.quiz.time_window) end_time = min(end_time, self.time_start + timedelta(minutes=self.quiz.time_limit)) + if self.student.extenstion_time: + end_time = now() + \ + timedelta(minutes=self.student.extenstion_time) + is_available = self.quiz.open_time_date <= current_time <= end_time if not is_available: self.state = QuizAttempt.State.COMPLETED diff --git a/server/api/quiz/serializers.py b/server/api/quiz/serializers.py index f8aa5f46..51dcff6d 100644 --- a/server/api/quiz/serializers.py +++ b/server/api/quiz/serializers.py @@ -9,6 +9,15 @@ class Meta: fields = '__all__' +class CompQuestionSerializer(serializers.ModelSerializer): + """ + Serializer for the Question model with no answer field. + """ + class Meta: + model = Question + fields = ['id', 'name', 'question_text', 'layout', 'image', 'mark'] + + class CategorySerializer(serializers.ModelSerializer): class Meta: model = Category @@ -39,6 +48,14 @@ class Meta: read_only_fields = ['quiz'] +class CompQuizSlotSerializer(serializers.ModelSerializer): + question = CompQuestionSerializer(read_only=True) + + class Meta: + model = QuizSlot + fields = '__all__' + + class QuizSerializer(serializers.ModelSerializer): class Meta: diff --git a/server/api/quiz/views.py b/server/api/quiz/views.py index 7a8301ba..2401449c 100644 --- a/server/api/quiz/views.py +++ b/server/api/quiz/views.py @@ -3,7 +3,8 @@ from rest_framework.viewsets import GenericViewSet from rest_framework.decorators import action, permission_classes from rest_framework.permissions import IsAdminUser, AllowAny, IsAuthenticated -from .serializers import QuizSerializer, QuizSlotSerializer, QuizAttemptSerializer, QuestionAttemptSerializer, AdminQuizSerializer +from .serializers import (QuizSerializer, + QuizSlotSerializer, QuizAttemptSerializer, QuestionAttemptSerializer, AdminQuizSerializer, CompQuizSlotSerializer) from rest_framework.response import Response from rest_framework import status, serializers from datetime import timedelta @@ -26,13 +27,27 @@ class AdminQuizViewSet(viewsets.ModelViewSet): def slots(self, request, pk=None): """ Retrieve or create slots for a specific quiz. - - Args: - request (Request): The request object. - pk (int): The primary key of the quiz. - - Returns: - Response: The response object containing the slots data. + The payload data is a list of dictionaries containing the following: + The slot_index is the order of the question in the quiz. + The block is the section of the quiz. + In a competition quiz, the order of the questions is randomized within each block. + + payload data example: + + [ + { + "question_id": 2, + "slot_index": 1, + "quiz_id": 12, + "block": 1 + }, + { + "question_id": 2, + "slot_index": 1, + "quiz_id": 12, + "block": 2 + } + ] """ if request.method == 'GET': self.serializer_class = QuizSlotSerializer @@ -69,15 +84,17 @@ def slots(self, request, pk=None): Returns: Response: The response object containing the slots data. """ - if request.method == 'GET': - quiz_instance = Quiz.objects.get(pk=pk) - if quiz_instance.visible and quiz_instance.status == 0: - self.serializer_class = QuizSlotSerializer - instance = QuizSlot.objects.filter(quiz_id=pk) - serializer = QuizSlotSerializer(instance, many=True) - return Response(serializer.data) - else: - return Response({'error': 'Quiz not exist'}, status=status.HTTP_404_NOT_FOUND) + try: + quiz = Quiz.objects.get(pk=pk) + except Quiz.DoesNotExist: + return Response({'error': 'Quiz not exist'}, status=status.HTTP_404_NOT_FOUND) + if quiz.visible and quiz.status == 0: + self.serializer_class = QuizSlotSerializer + instance = QuizSlot.objects.filter(quiz_id=pk) + serializer = QuizSlotSerializer(instance, many=True) + return Response(serializer.data) + else: + return Response({'error': 'Quiz not exist'}, status=status.HTTP_404_NOT_FOUND) @permission_classes([IsAuthenticated]) @@ -104,22 +121,25 @@ def slots(self, request, pk=None): Returns: Response: The response object containing the slots data. """ + try: + quiz_instance = Quiz.objects.get(pk=pk) + except Quiz.DoesNotExist: + return Response({'error': 'Quiz not exist'}, status=status.HTTP_404_NOT_FOUND) user = request.user student_id = user.student.id existing_attempt = QuizAttempt.objects.filter( quiz_id=pk, student_id=student_id, state=2).first() - - quiz_instance = Quiz.objects.get(pk=pk) - if quiz_instance.status == 3: - return Response({'error': 'Quiz has finished'}, status=status.HTTP_404_NOT_FOUND) - + # if attempt after the quiz has finished: is_available = self._is_available(quiz_instance, existing_attempt) - if is_available is True: + if quiz_instance.status == 3 and existing_attempt is None: + return Response({'error': 'Quiz has finished'}, status=status.HTTP_404_NOT_FOUND) + # check the attemt is available or not + elif is_available is True: return self._get_slots_response(pk, existing_attempt, user) else: return is_available - def _is_available(self, quiz_instance, attempt): + def _is_available(self, quiz, attempt): """ Check if the quiz is available for the user. @@ -130,58 +150,35 @@ def _is_available(self, quiz_instance, attempt): Returns: bool or Response: True if available, otherwise a Response with an error message. """ - if quiz_instance.visible: + # check if the quiz has been withdrawn by the admin + if quiz.visible: current_time = now() - start_time = quiz_instance.open_time_date + start_time = quiz.open_time_date end_time = start_time + \ - timedelta(minutes=quiz_instance.time_limit) + \ - timedelta(minutes=quiz_instance.time_window) + timedelta(minutes=quiz.time_limit) + \ + timedelta(minutes=quiz.time_window) + # if never attempt before if attempt is None: if start_time <= current_time <= end_time: return True elif current_time < start_time: return Response({'error': 'Quiz has not started yet'}, status=status.HTTP_403_FORBIDDEN) elif current_time > end_time: - return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) + return Response({'error': 'Quiz has ended'}, status=status.HTTP_403_FORBIDDEN) else: - return self._check_attempt_state(attempt, start_time, end_time) + if attempt.is_available: + return True + else: + return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) + # if the quiz has been withdrawn by the admin else: return Response({'error': 'Quiz not exist'}, status=status.HTTP_404_NOT_FOUND) - def _check_attempt_state(self, attempt, start_time, end_time): - """ - Check the state of the existing quiz attempt. - - Args: - attempt (QuizAttempt): The existing quiz attempt. - start_time (datetime): The start time of the quiz. - end_time (datetime): The end time of the quiz. - - Returns: - bool or Response: True if available, otherwise a Response with an error message. - """ - current_time = now() - if attempt.state == 2: - start_time = attempt.time_start - end_time = min( - start_time + timedelta(minutes=attempt.quiz.time_limit), end_time) - if start_time <= current_time <= end_time: - return True - elif current_time < start_time: - return Response({'error': 'Quiz has not started yet'}, status=status.HTTP_403_FORBIDDEN) - elif current_time > end_time: - attempt.state = 3 - attempt.save() - return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) - else: - attempt.state = 3 - attempt.save() - return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) - def _get_slots_response(self, quiz_id, existing_attempt, user): """ - Get the response containing the slots data. + Get the response containing the slosts data. + The slots are corresponding sorted questions of the quiz. Args: quiz_id (int): The primary key of the quiz. @@ -191,6 +188,7 @@ def _get_slots_response(self, quiz_id, existing_attempt, user): Returns: Response: The response object containing the slots data. """ + if existing_attempt is None: quiz_attempt_serializer = QuizAttemptSerializer(data={ 'quiz': quiz_id, @@ -199,9 +197,8 @@ def _get_slots_response(self, quiz_id, existing_attempt, user): }) quiz_attempt_serializer.is_valid(raise_exception=True) quiz_attempt_serializer.save() - self.serializer_class = QuizSlotSerializer instances = QuizSlot.objects.filter(quiz_id=quiz_id) - serializer = QuizSlotSerializer(instances, many=True) + serializer = CompQuizSlotSerializer(instances, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -246,11 +243,13 @@ def create(self, request, *args, **kwargs): quiz_id = request.data.get('quiz') student_id = request.data.get('student') existing_attempt = QuizAttempt.objects.filter( - quiz_id=quiz_id, student_id=student_id, state=2).first() + quiz_id=quiz_id, student_id=student_id).first() if existing_attempt: serializer = self.get_serializer(existing_attempt) data = serializer.data + if not data.is_available: + return Response({'error': 'Quiz has finished3'}, status=status.HTTP_403_FORBIDDEN) # switch cases by state match existing_attempt.state: @@ -259,10 +258,12 @@ def create(self, request, *args, **kwargs): return Response({'message': 'You already have an active attempt for this quiz.', 'data': data}, status=status.HTTP_200_OK) case 3: # submitted # prevent the user from creating a new attempt - return Response({'error': 'You have already submitted this quiz.'}, status=status.HTTP_403_FORBIDDEN) + return Response({'message': 'You have already submitted this quiz.'}, status=status.HTTP_403_FORBIDDEN) case 4: # completed # prevent the user from creating a new attempt - return Response({'error': 'The competition has ended.'}, status=status.HTTP_403_FORBIDDEN) + return Response({'message': 'The competition has ended.'}, status=status.HTTP_403_FORBIDDEN) + else: + return super().create(request, *args, **kwargs) def update(self, request, *args, **kwargs): instance = self.get_object() @@ -331,9 +332,9 @@ def create(self, request, *args, **kwargs): quiz_attempt_id = request.data.get('quiz_attempt') comp_attempt = QuizAttempt.objects.get( pk=quiz_attempt_id, student_id=request.user.student.id) - + print("@@") # check if the quiz is available for the user - if comp_attempt.is_available: + if not comp_attempt.is_available: return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) question_id = request.data.get('question') diff --git a/server/api/users/migrations/0003_student_extenstion_time.py b/server/api/users/migrations/0003_student_extenstion_time.py new file mode 100644 index 00000000..6c9603ea --- /dev/null +++ b/server/api/users/migrations/0003_student_extenstion_time.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-01-28 07:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0002_school_is_country_school_type_student_attendent_year"), + ] + + operations = [ + migrations.AddField( + model_name="student", + name="extenstion_time", + field=models.IntegerField(default=0), + ), + ] diff --git a/server/api/users/models.py b/server/api/users/models.py index f73c436d..89244441 100644 --- a/server/api/users/models.py +++ b/server/api/users/models.py @@ -45,6 +45,7 @@ class Student(models.Model): attendent_year = models.IntegerField(default=2025) year_level = models.CharField(max_length=50) created_at = models.DateTimeField(auto_now_add=True) + extenstion_time = models.IntegerField(default=0) def __str__(self): return f"{self.user.username}" From 93fc0bdc4290b0c4595c1d64dd93a13ed0f91b48 Mon Sep 17 00:00:00 2001 From: Yunho Ding Date: Tue, 28 Jan 2025 08:24:13 +0000 Subject: [PATCH 03/17] Remove debug print statements from QuizAttempt and User serializers --- server/api/quiz/views.py | 3 +-- server/api/users/serializers.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/server/api/quiz/views.py b/server/api/quiz/views.py index 2401449c..16679858 100644 --- a/server/api/quiz/views.py +++ b/server/api/quiz/views.py @@ -281,7 +281,6 @@ def update(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_200_OK) case 3: - print(now()) return Response({'error': 'You have already submitted this quiz.'}, status=status.HTTP_403_FORBIDDEN) case 4: return Response({'error': 'The competition has ended.'}, status=status.HTTP_403_FORBIDDEN) @@ -332,7 +331,7 @@ def create(self, request, *args, **kwargs): quiz_attempt_id = request.data.get('quiz_attempt') comp_attempt = QuizAttempt.objects.get( pk=quiz_attempt_id, student_id=request.user.student.id) - print("@@") + # check if the quiz is available for the user if not comp_attempt.is_available: return Response({'error': 'Quiz has finished'}, status=status.HTTP_403_FORBIDDEN) diff --git a/server/api/users/serializers.py b/server/api/users/serializers.py index 53d0e78f..59367e32 100644 --- a/server/api/users/serializers.py +++ b/server/api/users/serializers.py @@ -34,13 +34,12 @@ def create(self, validated_data): validated_data['username'] = fullname password = validated_data.pop('password') user = User(**validated_data) + print(user) user.set_password(password) user.save() - print(user.password) return user def update(self, instance, validated_data): - print(validated_data) fullname = validated_data['first_name'] + validated_data['last_name'] validated_data['username'] = fullname if 'password' in validated_data: @@ -48,7 +47,6 @@ def update(self, instance, validated_data): instance.set_password(password) instance.save() validated_data['password'] = instance.password - print(instance.password, validated_data['password']) return super().update(instance, validated_data) From fb34d6da98969dd4c9c113d0658f0bd62e466b19 Mon Sep 17 00:00:00 2001 From: Yunho Ding Date: Tue, 28 Jan 2025 09:08:55 +0000 Subject: [PATCH 04/17] Add dead_line field to QuizAttempt model and update availability logic --- .../migrations/0013_quizattempt_dead_line.py | 18 ++++++++++++++++++ server/api/quiz/models.py | 9 +++++++-- server/api/quiz/views.py | 15 +++++++++------ 3 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 server/api/quiz/migrations/0013_quizattempt_dead_line.py diff --git a/server/api/quiz/migrations/0013_quizattempt_dead_line.py b/server/api/quiz/migrations/0013_quizattempt_dead_line.py new file mode 100644 index 00000000..d8b7f880 --- /dev/null +++ b/server/api/quiz/migrations/0013_quizattempt_dead_line.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-01-28 08:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("quiz", "0012_alter_quizattempt_student"), + ] + + operations = [ + migrations.AddField( + model_name="quizattempt", + name="dead_line", + field=models.DateTimeField(blank=True, default=None, null=True), + ), + ] diff --git a/server/api/quiz/models.py b/server/api/quiz/models.py index d8f8b2bf..5749ce6c 100644 --- a/server/api/quiz/models.py +++ b/server/api/quiz/models.py @@ -98,7 +98,7 @@ class State(models.IntegerChoices): team = models.ForeignKey( Team, on_delete=models.CASCADE, related_name="quiz_attempts", default=None, null=True, blank=True ) - # dead_line = models.DateTimeField(default=None, null=True, blank=True) + dead_line = models.DateTimeField(default=None, null=True, blank=True) def __str__(self): return f"{self.id} {self.quiz} " @@ -119,11 +119,16 @@ def is_available(self): if self.student.extenstion_time: end_time = now() + \ timedelta(minutes=self.student.extenstion_time) + self.student.extenstion_time = 0 + self.dead_line = end_time - is_available = self.quiz.open_time_date <= current_time <= end_time + is_available = self.quiz.open_time_date <= current_time <= self.dead_line if not is_available: self.state = QuizAttempt.State.COMPLETED self.save() + else: + self.state = QuizAttempt.State.IN_PROGRESS + self.save() return is_available diff --git a/server/api/quiz/views.py b/server/api/quiz/views.py index 16679858..2564a9b3 100644 --- a/server/api/quiz/views.py +++ b/server/api/quiz/views.py @@ -128,13 +128,14 @@ def slots(self, request, pk=None): user = request.user student_id = user.student.id existing_attempt = QuizAttempt.objects.filter( - quiz_id=pk, student_id=student_id, state=2).first() + quiz_id=pk, student_id=student_id).first() # if attempt after the quiz has finished: is_available = self._is_available(quiz_instance, existing_attempt) if quiz_instance.status == 3 and existing_attempt is None: return Response({'error': 'Quiz has finished'}, status=status.HTTP_404_NOT_FOUND) # check the attemt is available or not elif is_available is True: + print(existing_attempt) return self._get_slots_response(pk, existing_attempt, user) else: return is_available @@ -158,7 +159,7 @@ def _is_available(self, quiz, attempt): timedelta(minutes=quiz.time_limit) + \ timedelta(minutes=quiz.time_window) - # if never attempt before + # if never attempt before, no attempt instance yet if attempt is None: if start_time <= current_time <= end_time: return True @@ -166,6 +167,7 @@ def _is_available(self, quiz, attempt): return Response({'error': 'Quiz has not started yet'}, status=status.HTTP_403_FORBIDDEN) elif current_time > end_time: return Response({'error': 'Quiz has ended'}, status=status.HTTP_403_FORBIDDEN) + # if the user has already attempted the quiz else: if attempt.is_available: return True @@ -244,6 +246,7 @@ def create(self, request, *args, **kwargs): student_id = request.data.get('student') existing_attempt = QuizAttempt.objects.filter( quiz_id=quiz_id, student_id=student_id).first() + print(existing_attempt) if existing_attempt: serializer = self.get_serializer(existing_attempt) @@ -253,13 +256,13 @@ def create(self, request, *args, **kwargs): # switch cases by state match existing_attempt.state: - case 2: + case QuizAttempt.State.IN_PROGRESS: # return the existing attempt return Response({'message': 'You already have an active attempt for this quiz.', 'data': data}, status=status.HTTP_200_OK) - case 3: # submitted + case QuizAttempt.State.SUBMITTED: # submitted # prevent the user from creating a new attempt return Response({'message': 'You have already submitted this quiz.'}, status=status.HTTP_403_FORBIDDEN) - case 4: # completed + case QuizAttempt.State.COMPLETED: # completed # prevent the user from creating a new attempt return Response({'message': 'The competition has ended.'}, status=status.HTTP_403_FORBIDDEN) else: @@ -299,7 +302,7 @@ def submit(self, request, pk=None): Submit the quiz attempt, changing its state to 2 (submitted). """ attempt = self.get_object() - attempt.state = 2 + attempt.state = QuizAttempt.State.SUBMITTED attempt.time_finish = now() attempt.save() return Response({'status': 'Quiz attempt submitted successfully.'}) From d24f762319388c8e93628165f40b472490131865 Mon Sep 17 00:00:00 2001 From: Yunho Ding Date: Tue, 28 Jan 2025 09:34:51 +0000 Subject: [PATCH 05/17] Update QuizAttempt model to handle extension time and adjust dead_line logic --- server/api/quiz/models.py | 10 ++++++++-- server/api/quiz/views.py | 10 +++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/server/api/quiz/models.py b/server/api/quiz/models.py index 5749ce6c..46c9af4a 100644 --- a/server/api/quiz/models.py +++ b/server/api/quiz/models.py @@ -116,11 +116,17 @@ def is_available(self): timedelta(minutes=self.quiz.time_window) end_time = min(end_time, self.time_start + timedelta(minutes=self.quiz.time_limit)) - if self.student.extenstion_time: + if int(self.student.extenstion_time) > 0: + print("extenstion time") end_time = now() + \ timedelta(minutes=self.student.extenstion_time) self.student.extenstion_time = 0 - self.dead_line = end_time + self.student.save() + if self.dead_line is None: + self.dead_line = end_time + self.save() + else: + self.dead_line = max(self.dead_line, end_time) is_available = self.quiz.open_time_date <= current_time <= self.dead_line if not is_available: diff --git a/server/api/quiz/views.py b/server/api/quiz/views.py index 2564a9b3..f27ca837 100644 --- a/server/api/quiz/views.py +++ b/server/api/quiz/views.py @@ -135,7 +135,6 @@ def slots(self, request, pk=None): return Response({'error': 'Quiz has finished'}, status=status.HTTP_404_NOT_FOUND) # check the attemt is available or not elif is_available is True: - print(existing_attempt) return self._get_slots_response(pk, existing_attempt, user) else: return is_available @@ -198,11 +197,16 @@ def _get_slots_response(self, quiz_id, existing_attempt, user): 'state': 2, }) quiz_attempt_serializer.is_valid(raise_exception=True) - quiz_attempt_serializer.save() + attempt = quiz_attempt_serializer.save() + attempt.is_available + end_time = attempt.dead_line + else: + end_time = existing_attempt.dead_line + # wrap the end_time into the response instances = QuizSlot.objects.filter(quiz_id=quiz_id) serializer = CompQuizSlotSerializer(instances, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response({'data': serializer.data, 'end_time': end_time}, status=status.HTTP_200_OK) class QuizSlotViewSet(viewsets.ModelViewSet): From dd034d05e79d20d9cef56c25a26003f52f93dfd2 Mon Sep 17 00:00:00 2001 From: Yunho Ding Date: Wed, 29 Jan 2025 02:43:57 +0000 Subject: [PATCH 06/17] Refactor Quiz serializers to include UserQuizSerializer and update QuizViewSet to use it --- server/api/quiz/serializers.py | 11 +++++++++- server/api/quiz/views.py | 38 ++++++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/server/api/quiz/serializers.py b/server/api/quiz/serializers.py index 51dcff6d..0461f20e 100644 --- a/server/api/quiz/serializers.py +++ b/server/api/quiz/serializers.py @@ -60,7 +60,16 @@ class QuizSerializer(serializers.ModelSerializer): class Meta: model = Quiz - fields = ['id', 'name', 'intro', 'total_marks'] + fields = '__all__' + + +class UserQuizSerializer(serializers.ModelSerializer): + """ + Serializer for the Quiz model with no is_comp, visible, and status fields. + """ + class Meta: + model = Quiz + exclude = ['is_comp', 'visible', 'status'] class AdminQuizSerializer(serializers.ModelSerializer): diff --git a/server/api/quiz/views.py b/server/api/quiz/views.py index f27ca837..7452e13a 100644 --- a/server/api/quiz/views.py +++ b/server/api/quiz/views.py @@ -4,7 +4,12 @@ from rest_framework.decorators import action, permission_classes from rest_framework.permissions import IsAdminUser, AllowAny, IsAuthenticated from .serializers import (QuizSerializer, - QuizSlotSerializer, QuizAttemptSerializer, QuestionAttemptSerializer, AdminQuizSerializer, CompQuizSlotSerializer) + QuizSlotSerializer, + QuizAttemptSerializer, + QuestionAttemptSerializer, + AdminQuizSerializer, + CompQuizSlotSerializer, + UserQuizSerializer) from rest_framework.response import Response from rest_framework import status, serializers from datetime import timedelta @@ -107,19 +112,34 @@ class CompetistionQuizViewSet(viewsets.ReadOnlyModelViewSet): slots: Retrieve slots for a specific competition quiz. """ queryset = Quiz.objects.filter(status=1, visible=True) - serializer_class = QuizSerializer + serializer_class = UserQuizSerializer @action(detail=True, methods=['get']) def slots(self, request, pk=None): """ - Retrieve slots for a specific competition quiz. - - Args: - request (Request): The request object. - pk (int): The primary key of the quiz. + Only allow the user to access the slots(competition questions) if the quiz is available. + api: + /api/quiz/competition/1/slots/ + data shape: {"data":[ ], "end_time": "2025-01-29T00:49:17.015606Z"} + + {"data": [ + { + "id": 1, + "question": { + "id": 12, + "name": "question_1", + "question_text": "question_text_1", + "layout": "left", + "image": null, + "mark": 2 + }, + "slot_index": 1, + "block": 1, + "quiz": 2 + } + ], + "end_time": "2025-01-29T00:49:17.015606Z"} - Returns: - Response: The response object containing the slots data. """ try: quiz_instance = Quiz.objects.get(pk=pk) From 25697060838f3bae9667898da806100d7d8c0c52 Mon Sep 17 00:00:00 2001 From: Lok Yx Date: Sat, 25 Jan 2025 03:37:07 +0000 Subject: [PATCH 07/17] feat(auth): add custom 'role' field to JWT token, reconnect client-server - feat(auth): include 'role' field in JWT token for role-based access control - refactor(client-server): replace client-side mock API with proper server communication - chore: ensure seamless integration between client and server endpoints --- client/src/components/layout.tsx | 37 +++++++++++--------------------- client/src/lib/api.ts | 5 +---- client/src/types/globals.d.ts | 1 + server/api/auth/serializers.py | 14 ++++++++++++ server/api/auth/urls.py | 5 +++-- server/api/auth/views.py | 6 ++++++ 6 files changed, 38 insertions(+), 30 deletions(-) create mode 100644 server/api/auth/serializers.py diff --git a/client/src/components/layout.tsx b/client/src/components/layout.tsx index b554e17a..ead617b4 100644 --- a/client/src/components/layout.tsx +++ b/client/src/components/layout.tsx @@ -1,10 +1,8 @@ import React, { useEffect, useState } from "react"; import Navbar from "@/components/navbar"; -import { WaitingLoader } from "@/components/ui/loading"; -import { useAuth } from "@/context/auth-provider"; -import { useFetchData } from "@/hooks/use-fetch-data"; -import { User } from "@/types/user"; +import { useTokenStore } from "@/store/token-store"; +import { Role } from "@/types/user"; import Sidebar from "./sidebar"; import Footer from "./ui/footer"; @@ -22,28 +20,21 @@ interface LayoutProps { */ export default function Layout({ children }: LayoutProps) { const [isAuthChecked, setIsAuthChecked] = useState(false); - const { userId } = useAuth(); - const isLoggedIn = Boolean(userId); + const { access } = useTokenStore(); // access the JWT token + const [role, setRole] = useState(undefined); - // wait for auth to be checked before rendering useEffect(() => { + if (access?.decoded) { + const userRole = access.decoded["role"]; + setRole(userRole); + } + // wait for auth to be checked before rendering setIsAuthChecked(true); - }, []); - - const { - data: user, - error, - isLoading, - } = useFetchData({ - queryKey: ["user", userId], - endpoint: "/users/me", - staleTime: 5 * 60 * 1000, - enabled: Boolean(userId), - }); + }, [access]); if (!isAuthChecked) return null; - if (!isLoggedIn) { + if (!access) { return (
@@ -53,8 +44,6 @@ export default function Layout({ children }: LayoutProps) { ); } - if (isLoading) return ; - if (!user) return
{error?.message || "Failed to load user data."}
; - - return {children}; + if (!role) return
Failed to get user role.
; + return {children}; } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 7cddc604..cd50b2bb 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -2,10 +2,7 @@ import axios from "axios"; export const backendURL = process.env.NEXT_PUBLIC_BACKEND_URL_BASE; -const baseURL = - process.env.NODE_ENV === "development" - ? "http://localhost:3000/api" // temporarily use port 3000 in dev for mock data - : process.env.NEXT_PUBLIC_BACKEND_URL; +const baseURL = process.env.NEXT_PUBLIC_BACKEND_URL; const api = axios.create({ baseURL }); diff --git a/client/src/types/globals.d.ts b/client/src/types/globals.d.ts index 9e665864..d8c35969 100644 --- a/client/src/types/globals.d.ts +++ b/client/src/types/globals.d.ts @@ -29,5 +29,6 @@ declare module "jwt-decode" { */ export interface JwtPayload extends OriginalJwtPayload { user_id: string; + role: string | undefined; } } diff --git a/server/api/auth/serializers.py b/server/api/auth/serializers.py new file mode 100644 index 00000000..651892bf --- /dev/null +++ b/server/api/auth/serializers.py @@ -0,0 +1,14 @@ +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + @classmethod + def get_token(cls, user): + token = super().get_token(user) + token['first_name'] = user.first_name + token['last_name'] = user.last_name + token['username'] = user.username + token['is_superuser'] = user.is_superuser + token['email'] = user.email + token['role'] = "admin" + return token diff --git a/server/api/auth/urls.py b/server/api/auth/urls.py index bb665e3d..370d55a0 100644 --- a/server/api/auth/urls.py +++ b/server/api/auth/urls.py @@ -10,13 +10,14 @@ from django.urls import path, include, re_path from rest_framework_simplejwt.views import ( - TokenObtainPairView, + # TokenObtainPairView, TokenRefreshView, TokenVerifyView, ) +from .views import CustomTokenObtainPairSerializer urlpatterns = [ - path("token/", TokenObtainPairView.as_view(), name="jwt_token"), + path("token/", CustomTokenObtainPairSerializer.as_view(), name="jwt_token"), path("refresh/", TokenRefreshView.as_view(), name="jwt_refresh"), path("verify/", TokenVerifyView.as_view(), name="jwt_verify"), re_path(r"^api-auth/", include("rest_framework.urls", namespace="rest_framework")), diff --git a/server/api/auth/views.py b/server/api/auth/views.py index e69de29b..176fda70 100644 --- a/server/api/auth/views.py +++ b/server/api/auth/views.py @@ -0,0 +1,6 @@ +from rest_framework_simplejwt.views import TokenObtainPairView +from .serializers import CustomTokenObtainPairSerializer + + +class CustomTokenObtainPairSerializer(TokenObtainPairView): + serializer_class = CustomTokenObtainPairSerializer From ffa27f75248ffa28c54f86edf513032a90dcffe5 Mon Sep 17 00:00:00 2001 From: Lok Yx Date: Sat, 25 Jan 2025 05:03:38 +0000 Subject: [PATCH 08/17] feat(auth): remove unwanted fields from JWT payload and implement role retrieval - refactor(auth): clean up JWT payload by removing unnecessary fields - feat(auth): add function to extract and utilize the 'role' field from JWT token --- client/src/components/layout.tsx | 12 +++++++++++- server/api/auth/serializers.py | 17 +++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/client/src/components/layout.tsx b/client/src/components/layout.tsx index ead617b4..24204be5 100644 --- a/client/src/components/layout.tsx +++ b/client/src/components/layout.tsx @@ -44,6 +44,16 @@ export default function Layout({ children }: LayoutProps) { ); } - if (!role) return
Failed to get user role.
; + if (!role) { + return ( +
+ +
+
Failed to get user role.
+
+
+ ); + } + return {children}; } diff --git a/server/api/auth/serializers.py b/server/api/auth/serializers.py index 651892bf..38e9aaa3 100644 --- a/server/api/auth/serializers.py +++ b/server/api/auth/serializers.py @@ -1,14 +1,19 @@ +from django.contrib.auth.models import User from rest_framework_simplejwt.serializers import TokenObtainPairSerializer class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): @classmethod - def get_token(cls, user): + def get_token(cls, user: User): + role = "admin" if user.is_staff else None + + if hasattr(user, "student"): + role = "student" + elif hasattr(user, "teacher"): + role = "teacher" + token = super().get_token(user) - token['first_name'] = user.first_name - token['last_name'] = user.last_name - token['username'] = user.username token['is_superuser'] = user.is_superuser - token['email'] = user.email - token['role'] = "admin" + token['role'] = role + return token From 255b6cdf6f61b85a369686fc1ba76f7df4bca2eb Mon Sep 17 00:00:00 2001 From: Lok Yx Date: Sat, 25 Jan 2025 06:35:39 +0000 Subject: [PATCH 09/17] fix(api): resolve CORS issue and configure auth token for API requests - fix(api): add trailing slash to endpoint URLs (e.g., questions/question-bank) to resolve CORS errors - feat(auth): configure Bearer token in axios instance for authenticated API requests --- client/src/lib/api.ts | 96 ++++++++++++++++++++++++++++- client/src/pages/question/index.tsx | 2 +- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index cd50b2bb..29bcc96a 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,9 +1,101 @@ -import axios from "axios"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; + +import { useTokenStore } from "@/store/token-store"; export const backendURL = process.env.NEXT_PUBLIC_BACKEND_URL_BASE; const baseURL = process.env.NEXT_PUBLIC_BACKEND_URL; -const api = axios.create({ baseURL }); +const api = axios.create({ + baseURL, + headers: { + "Content-Type": "application/json", + }, +}); +api.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + const accessToken = useTokenStore.getState().access; + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken.encoded}`; + } + return config; + }, + (error) => Promise.reject(error), +); + +// Queue of failed request promises waiting for refreshed token +let failedQueue: { + resolve: () => void; + reject: (_: AxiosError | null) => void; +}[] = []; +let isRefreshing = false; + +const processQueue = (error: AxiosError | null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(); + } + }); + failedQueue = []; +}; + +api.interceptors.response.use( + (response) => response, + async (error) => { + const tokenState = useTokenStore.getState(); + const originalRequest = error.config; + + const handleError = (error: AxiosError | null) => { + processQueue(error); + tokenState.clear(); + return Promise.reject(error); + }; + + const refreshTokenValid = + tokenState.refresh != undefined && tokenState.refresh.expiry > Date.now(); + const isAuthError = + error.response?.status === 401 || error.response?.status === 403; + + if ( + refreshTokenValid == true && + isAuthError && + originalRequest.url !== "/auth/refresh/" && + originalRequest._retry !== true + ) { + if (isRefreshing) { + return new Promise(function (resolve, reject) { + failedQueue.push({ resolve, reject }); + }) + .then(() => api(originalRequest)) + .catch((err) => Promise.reject(err)); + } + isRefreshing = true; + originalRequest._retry = true; + return api + .post("/auth/refresh/", { + refresh: tokenState.refresh!.encoded, + }) + .then((res) => { + if (res.data.access) { + tokenState.setAccess(res.data.access); + } + processQueue(null); + + return api(originalRequest); + }, handleError) + .finally(() => { + isRefreshing = false; + }); + } + + if (isAuthError) { + return handleError(error); + } + + return Promise.reject(error); + }, +); export default api; diff --git a/client/src/pages/question/index.tsx b/client/src/pages/question/index.tsx index 0d2e88e6..ebce9dd9 100644 --- a/client/src/pages/question/index.tsx +++ b/client/src/pages/question/index.tsx @@ -17,7 +17,7 @@ export default function Index() { error: QuestionError, } = useFetchData({ queryKey: ["question.list"], - endpoint: "/question/list", + endpoint: "/questions/question-bank/", }); // Tracks the current page number for pagination. From ebe8fd3a7f043cc9ba2bcc14f892049f8a93f048 Mon Sep 17 00:00:00 2001 From: thnorton Date: Sat, 25 Jan 2025 06:18:14 +0000 Subject: [PATCH 10/17] added answer model --- server/api/question/admin.py | 7 ++++++- server/api/question/models.py | 17 +++++++++++------ server/api/question/serializers.py | 13 +++++++++---- server/api/question/urls.py | 3 ++- server/api/question/views.py | 28 ++++++++++------------------ 5 files changed, 38 insertions(+), 30 deletions(-) diff --git a/server/api/question/admin.py b/server/api/question/admin.py index 7a3e0a05..d6ca404a 100644 --- a/server/api/question/admin.py +++ b/server/api/question/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Question, Category +from .models import Question, Category, Answer @admin.register(Category) @@ -13,3 +13,8 @@ class QuestionAdmin(admin.ModelAdmin): list_display = ('name', 'question_text') list_filter = ('id', 'mark', 'created_by', 'modified_by') search_fields = ('id',) + + +@admin.register(Answer) +class AnswerAdmin(admin.ModelAdmin): + list_display = ('id', 'question', 'value') diff --git a/server/api/question/models.py b/server/api/question/models.py index f08d7225..93d0a8d9 100644 --- a/server/api/question/models.py +++ b/server/api/question/models.py @@ -1,5 +1,4 @@ from django.db import models -from django.contrib.postgres.fields import ArrayField import os import uuid from django.conf import settings @@ -86,10 +85,6 @@ class Question(models.Model): name = models.CharField(max_length=255, unique=True) question_text = models.TextField(default="") note = models.TextField(default="") - # answer to the question - answer = ArrayField(models.IntegerField(), default=list) - # detailed answer with explanation - answer_text = models.TextField(default="") categories = models.ManyToManyField(Category, related_name='questions', blank=True) created_by = models.ForeignKey( 'auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='questions_created') @@ -111,4 +106,14 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def __str__(self): - return f"{self.name} {self.question_text}" + return f'{self.name} {self.question_text}' + + +class Answer(models.Model): + id = models.AutoField(primary_key=True) + question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="answers") + value = models.IntegerField() + text = models.TextField(default="") + + def __str__(self): + return f'{self.question} {self.value} {self.text}' diff --git a/server/api/question/serializers.py b/server/api/question/serializers.py index b9ba5302..16b22fa7 100644 --- a/server/api/question/serializers.py +++ b/server/api/question/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Question, Category +from .models import Question, Category, Answer from api.users.serializers import UserSerializer @@ -19,6 +19,13 @@ class Meta: fields = "__all__" +class AnswerSerializer(serializers.ModelSerializer): + + class Meta: + model = Answer + fields = '__all__' + + class QuestionSerializer(serializers.ModelSerializer): """ Serializer for the Question model. @@ -30,7 +37,6 @@ class QuestionSerializer(serializers.ModelSerializer): category_ids (PrimaryKeyRelatedField): The categories associated with the question. categories (CategorySerializer): The categories associated with the question. is_comp (BooleanField): Indicates if the question is for competition. - answer (ListField): The correct answer to the question. """ name = serializers.CharField(max_length=255, required=True) created_by = UserSerializer(read_only=True).fields['username'] @@ -39,8 +45,7 @@ class QuestionSerializer(serializers.ModelSerializer): queryset=Category.objects.all(), write_only=True, required=False, allow_null=True, many=True, source='categories') categories = CategorySerializer(read_only=True, many=True) is_comp = serializers.BooleanField(required=False, default=False) - answer = serializers.ListField( - child=serializers.IntegerField(), default=[1]) + answers = AnswerSerializer(many=True, required=False) def create(self, validated_data): """ diff --git a/server/api/question/urls.py b/server/api/question/urls.py index 370d41a2..cd232e9d 100644 --- a/server/api/question/urls.py +++ b/server/api/question/urls.py @@ -1,9 +1,10 @@ # from django.urls import path from rest_framework.routers import DefaultRouter -from .views import QuestionViewSet, CategoryViewSet +from .views import QuestionViewSet, CategoryViewSet, AnswerViewSet router = DefaultRouter() router.register(r'question-bank', QuestionViewSet, basename='question-bank') router.register(r'categories', CategoryViewSet, basename='categories') +router.register(r'answers', AnswerViewSet, basename='answers') urlpatterns = router.urls diff --git a/server/api/question/views.py b/server/api/question/views.py index 3093884b..a0d3cbc6 100644 --- a/server/api/question/views.py +++ b/server/api/question/views.py @@ -1,8 +1,8 @@ from rest_framework import viewsets, filters, status -from .serializers import QuestionSerializer, CategorySerializer -from .models import Question, Category +from .serializers import QuestionSerializer, CategorySerializer, AnswerSerializer +from .models import Question, Category, Answer from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.decorators import action, permission_classes +from rest_framework.decorators import permission_classes from rest_framework.response import Response from rest_framework.permissions import IsAdminUser from django.utils.timezone import now @@ -24,7 +24,7 @@ class QuestionViewSet(viewsets.ModelViewSet): serializer_class = QuestionSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter] search_fields = ['name'] - filterset_fields = ['mark'] + filterset_fields = ['mark', 'answers__value'] def create(self, request, *args, **kwargs): """ @@ -53,20 +53,6 @@ def perform_update(self, serializer): instance.time_modified = now() instance.save() - @action(detail=False, methods=['get']) - def search_by_answer(self, request): - """ - Search questions by answer. - - Args: - request (Request): The request object. - - Returns: - Response: The response object. - """ - self.search_fields = ['answer'] - return self.list(request) - # @action(detail=False, methods=['get']) # def get_random_question(self, request): # """ @@ -113,3 +99,9 @@ def create(self, request, *args, **kwargs): if Category.objects.filter(genre=genre).exists(): return Response({'error': 'Category with this genre already exists'}, status=status.HTTP_400_BAD_REQUEST) return super().create(request, *args, **kwargs) + + +class AnswerViewSet(viewsets.ModelViewSet): + queryset = Answer.objects.all() + serializer_class = AnswerSerializer + filterset_fields = ['question'] From 43588a889c66e71078332be8a44c869fee94bac4 Mon Sep 17 00:00:00 2001 From: thnorton Date: Sat, 25 Jan 2025 07:58:46 +0000 Subject: [PATCH 11/17] add nested answer creation --- server/api/question/models.py | 4 ++-- server/api/question/serializers.py | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/server/api/question/models.py b/server/api/question/models.py index 93d0a8d9..ed0fcaf1 100644 --- a/server/api/question/models.py +++ b/server/api/question/models.py @@ -92,6 +92,7 @@ class Question(models.Model): 'auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='questions_modified') is_comp = models.BooleanField() diff_level = models.IntegerField() + solution_text = models.TextField(default="") layout = models.TextField(default="") # Placeholder for layout enum image = models.ForeignKey( Image, on_delete=models.SET_NULL, null=True, blank=True, related_name='questions', default=None) @@ -113,7 +114,6 @@ class Answer(models.Model): id = models.AutoField(primary_key=True) question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="answers") value = models.IntegerField() - text = models.TextField(default="") def __str__(self): - return f'{self.question} {self.value} {self.text}' + return f'{self.question} {self.value}' diff --git a/server/api/question/serializers.py b/server/api/question/serializers.py index 16b22fa7..6be4d08e 100644 --- a/server/api/question/serializers.py +++ b/server/api/question/serializers.py @@ -23,7 +23,7 @@ class AnswerSerializer(serializers.ModelSerializer): class Meta: model = Answer - fields = '__all__' + fields = ['value'] class QuestionSerializer(serializers.ModelSerializer): @@ -45,7 +45,8 @@ class QuestionSerializer(serializers.ModelSerializer): queryset=Category.objects.all(), write_only=True, required=False, allow_null=True, many=True, source='categories') categories = CategorySerializer(read_only=True, many=True) is_comp = serializers.BooleanField(required=False, default=False) - answers = AnswerSerializer(many=True, required=False) + # answers = AnswerSerializer(required=True, many=True) + answers = AnswerSerializer(many=True) def create(self, validated_data): """ @@ -61,8 +62,20 @@ def create(self, validated_data): validated_data['created_by'] = request.user validated_data['modified_by'] = request.user - return super().create(validated_data) + answers_data = validated_data.pop('answers', []) + + question = super().create(validated_data) + + for answer_data in answers_data: + Answer.objects.create(question=question, **answer_data) + + return question class Meta: model = Question +<<<<<<< HEAD fields = "__all__" +======= + fields = '__all__' + +>>>>>>> f10305d (add nested answer creation) From 238aa6c11ef173888e5564624fde29e4c52da914 Mon Sep 17 00:00:00 2001 From: Yunho Ding Date: Sat, 25 Jan 2025 08:43:21 +0000 Subject: [PATCH 12/17] Refactor question model and serializer: remove answer fields, add solution_text, and update answers handling in create method --- server/api/question/serializers.py | 8 +------- server/api/question/views.py | 7 +++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/server/api/question/serializers.py b/server/api/question/serializers.py index 6be4d08e..c04c6f17 100644 --- a/server/api/question/serializers.py +++ b/server/api/question/serializers.py @@ -45,8 +45,7 @@ class QuestionSerializer(serializers.ModelSerializer): queryset=Category.objects.all(), write_only=True, required=False, allow_null=True, many=True, source='categories') categories = CategorySerializer(read_only=True, many=True) is_comp = serializers.BooleanField(required=False, default=False) - # answers = AnswerSerializer(required=True, many=True) - answers = AnswerSerializer(many=True) + answers = AnswerSerializer(required=False, many=True) def create(self, validated_data): """ @@ -73,9 +72,4 @@ def create(self, validated_data): class Meta: model = Question -<<<<<<< HEAD - fields = "__all__" -======= fields = '__all__' - ->>>>>>> f10305d (add nested answer creation) diff --git a/server/api/question/views.py b/server/api/question/views.py index a0d3cbc6..f696568c 100644 --- a/server/api/question/views.py +++ b/server/api/question/views.py @@ -38,8 +38,15 @@ def create(self, request, *args, **kwargs): """ # validate integrity of name name = request.data.get('name') + answers_list = request.data.get('answers') + answers = [] + if Question.objects.filter(name=name).exists(): return Response({'error': 'Question with this name already exists'}, status=status.HTTP_400_BAD_REQUEST) + for answer in answers_list: + answer = {'value': answer} + answers.append(answer) + request.data['answers'] = answers return super().create(request, *args, **kwargs) def perform_update(self, serializer): From 1f8f71742e17247bc4f4c521807c42cc7466285a Mon Sep 17 00:00:00 2001 From: Yunho Date: Sat, 25 Jan 2025 16:32:37 +0000 Subject: [PATCH 13/17] modify QuestionViewSet to validate answers on create and update --- server/api/question/serializers.py | 28 ++++++++---- server/api/question/tests.py | 32 +++++++------- server/api/question/views.py | 71 ++++++++++++++++-------------- 3 files changed, 74 insertions(+), 57 deletions(-) diff --git a/server/api/question/serializers.py b/server/api/question/serializers.py index c04c6f17..7ac5c57e 100644 --- a/server/api/question/serializers.py +++ b/server/api/question/serializers.py @@ -46,16 +46,11 @@ class QuestionSerializer(serializers.ModelSerializer): categories = CategorySerializer(read_only=True, many=True) is_comp = serializers.BooleanField(required=False, default=False) answers = AnswerSerializer(required=False, many=True) - + def create(self, validated_data): """ - Create a new Question instance. - - Args: - validated_data (dict): The validated data for the question. - - Returns: - Question: The created question instance. + Override the default create method to set the created_by and modified_by fields + and handle nested answer data. """ request = self.context.get('request') validated_data['created_by'] = request.user @@ -70,6 +65,23 @@ def create(self, validated_data): return question + def update(self, instance, validated_data): + """ + Override the default update method to set the modified_by field + and handle nested answer data. + """ + request = self.context.get('request') + validated_data['modified_by'] = request.user + + answers_data = validated_data.pop('answers', []) + + instance = super().update(instance, validated_data) + + instance.answers.all().delete() + for answer_data in answers_data: + Answer.objects.create(question=instance, **answer_data) + return instance + class Meta: model = Question fields = '__all__' diff --git a/server/api/question/tests.py b/server/api/question/tests.py index e4954f18..6f38faa8 100644 --- a/server/api/question/tests.py +++ b/server/api/question/tests.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User from rest_framework.test import APIClient from rest_framework import status -from .models import Question, Category +from .models import Question, Category, Answer class QuestionCategoryTestCase(TestCase): @@ -14,13 +14,15 @@ def setUp(self): self.category = Category.objects.create( genre='Science', info='Science related questions') self.question = Question.objects.create( - name='What is the boiling point of water?', + name='sample question', question_text='At what temperature does water boil?', - answer=[1], - answer_text='Water boils at 100 degrees Celsius.', + solution_text='Water boils at 100 degrees Celsius.', is_comp=False, diff_level=1 ) + answer1 = Answer.objects.create(value=1, question=self.question) + answer2 = Answer.objects.create(value=2, question=self.question) + self.question.answers.set([answer1, answer2]) self.question.categories.add(self.category) def test_create_category(self): @@ -53,21 +55,21 @@ def test_genre_name_unique(self): def test_question_integrity(self): response = self.client.post('/api/questions/question-bank/', { - 'name': 'What is the boiling point of water?', + 'name': 'sample question', 'question_text': 'At what temperature does water boil?', - 'answer': [1], - 'answer_text': 'Water boils at 100 degrees Celsius.', + 'answers': [1], + 'solution_text': 'Water boils at 100 degrees Celsius.', 'is_comp': False, 'diff_level': 1, }) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Question.objects.count(), 1) - def test_question_must_have_answer(self): + def test_question_must_have_answers(self): response = self.client.post('/api/questions/question-bank/', { 'name': 'What is the boiling point of water?', 'question_text': 'At what temperature does water boil?', - 'answer_text': 'Water boils at 100 degrees Celsius.', + 'solution_text': 'Water boils at 100 degrees Celsius.', 'is_comp': False, 'diff_level': 1, }) @@ -78,19 +80,19 @@ def test_question_must_have_diff_level(self): response = self.client.post('/api/questions/question-bank/', { 'name': 'What is the boiling point of water?', 'question_text': 'At what temperature does water boil?', - 'answer': [1], - 'answer_text': 'Water boils at 100 degrees Celsius.', + 'answers': [1], + 'solution_text': 'Water boils at 100 degrees Celsius.', 'is_comp': False, }) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(Question.objects.count(), 1) - def test_question_can_only_have_one_answer(self): + def test_question_can_have_multiple_answers(self): response = self.client.post('/api/questions/question-bank/', { - 'name': 'What is the boiling point of water2?', + 'name': 'What is the boiling point of water?', 'question_text': 'At what temperature does water boil?', - 'answer': [1, 2], - 'answer_text': 'Water boils at 100 degrees Celsius.', + 'answers': [1, 2], + 'solution_text': 'Water boils at 100 degrees Celsius.', 'is_comp': False, 'diff_level': 1, }) diff --git a/server/api/question/views.py b/server/api/question/views.py index f696568c..6ab10480 100644 --- a/server/api/question/views.py +++ b/server/api/question/views.py @@ -11,7 +11,7 @@ @permission_classes([IsAdminUser]) class QuestionViewSet(viewsets.ModelViewSet): """ - A viewset for viewing and editing Question instances. + A viewset for viewing and editing Question instances.Not supported for PATCH requests. Attributes: queryset (QuerySet): The queryset of Question instances. @@ -26,40 +26,53 @@ class QuestionViewSet(viewsets.ModelViewSet): search_fields = ['name'] filterset_fields = ['mark', 'answers__value'] + # override the create method def create(self, request, *args, **kwargs): - """ - Create a new Question instance. - - Args: - request (Request): The request object. - - Returns: - Response: The response object. - """ # validate integrity of name name = request.data.get('name') - answers_list = request.data.get('answers') - answers = [] - if Question.objects.filter(name=name).exists(): return Response({'error': 'Question with this name already exists'}, status=status.HTTP_400_BAD_REQUEST) - for answer in answers_list: - answer = {'value': answer} - answers.append(answer) - request.data['answers'] = answers - return super().create(request, *args, **kwargs) + response = self.handle_answers(request, True) + return response - def perform_update(self, serializer): - """ - Update an existing Question instance. + # override the update method + def update(self, request, *args, **kwargs): + response = self.handle_answers(request, False) + return response - Args: - serializer (Serializer): The serializer object. - """ + # override the partial_update method to disable PATCH requests + def partial_update(self, request, *args, **kwargs): + return Response({'error': 'PATCH method is not allowed'}, status=status.HTTP_405_METHOD_NOT_ALLOWED) + + # override the perform_update method + def perform_update(self, serializer): instance = serializer.save(modified_by=self.request.user) instance.time_modified = now() instance.save() + def handle_answers(self, request, is_create, *args, **kwargs): + answers = request.data.get('answers') + if is_create: + if not answers or len(answers) == 0: + return Response({'error': 'Answers field is required'}, status=status.HTTP_400_BAD_REQUEST) + if isinstance(answers[0], dict): + if is_create: + return super().create(request, *args, **kwargs) + else: + return super().update(request, *args, **kwargs) + else: + answers_list = [{'value': int(answer)} for answer in answers] + data = request.data.copy() + data['answers'] = answers_list + if is_create: + serializer = self.get_serializer(data=data) + else: + instance = self.get_object() + serializer = self.get_serializer(instance, data=data) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + # @action(detail=False, methods=['get']) # def get_random_question(self, request): # """ @@ -92,15 +105,6 @@ class CategoryViewSet(viewsets.ModelViewSet): search_fields = ['name'] def create(self, request, *args, **kwargs): - """ - Create a new Category instance. - - Args: - request (Request): The request object. - - Returns: - Response: The response object. - """ # validate integrity of genre genre = request.data.get('genre') if Category.objects.filter(genre=genre).exists(): @@ -111,4 +115,3 @@ def create(self, request, *args, **kwargs): class AnswerViewSet(viewsets.ModelViewSet): queryset = Answer.objects.all() serializer_class = AnswerSerializer - filterset_fields = ['question'] From 2f0f5878732f20cd113eb575f9728aabedbaa6a0 Mon Sep 17 00:00:00 2001 From: Yunho Date: Sat, 25 Jan 2025 16:34:01 +0000 Subject: [PATCH 14/17] fix: remove unnecessary blank line in QuestionSerializer --- server/api/question/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/question/serializers.py b/server/api/question/serializers.py index 7ac5c57e..230c3002 100644 --- a/server/api/question/serializers.py +++ b/server/api/question/serializers.py @@ -46,7 +46,7 @@ class QuestionSerializer(serializers.ModelSerializer): categories = CategorySerializer(read_only=True, many=True) is_comp = serializers.BooleanField(required=False, default=False) answers = AnswerSerializer(required=False, many=True) - + def create(self, validated_data): """ Override the default create method to set the created_by and modified_by fields From ba8994663c247c4743942169fcbfba2110b9d064 Mon Sep 17 00:00:00 2001 From: Yunho Date: Sat, 25 Jan 2025 17:00:32 +0000 Subject: [PATCH 15/17] feat: add pagination settings to REST framework configuration --- ...er_remove_question_answer_text_and_more.py | 42 +++++++++++++++++++ server/api/settings.py | 5 +++ 2 files changed, 47 insertions(+) create mode 100644 server/api/question/migrations/0002_remove_question_answer_remove_question_answer_text_and_more.py diff --git a/server/api/question/migrations/0002_remove_question_answer_remove_question_answer_text_and_more.py b/server/api/question/migrations/0002_remove_question_answer_remove_question_answer_text_and_more.py new file mode 100644 index 00000000..d11f564a --- /dev/null +++ b/server/api/question/migrations/0002_remove_question_answer_remove_question_answer_text_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.5 on 2025-01-25 08:03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("question", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="question", + name="answer", + ), + migrations.RemoveField( + model_name="question", + name="answer_text", + ), + migrations.AddField( + model_name="question", + name="solution_text", + field=models.TextField(default=""), + ), + migrations.CreateModel( + name="Answer", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("value", models.IntegerField()), + ( + "question", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="answers", + to="question.question", + ), + ), + ], + ), + ] diff --git a/server/api/settings.py b/server/api/settings.py index 0fcb0548..fa7aa9e0 100644 --- a/server/api/settings.py +++ b/server/api/settings.py @@ -71,7 +71,12 @@ ), 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], +<<<<<<< HEAD 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination' +======= + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 5 +>>>>>>> 6dc9b0c (feat: add pagination settings to REST framework configuration) } SIMPLE_JWT = { From c3a57a67c1f00728f965a90832092764e74bf5ce Mon Sep 17 00:00:00 2001 From: Yunho Date: Sat, 25 Jan 2025 17:11:20 +0000 Subject: [PATCH 16/17] reverse change --- server/api/settings.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/server/api/settings.py b/server/api/settings.py index fa7aa9e0..70ac8583 100644 --- a/server/api/settings.py +++ b/server/api/settings.py @@ -71,12 +71,7 @@ ), 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], -<<<<<<< HEAD - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination' -======= - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 5 ->>>>>>> 6dc9b0c (feat: add pagination settings to REST framework configuration) + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', } SIMPLE_JWT = { From edde25a9bbf06e73a977169335bf0ec42a1a1707 Mon Sep 17 00:00:00 2001 From: Yunho Ding Date: Wed, 29 Jan 2025 03:44:15 +0000 Subject: [PATCH 17/17] Add marking action to AdminQuizViewSet for grading quiz attempts; add auto calculation of total_mark of a quiz --- server/api/quiz/views.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/server/api/quiz/views.py b/server/api/quiz/views.py index 7452e13a..70d8709c 100644 --- a/server/api/quiz/views.py +++ b/server/api/quiz/views.py @@ -63,8 +63,42 @@ def slots(self, request, pk=None): serializer = QuizSlotSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) serializer.save() + quiz = Quiz.objects.get(pk=pk) + quiz_slots = quiz.quiz_slots.all() + questions = [slot.question for slot in quiz_slots] + quiz.total_marks = sum([question.mark for question in questions]) + quiz.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + @action(detail=True, methods=['get']) + def marking(self, request, pk=None): + """ + Mark the quiz attempt. + """ + + # get the quiz attempts + quiz_attempts = QuizAttempt.objects.all().filter(quiz_id=pk) + + for attempt in quiz_attempts: + question_attempts = attempt.question_attempts.all() + total_marks = 0 + for question_attempt in question_attempts: + question = question_attempt.question.all() + answers = [ + question.answer.value.all() for question.answer in question.answers] + if question_attempt.answer_student in answers: + total_marks += question.mark + question_attempt.is_correct = True + question_attempt.save() + else: + question_attempt.is_correct = False + question_attempt.save() + attempt.total_marks = total_marks + attempt.save() + + return Response({'message': 'Quiz attempt marked successfully.'}) + @permission_classes([AllowAny]) class QuizViewSet(viewsets.ReadOnlyModelViewSet):