diff --git a/client/src/components/layout.tsx b/client/src/components/layout.tsx index 93d0b27..2a43b64 100644 --- a/client/src/components/layout.tsx +++ b/client/src/components/layout.tsx @@ -64,12 +64,7 @@ export function ProtectedPage({ children, requiredRoles }: ProtectedPageProps) { ); case "authorized": return ( - - {children} - + {children} ); default: return ; diff --git a/client/src/components/ui/Quiz/comp-start.tsx b/client/src/components/ui/Quiz/comp-start.tsx index b430cb2..9729991 100644 --- a/client/src/components/ui/Quiz/comp-start.tsx +++ b/client/src/components/ui/Quiz/comp-start.tsx @@ -182,8 +182,7 @@ export function CompStart({ }); window.alert("Your answers have been submitted successfully."); // refresh the page to get the latest data - window.location.reload(); - router.push("/"); + window.location.href = "/quiz"; } console.log("Final answers:", userAnswers); } diff --git a/client/src/components/ui/Quiz/quiz-intro.tsx b/client/src/components/ui/Quiz/quiz-intro.tsx index f911441..a4479c6 100644 --- a/client/src/components/ui/Quiz/quiz-intro.tsx +++ b/client/src/components/ui/Quiz/quiz-intro.tsx @@ -5,16 +5,24 @@ interface Props { onStart: () => void; quizName: string; startTime: string | Date; + timeWindow: number; quizDuration: number; numberOfQuestions: number; + isFinished: boolean; + isEntryClosed: boolean; + isSubmitted: boolean; } export default function QuizIntro({ onStart, quizName, startTime, + timeWindow, quizDuration, numberOfQuestions, + isFinished, + isEntryClosed, + isSubmitted, }: Partial) { let headingStyle = `text-xl sm:text-2xl md:text-3xl text-slate-800 font-bold`; let generalInstructions = @@ -34,6 +42,9 @@ export default function QuizIntro({ className="flex-row gap-1 font-bold" /> +
+ Competition will allow entering within {timeWindow} minutes +

Individual Quiz

{quizDuration} minutes

@@ -48,9 +59,23 @@ export default function QuizIntro({ not be expected to be to scale.

- + {isFinished ? ( + + ) : isEntryClosed ? ( + + ) : isSubmitted ? ( + + ) : ( + + )}
); diff --git a/client/src/components/ui/mobilenav.tsx b/client/src/components/ui/mobilenav.tsx index 542909f..781dc13 100644 --- a/client/src/components/ui/mobilenav.tsx +++ b/client/src/components/ui/mobilenav.tsx @@ -1,12 +1,16 @@ import { AlignJustify, X } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/router"; import { Drawer } from "vaul"; import { Button } from "@/components/ui/button"; import { LoginModal } from "@/components/ui/Users/login-modal"; +import { useAuth } from "@/context/auth-provider"; export default function MobileNav() { + const router = useRouter(); + const { isLoggedIn } = useAuth(); return ( @@ -42,15 +46,26 @@ export default function MobileNav() { Awards Quizzes Contact us - + {isLoggedIn ? ( - + ) : ( + + + + )} {/* */} diff --git a/client/src/pages/quiz/competition/[id].tsx b/client/src/pages/quiz/competition/[id].tsx index 38f9cbb..574a172 100644 --- a/client/src/pages/quiz/competition/[id].tsx +++ b/client/src/pages/quiz/competition/[id].tsx @@ -18,15 +18,18 @@ import { CompetitionSlot, QuizAttempt, QuizAttemptResponse, + QuizState, } from "@/types/quiz"; export default function CompetitionQuizPage() { const router = useRouter(); const compId = router.query.id as string; const [start, setStart] = useState(false); + const [isFinished, setIsFinished] = useState(false); + const [isEntryClosed, setIsEntryClosed] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); const handleStart = () => { setStart(true); - // console.log(compId); }; const { @@ -40,9 +43,53 @@ export default function CompetitionQuizPage() { enabled: !!compId, // Only run the query if compId is defined }); - if (!compId) return ; + const { + data: quizAttemptData, + isLoading: isQuizAttemptDataLoading, + error: quizAttemptDataError, + } = useFetchData<{ + results: QuizAttempt[]; + count: number; + next: string | null; + previous: string | null; + }>({ + queryKey: ["quizAttemptList"], + endpoint: `/quiz/quiz-attempts/`, + }); + + const { userId } = useAuth(); + useEffect(() => { + if (compData) { + const filteredAttempt = quizAttemptData?.results?.filter( + (attempt) => + attempt.student_user_id === userId && + attempt.quiz === compData.id && + (attempt.state === QuizState.SUBMITTED || + attempt.state === QuizState.COMPLETED), + ); + if (filteredAttempt?.length === 1) setIsSubmitted(true); + } + }, [quizAttemptData, compData]); + + useEffect(() => { + if (compData) { + const endTime = new Date(compData.open_time_date); + endTime.setMinutes(endTime.getMinutes() + compData.time_limit); + const endWindowTime = new Date(compData.open_time_date); + endWindowTime.setMinutes( + endWindowTime.getMinutes() + compData.time_window, + ); + const now = new Date(); + if (now > endTime) { + setIsFinished(true); + } else if (now > endWindowTime) { + setIsEntryClosed(true); + } + } + }, [compData]); + + if (!compId || !quizAttemptData) return ; - console.log(compId); if (!start) { return ( ); diff --git a/client/src/pages/quiz/index.tsx b/client/src/pages/quiz/index.tsx index e81d8bd..a98c46e 100644 --- a/client/src/pages/quiz/index.tsx +++ b/client/src/pages/quiz/index.tsx @@ -1,5 +1,6 @@ "use client"; +import Cookies from "js-cookie"; import { createContext, useContext, useEffect } from "react"; import { PublicPage } from "@/components/layout"; @@ -41,22 +42,24 @@ const QuizPage = () => { endpoint: "/quiz/competition/", }); + const userRole = Cookies.get("user_role"); if (isQuizDataLoading || !quizData || isCompQuizDataLoading || !compQuizData) return ; if (isQuizDataError) return
Error Quiz: {QuizDataError?.message}
; - if (isCompQuizDataError) + if (userRole && isCompQuizDataError) return
Error Competition: {compQuizDataError?.message}
; return (
- {compQuizData.results.length > 0 ? ( - - ) : ( - - )} + {userRole && + (compQuizData.results.length > 0 ? ( + + ) : ( + + ))}
); diff --git a/client/src/pages/team/submit-success.tsx b/client/src/pages/team/submit-success.tsx new file mode 100644 index 0000000..dc11d33 --- /dev/null +++ b/client/src/pages/team/submit-success.tsx @@ -0,0 +1,13 @@ +import { useRouter } from "next/router"; + +import { Button } from "@/components/ui/button"; + +export default function SubmitSuccess() { + const router = useRouter(); + return ( + <> +
Team submission completed!
+ + + ); +} diff --git a/client/src/types/quiz.ts b/client/src/types/quiz.ts index 69d35ec..597b5cd 100644 --- a/client/src/types/quiz.ts +++ b/client/src/types/quiz.ts @@ -163,8 +163,10 @@ export interface QuizAttempt { time_finish: Date; time_modified: Date; total_marks: number; + dead_line: Date; quiz: number; student: number; + student_user_id: number; team: number; } diff --git a/server/api/quiz/models.py b/server/api/quiz/models.py index c742a2a..9b1b95f 100644 --- a/server/api/quiz/models.py +++ b/server/api/quiz/models.py @@ -19,6 +19,7 @@ class Quiz(models.Model): visible (BooleanField): Notes whether the quiz is visible. open_time_date (DateTimeField): Notes when the quiz opens. time_limit (Integer): Denotes the time allotted for each quiz. + time_window: The amount of time after quiz start that a student has to be able to start the quiz """ id = models.AutoField(primary_key=True) @@ -42,9 +43,11 @@ def __str__(self): def clean(self): if self.is_comp: if self.open_time_date is None: - raise ValidationError({'open_time_date': 'This field is required for competition quizzes.'}) + raise ValidationError( + {'open_time_date': 'This field is required for competition quizzes.'}) if self.time_window is None: - raise ValidationError({'time_window': 'This field is required for competition quizzes.'}) + raise ValidationError( + {'time_window': 'This field is required for competition quizzes.'}) super().clean() def save(self, *args, **kwargs): @@ -64,7 +67,8 @@ class QuizSlot(models.Model): """ id = models.AutoField(primary_key=True) - quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name="quiz_slots") + quiz = models.ForeignKey( + Quiz, on_delete=models.CASCADE, related_name="quiz_slots") question = models.ForeignKey( Question, on_delete=models.CASCADE, default=None, related_name="slots" ) @@ -99,7 +103,8 @@ class State(models.IntegerChoices): COMPLETED = 4 id = models.AutoField(primary_key=True) - quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name="attempts") + quiz = models.ForeignKey( + Quiz, on_delete=models.CASCADE, related_name="attempts") student = models.ForeignKey( Student, on_delete=models.CASCADE, @@ -108,7 +113,8 @@ class State(models.IntegerChoices): 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, blank=True) time_modified = models.DateTimeField(auto_now=True) diff --git a/server/api/quiz/serializers.py b/server/api/quiz/serializers.py index b979843..7bd96b8 100644 --- a/server/api/quiz/serializers.py +++ b/server/api/quiz/serializers.py @@ -41,7 +41,8 @@ class QuizSlotSerializer(serializers.ModelSerializer): required=True, allow_null=True, ) - slot_index = serializers.IntegerField(required=True, min_value=0, max_value=99) + slot_index = serializers.IntegerField( + required=True, min_value=0, max_value=99) quiz_id = serializers.PrimaryKeyRelatedField( queryset=Quiz.objects.all(), write_only=True, @@ -55,7 +56,8 @@ def validate(self, data): quiz = data["quiz"] slot_index = data["slot_index"] if QuizSlot.objects.filter(quiz=quiz, slot_index=slot_index).exists(): - raise serializers.ValidationError("Slot index already exists for this quiz") + raise serializers.ValidationError( + "Slot index already exists for this quiz") return data class Meta: @@ -100,6 +102,8 @@ class Meta: class QuizAttemptSerializer(serializers.ModelSerializer): current_page = serializers.IntegerField(default=0, required=False) total_marks = serializers.IntegerField(default=0, required=False) + student_user_id = serializers.IntegerField( + source='student.user.id', read_only=True) class Meta: model = QuizAttempt diff --git a/server/api/quiz/urls.py b/server/api/quiz/urls.py index 354e71f..8010859 100644 --- a/server/api/quiz/urls.py +++ b/server/api/quiz/urls.py @@ -9,8 +9,8 @@ ) router = DefaultRouter() -router.register(r"admin-quizzes", AdminQuizViewSet, basename="") -router.register(r"all-quizzes", QuizViewSet) +router.register(r"admin-quizzes", AdminQuizViewSet, basename="") # all quizzes +router.register(r"all-quizzes", QuizViewSet) # all visible quizzes router.register(r"competition", CompetitionQuizViewSet, basename="competition") router.register(r"quiz-slots", QuizSlotViewSet) router.register(r"quiz-attempts", QuizAttemptViewSet) diff --git a/server/api/quiz/views.py b/server/api/quiz/views.py index ba05d49..aa35884 100644 --- a/server/api/quiz/views.py +++ b/server/api/quiz/views.py @@ -92,7 +92,8 @@ def slots(self, request, pk=None): 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.total_marks = sum( + [question.mark for question in questions]) quiz.save() return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -197,7 +198,7 @@ class CompetitionQuizViewSet(viewsets.ReadOnlyModelViewSet): """ queryset = Quiz.objects.filter( - status=1, visible=True).order_by("-created_at") + status=1, visible=True, is_comp=True).order_by("-created_at") serializer_class = UserQuizSerializer def get_permissions(self): @@ -443,8 +444,10 @@ def create(self, request, *args, **kwargs): print("request.user: ", request.user) print("request.user.student: ", request.user.student) print("request.user.student.id: ", request.user.student.id) - print("request.user.student.quiz_attempts: ", request.user.student.quiz_attempts) - print("request.user.student.quiz_attempts.all(): ", request.user.student.quiz_attempts.all()) + print("request.user.student.quiz_attempts: ", + request.user.student.quiz_attempts) + print("request.user.student.quiz_attempts.all(): ", + request.user.student.quiz_attempts.all()) print("-----------------------------------------") print("-----------------------------------------\n\n") quiz_id = request.data.get("quiz") @@ -504,7 +507,8 @@ def create(self, request, *args, **kwargs): # return super().create(request, *args, **kwargs) # Create a new QuizAttempt and assign the team data = request.data.copy() - data["team"] = team.id if team else None # Assign the team ID or None if no team is found + # Assign the team ID or None if no team is found + data["team"] = team.id if team else None serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) diff --git a/server/api/users/serializers.py b/server/api/users/serializers.py index dc2cce1..d008fe6 100644 --- a/server/api/users/serializers.py +++ b/server/api/users/serializers.py @@ -107,6 +107,7 @@ class StudentSerializer(serializers.ModelSerializer): It handles the serialization and deserialization of Student instances, including nested user data. Fields: + - user_id: get matching user id from user table - first_name: CharField, required, maps to user.first_name - last_name: CharField, required, maps to user.last_name - password: CharField, required, write-only, maps to user.password @@ -122,19 +123,21 @@ class StudentSerializer(serializers.ModelSerializer): - model: Student - exclude: ['user'] """ - + user_id = serializers.IntegerField(source='user.id', read_only=True) first_name = serializers.CharField(required=True, source="user.first_name") last_name = serializers.CharField(required=True, source="user.last_name") student_id = serializers.CharField(source="user.username", read_only=True) password = serializers.CharField( required=True, source="user.password", write_only=True ) - year_level = serializers.IntegerField(required=True, min_value=0, max_value=12) + year_level = serializers.IntegerField( + required=True, min_value=0, max_value=12) school_id = serializers.PrimaryKeyRelatedField( queryset=School.objects.all(), write_only=True, source="school" ) school = SchoolSerializer(read_only=True) - quiz_attempts = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + quiz_attempts = serializers.PrimaryKeyRelatedField( + many=True, read_only=True) def create(self, validated_data) -> Student: """ @@ -153,13 +156,15 @@ def create(self, validated_data) -> Student: user = validated_data.pop("user") random_str = self.random_digits(4) - random_str = str(now().year) + str(validated_data["school"].id) + random_str + random_str = str(now().year) + \ + str(validated_data["school"].id) + random_str user["username"] = random_str user_serializer = UserSerializer(data=user) # if the username clashes with another student, generate a new one while User.objects.filter(username=random_str).exists(): random_str = self.random_digits(4) - random_str = str(now().year) + str(validated_data["school"].id) + random_str + random_str = str(now().year) + \ + str(validated_data["school"].id) + random_str user["username"] = random_str user_serializer = UserSerializer(data=user) user_serializer.is_valid(raise_exception=True) @@ -215,6 +220,7 @@ class TeacherSerializer(serializers.ModelSerializer): It handles the serialization and deserialization of Teacher instances, including nested user data. Fields: + - user_id: get matching user id from user table - first_name: CharField, required, maps to user.first_name - last_name: CharField, required, maps to user.last_name - password: CharField, required, write-only, maps to user.password @@ -232,6 +238,7 @@ class TeacherSerializer(serializers.ModelSerializer): - exclude: ['user'] """ + user_id = serializers.IntegerField(source='user.id', read_only=True) first_name = serializers.CharField(required=True, source="user.first_name") last_name = serializers.CharField(required=True, source="user.last_name") password = serializers.CharField( @@ -257,7 +264,8 @@ def create(self, validated_data): # Extract and create the nested User instance user_data = validated_data.pop("user") # user_data['username'] = user_data['email'] - user_data["username"] = user_data["first_name"] + user_data["last_name"] + user_data["username"] = user_data["first_name"] + \ + user_data["last_name"] user_serializer = UserSerializer(data=user_data) user_serializer.is_valid(raise_exception=True) user = user_serializer.save()