diff --git a/backend/app/apps.py b/backend/app/apps.py index e8eab2de..ea03398a 100644 --- a/backend/app/apps.py +++ b/backend/app/apps.py @@ -1,7 +1,6 @@ from django.apps import AppConfig import requests from django.db.models.signals import post_migrate, post_init - import json diff --git a/backend/app/migrations/0001_initial.py b/backend/app/migrations/0001_initial.py index 01c414c4..cbcdff1f 100644 --- a/backend/app/migrations/0001_initial.py +++ b/backend/app/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.16 on 2024-11-22 11:51 +# Generated by Django 4.2.16 on 2024-11-23 14:38 from django.conf import settings import django.contrib.postgres.fields @@ -16,6 +16,29 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='Quiz', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100)), + ('description', models.TextField()), + ('level', models.CharField(choices=[('A1', 'A1'), ('A2', 'A2'), ('B1', 'B1'), ('B2', 'B2'), ('C1', 'C1'), ('C2', 'C2')], max_length=2)), + ('question_count', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('times_taken', models.IntegerField(default=0)), + ('total_score', models.FloatField(default=0)), + ('time_limit', models.IntegerField(default=0)), + ('like_count', models.IntegerField(default=0)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Tags', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ], + ), migrations.CreateModel( name='Word', fields=[ @@ -45,6 +68,51 @@ class Migration(migrations.Migration): ('word', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='relationships', to='app.word')), ], ), + migrations.CreateModel( + name='QuizResults', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('score', models.FloatField()), + ('time_taken', models.IntegerField()), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='app.quiz')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='QuizProgress', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('score', models.FloatField(default=0)), + ('quiz_attempt', models.IntegerField(default=0)), + ('date_started', models.DateTimeField(default=django.utils.timezone.now)), + ('completed', models.BooleanField(default=False)), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='app.quiz')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quiz_progress', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('quiz', 'user', 'quiz_attempt')}, + }, + ), + migrations.AddField( + model_name='quiz', + name='tags', + field=models.ManyToManyField(related_name='quizzes', to='app.tags'), + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question_number', models.IntegerField()), + ('question_text', models.TextField()), + ('level', models.CharField(choices=[('A1', 'A1'), ('A2', 'A2'), ('B1', 'B1'), ('B2', 'B2'), ('C1', 'C1'), ('C2', 'C2')], max_length=2)), + ('choice1', models.CharField(max_length=100)), + ('choice2', models.CharField(max_length=100)), + ('choice3', models.CharField(max_length=100)), + ('choice4', models.CharField(max_length=100)), + ('correct_choice', models.IntegerField()), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='app.quiz')), + ], + ), migrations.CreateModel( name='Profile', fields=[ @@ -90,6 +158,20 @@ class Migration(migrations.Migration): ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to=settings.AUTH_USER_MODEL)), ], ), + migrations.CreateModel( + name='QuestionProgress', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('answer', models.IntegerField(default=0)), + ('time_taken', models.IntegerField(default=0)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='app.question')), + ('quiz_progress', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='question_progress', to='app.quizprogress')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='question_progress', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('question', 'quiz_progress')}, + }, + ), migrations.CreateModel( name='Bookmark', fields=[ diff --git a/backend/app/migrations/0002_remove_questionprogress_user.py b/backend/app/migrations/0002_remove_questionprogress_user.py new file mode 100644 index 00000000..a172078a --- /dev/null +++ b/backend/app/migrations/0002_remove_questionprogress_user.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-11-23 14:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='questionprogress', + name='user', + ), + ] diff --git a/backend/app/models.py b/backend/app/models.py index 39cb5e4f..3f85a9fe 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -14,6 +14,12 @@ ('NA', 'NA') ] +class Tags(models.Model): + name = models.CharField(max_length=50) + + def __str__(self): + return self.name + class Profile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") @@ -25,6 +31,88 @@ def __str__(self): return self.name +class Quiz(models.Model): + LEVEL_CHOICES = [ + ('A1', 'A1'), + ('A2', 'A2'), + ('B1', 'B1'), + ('B2', 'B2'), + ('C1', 'C1'), + ('C2', 'C2'), + ] + + title = models.CharField(max_length=100) + description = models.TextField() + author = models.ForeignKey('auth.User', on_delete=models.CASCADE, related_name='quizzes') + tags = models.ManyToManyField('Tags', related_name='quizzes') + level = models.CharField(max_length=2, choices=LEVEL_CHOICES) + question_count = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + times_taken = models.IntegerField(default=0) + total_score = models.FloatField(default=0) + time_limit = models.IntegerField(default=0) + like_count = models.IntegerField(default=0) + + def __str__(self): + return self.title + + +class Question(models.Model): + LEVEL_CHOICES = Quiz.LEVEL_CHOICES + quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name='questions') + question_number = models.IntegerField() + question_text = models.TextField() + level = models.CharField(max_length=2, choices=LEVEL_CHOICES) + choice1 = models.CharField(max_length=100) + choice2 = models.CharField(max_length=100) + choice3 = models.CharField(max_length=100) + choice4 = models.CharField(max_length=100) + correct_choice = models.IntegerField() + + def __str__(self): + return self.question_text + + +class QuizProgress(models.Model): + id = models.AutoField(primary_key=True) + quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name='progress') + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='quiz_progress') + score = models.FloatField(default=0) + quiz_attempt = models.IntegerField(default=0) + date_started = models.DateTimeField(default=timezone.now) + completed = models.BooleanField(default=False) + + class Meta: + unique_together = ('quiz', 'user', "quiz_attempt") # Enforce unique combination of quiz and user + + + def __str__(self): + return self.quiz.title + ' - ' + self.user.username + +class QuizResults(models.Model): + id = models.AutoField(primary_key=True) + quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name='results') + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='results') + score = models.FloatField() + time_taken = models.IntegerField() + + def __str__(self): + return self.quiz.title + ' - ' + self.user.username + + +class QuestionProgress(models.Model): + id = models.AutoField(primary_key=True) + question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='progress') + answer = models.IntegerField(default=0) + time_taken = models.IntegerField(default=0) + quiz_progress = models.ForeignKey(QuizProgress, on_delete=models.CASCADE, related_name='question_progress') + + class Meta: + unique_together = ('question', 'quiz_progress') # Enforce unique combination of question and user + + def __str__(self): + return self.question.question_text + ' - ' + self.user.username + class Post(models.Model): id = models.AutoField(primary_key=True) tags = ArrayField( diff --git a/backend/app/serializers.py b/backend/app/serializers.py index cd61ad33..00114552 100644 --- a/backend/app/serializers.py +++ b/backend/app/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from django.contrib.auth.models import User -from .models import Profile, Post, Comment +from .models import Profile, Quiz, Post, QuizResults, QuizProgress, QuestionProgress, Question, Comment, Tags @@ -70,7 +70,97 @@ def update(self, instance, validated_data): return instance +class QuizResultsSerializer(serializers.ModelSerializer): + class Meta: + model = QuizResults + fields = ['quiz', 'user', 'score', 'time_taken'] + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation['quiz'] = { 'id' : instance.quiz.id, 'title' : instance.quiz.title } + representation['question_count'] = instance.quiz.question_count + representation['user'] = { 'id' : instance.user.id, 'username' : instance.user.username } + representation['author'] = { 'id' : instance.quiz.author.id, 'username' : instance.quiz.author.username } + return representation + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tags + fields = ['id', 'name'] + +class QuizSerializer(serializers.ModelSerializer): + tags = TagSerializer(many=True) + level = serializers.ChoiceField(choices=Quiz.LEVEL_CHOICES) + + class Meta: + model = Quiz + fields = [ + 'id', + 'title', + 'description', + 'author', + 'tags', + 'level', + 'question_count', + 'created_at', + 'times_taken', + 'total_score', + 'time_limit', + 'like_count', + ] + + def create(self, validated_data): + tags_data = validated_data.pop('tags') + quiz = Quiz.objects.create(**validated_data) + for tag_data in tags_data: + tag, _ = Tags.objects.get_or_create(name=tag_data['name']) + quiz.tags.add(tag) + + return quiz + + def to_representation(self, instance): + representation = super().to_representation(instance) + + # Transform tags to a list of names + representation['tags'] = [tag['name'] for tag in representation['tags']] + representation['author'] = { 'id' : instance.author.id, 'username' : instance.author.username } + return representation + +class QuizProgressSerializer(serializers.ModelSerializer): + class Meta: + model = QuizProgress + fields = ['quiz', 'user', 'score', 'quiz_attempt', 'date_started', 'completed'] + + +class QuestionSerializer(serializers.ModelSerializer): + level = serializers.ChoiceField(choices=Question.LEVEL_CHOICES) + class Meta: + model = Question + fields = [ + 'quiz', + 'id', + 'question_number', + 'question_text', + 'choice1', + 'choice2', + 'choice3', + 'choice4', + 'correct_choice', + 'level', + ] + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation.pop('quiz') + representation['quiz_id'] = instance.quiz.id + return representation + +class QuestionProgressSerializer(serializers.ModelSerializer): + class Meta: + model = QuestionProgress + fields = ['question', 'quiz_progress', 'answer', 'time_taken'] class PostSerializer(serializers.ModelSerializer): @@ -80,11 +170,11 @@ class Meta: model = Post fields = ['id', 'title', 'description', 'author', 'tags', 'created_at', 'like_count', 'comments'] + def get_comments(self, obj): comments = Comment.objects.filter(post=obj) # Fetch all comments for the post return CommentSerializer(comments, many=True).data - class CommentSerializer(serializers.ModelSerializer): replies = serializers.SerializerMethodField() # Fetch nested replies diff --git a/backend/app/urls.py b/backend/app/urls.py index 3f439193..05ffe172 100644 --- a/backend/app/urls.py +++ b/backend/app/urls.py @@ -12,11 +12,22 @@ from app.views_directory.bookmark_views import bookmark_post, unbookmark_post, get_bookmarked_posts +import app.views_directory.quiz_views as quiz_views urlpatterns = [ path('', index , name='index_page'), path('profile/', view_profile, name='view_profile'), path('profile/update/', update_profile, name='update_profile'), + path('quiz/', quiz_views.get_quiz, name="get_quiz"), + path('feed/quiz/', quiz_views.view_quizzes, name="feed_quiz"), + path('quiz/create/', quiz_views.create_quiz, name="create_quiz"), + path('quiz/question/', quiz_views.get_question, name="get_question"), + path('quiz/question/solve/', quiz_views.solve_question, name="solve_question"), + path('quiz/submit/', quiz_views.submit_quiz, name="submit_quiz"), + path('quiz/start/', quiz_views.start_quiz, name="start_quiz"), + path('quiz/results/', quiz_views.get_quiz_results, name="get_quiz_results"), + + path('create-post/',create_post, name='create_post'), path('signup/', RegisterView.as_view(), name='auth_register'), path('login/', LoginView.as_view(), name='auth_login'), path('logout/', LogoutView.as_view(), name='auth_logout'), diff --git a/backend/app/views_directory/quiz_views.py b/backend/app/views_directory/quiz_views.py new file mode 100644 index 00000000..7eb72197 --- /dev/null +++ b/backend/app/views_directory/quiz_views.py @@ -0,0 +1,195 @@ +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status +from django.shortcuts import get_object_or_404 +from app.serializers import QuizSerializer, QuizResultsSerializer, QuestionSerializer, QuizProgressSerializer, QuestionProgressSerializer +from django.contrib.auth.models import User +from app.models import Quiz, QuizResults, Question, QuizProgress, QuestionProgress + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def create_quiz(request): + if request.data.get('quiz') is None or request.data.get('questions') is None: + return Response({'error': 'quiz and questions must be provided'}, status=status.HTTP_400_BAD_REQUEST) + data = request.data + data['quiz']['author'] = request.user.id + questions = data['questions'] + data['quiz']['question_count'] = len(questions) + quizSerializer = QuizSerializer(data=data['quiz']) + quiz = None + if not quizSerializer.is_valid(): + print("was") + return Response(quizSerializer.errors, status=status.HTTP_400_BAD_REQUEST) + quiz = quizSerializer.save() + question_serializers = [] + for question in questions: + question['quiz'] = quiz.id + question['level'] = quiz.level # TODO: change this + questionSerializer = QuestionSerializer(data=question) + if questionSerializer.is_valid(): + print(questionSerializer.validated_data['question_number']) + question_serializers.append(questionSerializer) + else: + quiz.delete() + return Response(questionSerializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # quizSerializer.save() + for question_serializer in question_serializers: + question_serializer.save() + return Response(quizSerializer.data, status=status.HTTP_201_CREATED) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def view_quizzes(request): + quizzes = Quiz.objects.all() + # TODO: paginate the results + serializer = QuizSerializer(quizzes, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def submit_quiz(request): + quiz_progress = get_object_or_404(QuizProgress, id=request.data['quiz_progress_id']) + if quiz_progress.completed: + return Response({'error': 'Quiz already submitted.'}, status=status.HTTP_400_BAD_REQUEST) + quiz_progress.quiz.id + quiz = get_object_or_404(Quiz, id=quiz_progress.quiz.id) + + questions = Question.objects.filter(quiz=quiz.id) + # print(questions) + question_progresses = [] + + for question in questions: + print(question.id, request.user.id) + question_progress = get_object_or_404(QuestionProgress, question=question.id, quiz_progress=quiz_progress) + if question_progress.answer is 0: + return Response({'error': 'Please answer all questions'}, status=status.HTTP_400_BAD_REQUEST) + question_progresses.append(question_progress) + + score = sum([1 for question_progress in question_progresses if question_progress.question.correct_choice == question_progress.answer]) + + quizResults = QuizResultsSerializer(data = {'quiz': quiz.id, 'user': request.user.id, 'score': score, 'time_taken': quiz_progress.quiz_attempt}) + if quizResults.is_valid(): + quizResults.save() + + quiz_progress.completed = True + quiz_progress.save() + + + # update time taken of quiz in database + quiz.times_taken += 1 + + return Response({'score': score}, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_quiz_results(request): + quizResults = QuizResults.objects.filter(user=request.user) + serializer = QuizResultsSerializer(quizResults, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def start_quiz(request): + quiz = get_object_or_404(Quiz, id=request.data['quiz_id']) + + # get quiz progresses, if the highest is not completed, retrieve that + quiz_progresses = QuizProgress.objects.filter(quiz=quiz, user=request.user, completed = False) + if quiz_progresses.count() > 1: + return Response({'error': 'Multiple quiz progresses found'}, status=status.HTTP_400_BAD_REQUEST) + elif quiz_progresses.count() == 1: + quiz_progress = quiz_progresses[0] + question_progress = QuestionProgress.objects.filter(quiz_progress=quiz_progress) + if question_progress.count() == 0: + return Response({'error': 'No question progress found'}, status=status.HTTP_400_BAD_REQUEST) + else: + data = {'questions': []} + for question_progress in question_progress: + data['questions'].append({'question': question_progress.question.question_text, + 'choices': [question_progress.question.choice1, question_progress.question.choice2, + question_progress.question.choice3, question_progress.question.choice4], + 'question_number': question_progress.question.question_number, + 'previous_answer': question_progress.answer}) + data["quiz_progress_id"] = quiz_progress.id + return Response(data, status=status.HTTP_200_OK) + + # no quiz progress found, create a new one + # get highest quiz attempt yet + quiz_progresses = QuizProgress.objects.filter(quiz=quiz, user=request.user) + quiz_attempt = 1 + if quiz_progresses.count() > 0: + quiz_attempt = max([quiz_progress.quiz_attempt for quiz_progress in quiz_progresses]) + 1 + quiz_progress = QuizProgress.objects.create(quiz=quiz, user=request.user, quiz_attempt=quiz_attempt) + + for question in Question.objects.filter(quiz=quiz): + question_progress = QuestionProgress.objects.create(quiz_progress= quiz_progress, question= question) + question_progress.answer = 0 + + question_progress_serializer = QuestionProgressSerializer( + instance=question_progress, + data={"answer": 0}, + partial=True + ) + + # send all questions to the user + questions = Question.objects.filter(quiz=quiz) + data = {"questions": []} + for question in questions: + question_progress = get_object_or_404(QuestionProgress, question=question, quiz_progress= quiz_progress) + data["questions"].append({'question': question.question_text, + 'choices': [question.choice1, question.choice2, + question.choice3, question.choice4], + 'question_number': question.question_number, + 'previous_answer': question_progress.answer}) + data["quiz_progress_id"] = quiz_progress.id + + return Response(data, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def solve_question(request): + quizProgress = get_object_or_404(QuizProgress, id=request.data['quiz_progress_id']) + quiz = quizProgress.quiz + question = get_object_or_404(Question, question_number = request.data['question_number'], quiz = quiz) + + question_progress = get_object_or_404(QuestionProgress, question=question, quiz_progress=request.data['quiz_progress_id']) + + question_progress_serializer = QuestionProgressSerializer( + instance=question_progress, + data={"answer": request.data['answer']}, + partial=True + ) + + if question_progress_serializer.is_valid(): + question_progress_serializer.save() + return Response({"detail": "Question progress updated."}, status=status.HTTP_200_OK) + else: + return Response(question_progress_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_question(request): + question = get_object_or_404(Question, question_number = request.data['question_number'], quiz=request.data['quiz_id']) + print(question) + questionProgress, _ = QuestionProgress.objects.get_or_create(question=question, user=request.user) + previous_answer = None + if questionProgress is not None: + previous_answer = questionProgress.answer + + return Response({'question': question.question_text, 'choices': [question.choice1, question.choice2, question.choice3, question.choice4], 'previous_answer': previous_answer}, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_quiz(request): + quiz = get_object_or_404(Quiz, id=request.data['quiz_id']) + serializer = QuizSerializer(quiz) + + return Response(serializer.data, status=status.HTTP_200_OK) +