diff --git a/django_email_learning/api/serializers.py b/django_email_learning/api/serializers.py index 4d242ea..9fd3690 100644 --- a/django_email_learning/api/serializers.py +++ b/django_email_learning/api/serializers.py @@ -1,6 +1,12 @@ -from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_serializer, + field_validator, + model_validator, +) from typing import Optional, Literal, Any -from django.core.exceptions import ValidationError from django_email_learning.models import ( Organization, ImapConnection, @@ -195,7 +201,7 @@ class AnswerCreate(BaseModel): is_correct: bool = Field(examples=[True]) -class AnswerResponse(BaseModel): +class AnswerObject(BaseModel): id: int text: str is_correct: bool @@ -215,11 +221,11 @@ def at_least_one_correct_answer( ) -> list[AnswerCreate]: correct_answers = [answer for answer in answers if answer.is_correct] if not correct_answers: - raise ValidationError("At least one answer must be marked as correct.") + raise ValueError("At least one answer must be marked as correct.") return answers -class QuestionResponse(BaseModel): +class QuestionObject(BaseModel): id: int text: str priority: int @@ -228,13 +234,20 @@ class QuestionResponse(BaseModel): @field_serializer("answers") def serialize_answers(self, answers: Any) -> list[dict]: return [ - AnswerResponse.model_validate(answer).model_dump() - for answer in answers.all() + AnswerObject.model_validate(answer).model_dump() for answer in answers.all() ] model_config = ConfigDict(from_attributes=True) +class UpdateQuiz(BaseModel): + questions: Optional[list[QuestionCreate]] = Field(min_length=1) + title: Optional[str] = None + required_score: Optional[int] = Field(ge=0, examples=[80], default=None) + + model_config = ConfigDict(extra="forbid") + + class QuizCreate(BaseModel): title: str required_score: int = Field(ge=0, examples=[80]) @@ -252,7 +265,7 @@ class QuizResponse(BaseModel): @field_serializer("questions") def serialize_questions(self, questions: Any) -> list[dict]: return [ - QuestionResponse.model_validate(question).model_dump() + QuestionObject.model_validate(question).model_dump() for question in questions.all() ] @@ -343,6 +356,32 @@ def to_django_model(self, course: Course) -> CourseContent: return course_content +class UpdateCourseContentRequest(BaseModel): + priority: Optional[int] = Field(gt=0, examples=[1], default=None) + waiting_period: Optional[WaitingPeriod] = None + lesson: Optional[LessonUpdate] = None + quiz: Optional[UpdateQuiz] = None + is_published: Optional[bool] = None + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def check_at_least_one(self) -> "UpdateCourseContentRequest": + # Check if all fields are None + fields = [ + self.priority, + self.waiting_period, + self.lesson, + self.quiz, + self.is_published, + ] + if not any(f is not None for f in fields): + raise ValueError( + "At least one of 'priority', 'waiting_period', 'lesson', 'quiz', or 'is_published' must be provided." + ) + return self + + class CourseContentResponse(BaseModel): id: int priority: int @@ -363,6 +402,7 @@ class CourseContentSummaryResponse(BaseModel): title: str priority: int waiting_period: int + is_published: bool type: str @field_serializer("waiting_period") diff --git a/django_email_learning/api/urls.py b/django_email_learning/api/urls.py index d03da41..96a788a 100644 --- a/django_email_learning/api/urls.py +++ b/django_email_learning/api/urls.py @@ -3,7 +3,6 @@ from django_email_learning.api.views import ( CourseView, ImapConnectionView, - LessonView, OrganizationsView, SingleCourseView, CourseContentView, @@ -39,11 +38,6 @@ SingleCourseContentView.as_view(), name="single_course_content_view", ), - path( - "organizations//lessons//", - LessonView.as_view(), - name="lesson_view", - ), path("organizations/", OrganizationsView.as_view(), name="organizations_view"), path("session", UpdateSessionView.as_view(), name="update_session_view"), path("", page_not_found, name="root"), diff --git a/django_email_learning/api/views.py b/django_email_learning/api/views.py index 9147df1..e6fc729 100644 --- a/django_email_learning/api/views.py +++ b/django_email_learning/api/views.py @@ -4,7 +4,8 @@ from django.db.utils import IntegrityError from django.http import JsonResponse from django.core.exceptions import ValidationError as DjangoValidationError -from django.db import models +from django.db import models, transaction + from pydantic import ValidationError from django_email_learning.api import serializers @@ -12,7 +13,6 @@ Course, CourseContent, ImapConnection, - Lesson, OrganizationUser, Organization, ) @@ -41,7 +41,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt status=201, ) except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) + return JsonResponse({"error": e.json()}, status=400) except (IntegrityError, ValueError) as e: return JsonResponse({"error": str(e)}, status=409) @@ -89,7 +89,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt except Course.DoesNotExist: return JsonResponse({"error": "Course not found"}, status=404) except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) + return JsonResponse({"error": e.json()}, status=400) except DjangoValidationError as e: return JsonResponse({"error": e.messages}, status=400) @@ -111,6 +111,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty @method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get") @method_decorator(accessible_for(roles={"admin", "editor"}), name="delete") +@method_decorator(accessible_for(roles={"admin", "editor"}), name="post") class SingleCourseContentView(View): def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] try: @@ -124,7 +125,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty except CourseContent.DoesNotExist: return JsonResponse({"error": "Course content not found"}, status=404) except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) + return JsonResponse({"error": e.json()}, status=400) def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def] try: @@ -136,11 +137,87 @@ def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def] except CourseContent.DoesNotExist: return JsonResponse({"error": "Course content not found"}, status=404) except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) + return JsonResponse({"error": e.json()}, status=400) except (IntegrityError, ValueError) as e: return JsonResponse({"error": str(e)}, status=409) - # TODO: Implement POST method for updating course content. + def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + payload = json.loads(request.body) + try: + serializer = serializers.UpdateCourseContentRequest.model_validate(payload) + except ValidationError as e: + return JsonResponse({"error": e.json()}, status=400) + except ValueError as e: + return JsonResponse({"error": str(e)}, status=400) + + try: + return self._update_course_content_atomic( + serializer, kwargs["course_content_id"] + ) + except CourseContent.DoesNotExist: + return JsonResponse({"error": "Course content not found"}, status=404) + except ValidationError as e: + return JsonResponse({"error": e.json()}, status=400) + except (IntegrityError, ValueError) as e: + return JsonResponse({"error": str(e)}, status=409) + + @transaction.atomic + def _update_course_content_atomic( + self, serializer: serializers.UpdateCourseContentRequest, course_content_id: int + ) -> JsonResponse: + course_content = CourseContent.objects.get(id=course_content_id) + + if serializer.priority is not None: + course_content.priority = serializer.priority + if serializer.waiting_period is not None: + course_content.waiting_period = serializer.waiting_period.to_seconds() + + if serializer.is_published is not None: + if course_content.type == "lesson" and course_content.lesson is not None: + lesson = course_content.lesson + lesson.is_published = serializer.is_published + lesson.save() + elif course_content.type == "quiz" and course_content.quiz is not None: + quiz = course_content.quiz + quiz.is_published = serializer.is_published + quiz.save() + + if serializer.lesson is not None and course_content.lesson is not None: + lesson_serializer = serializer.lesson + lesson = course_content.lesson + if lesson_serializer.title is not None: + lesson.title = lesson_serializer.title + if lesson_serializer.content is not None: + lesson.content = lesson_serializer.content + lesson.save() + + if serializer.quiz is not None and course_content.quiz is not None: + quiz_serializer = serializer.quiz + quiz = course_content.quiz + if quiz_serializer.title is not None: + quiz.title = quiz_serializer.title + if quiz_serializer.required_score is not None: + quiz.required_score = quiz_serializer.required_score + if quiz_serializer.questions is not None: + # Clear existing questions and answers + quiz.questions.all().delete() + for question_data in quiz_serializer.questions: + question = quiz.questions.create( + text=question_data.text, priority=question_data.priority + ) + for answer_data in question_data.answers: + question.answers.create( + text=answer_data.text, is_correct=answer_data.is_correct + ) + quiz.save() + + course_content.save() + return JsonResponse( + serializers.CourseContentResponse.model_validate( + course_content + ).model_dump(), + status=200, + ) @method_decorator(accessible_for(roles={"admin", "editor"}), name="post") @@ -157,7 +234,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty except Course.DoesNotExist: return JsonResponse({"error": "Course not found"}, status=404) except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) + return JsonResponse({"error": e.json()}, status=400) except (IntegrityError, ValueError) as e: return JsonResponse({"error": str(e)}, status=409) @@ -172,7 +249,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt status=200, ) except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) + return JsonResponse({"error": e.json()}, status=400) except (IntegrityError, ValueError) as e: return JsonResponse({"error": str(e)}, status=409) @@ -184,7 +261,7 @@ def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def] except Course.DoesNotExist: return JsonResponse({"error": "Course not found"}, status=404) except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) + return JsonResponse({"error": e.json()}, status=400) except (IntegrityError, ValueError) as e: return JsonResponse({"error": str(e)}, status=409) @@ -220,7 +297,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt status=201, ) except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) + return JsonResponse({"error": e.json()}, status=400) except IntegrityError as e: return JsonResponse({"error": str(e)}, status=409) @@ -262,34 +339,11 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt status=201, ) except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) + return JsonResponse({"error": e.json()}, status=400) except IntegrityError as e: return JsonResponse({"error": str(e)}, status=409) -@method_decorator(accessible_for(roles={"admin", "editor"}), name="post") -class LessonView(View): - def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] - payload = json.loads(request.body) - try: - serializer = serializers.LessonUpdate.model_validate(payload) - lesson = Lesson.objects.get(id=kwargs["lesson_id"]) - if serializer.title is not None: - lesson.title = serializer.title - if serializer.content is not None: - lesson.content = serializer.content - lesson.save() - - return JsonResponse( - {}, - status=204, - ) - except Lesson.DoesNotExist: - return JsonResponse({"error": "Lesson not found"}, status=404) - except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) - - @method_decorator(is_an_organization_member(), name="post") class UpdateSessionView(View): def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] @@ -298,7 +352,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt serializer = serializers.UpdateSessionRequest.model_validate(payload) organization_id = serializer.active_organization_id except ValidationError as e: - return JsonResponse({"error": e.errors()}, status=400) + return JsonResponse({"error": e.json()}, status=400) if ( not OrganizationUser.objects.filter( diff --git a/django_email_learning/models.py b/django_email_learning/models.py index f1fdaed..c470c14 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -235,6 +235,14 @@ def title(self) -> str: return self.quiz.title return "Untitled Content" + @property + def is_published(self) -> bool: + if self.type == "lesson" and self.lesson: + return self.lesson.is_published + elif self.type == "quiz" and self.quiz: + return self.quiz.is_published + return False + def _validate_content(self) -> None: if self.type == "lesson" and not self.lesson: raise ValidationError("Lesson must be provided for lesson content.") diff --git a/frontend/course/Course.jsx b/frontend/course/Course.jsx index e14b11a..fdd1bd6 100644 --- a/frontend/course/Course.jsx +++ b/frontend/course/Course.jsx @@ -30,7 +30,7 @@ function Course() { } const handleClose = (event, reason) => { - if (reason !== "backdropClick") { + if (reason !== "backdropClick" && reason !== "escapeKeyDown") { setDialogOpen(false); } } @@ -52,6 +52,21 @@ function Course() { } } + const translateOptions = (options) => { + return options.map((opt) => ({ + optionText: opt.text, + isCorrect: opt.is_correct, + editMode: false + })); + } + + const translateQuestions = (questions) => { + return questions.map((q) => ({ + text: q.text, + options: translateOptions(q.answers), + })); + } + const tableEventHandler = async (event) => { console.log("Event triggered from ContentTable", event); if (event.type === 'content_loaded') { @@ -70,7 +85,23 @@ function Course() { cancelCallback={() => {setLessonCache(""); setDialogOpen(false);}} successCallback={resetDialog} courseId={course_id} - lessonId={content.lesson.id} />); + lessonId={content.lesson.id} + initialWaitingPeriod={content.waiting_period} + contentId={content.id} />); + } else if (content.type == 'quiz') { + console.log("Opening quiz editor for content:", content); + setDialogOpen(true); + setDialogContent( setDialogOpen(false)} + successCallback={resetDialog} + courseId={course_id} + quizId={content.quiz.id} + contentId={content.id} + initialTitle={content.quiz.title} + initialRequiredScore={content.quiz.required_score} + initialQuestions={translateQuestions(content.quiz.questions)} + initialWaitingPeriod={content.waiting_period} + />); } } } @@ -108,7 +139,7 @@ function Course() { - + {dialogContent} diff --git a/frontend/course/components/ContentTable.jsx b/frontend/course/components/ContentTable.jsx index 781ec02..2957b81 100644 --- a/frontend/course/components/ContentTable.jsx +++ b/frontend/course/components/ContentTable.jsx @@ -1,4 +1,4 @@ -import { IconButton, TableContainer, Table, TableHead, TableRow, TableBody, TableCell, Paper, Typography } from '@mui/material'; +import { IconButton, Switch, TableContainer, Table, TableHead, TableRow, TableBody, TableCell, Paper, Typography, Tab } from '@mui/material'; import { useState, useEffect } from 'react'; import { getCookie } from '../../src/utils.js'; import DeleteIcon from '@mui/icons-material/Delete'; @@ -41,6 +41,34 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => { .catch(error => console.error('Error deleting content:', error)); } + const TogglePublishContent = (contentId, is_published) => { + fetch(`${apiBaseUrl}/organizations/${organizationId}/courses/${courseId}/contents/${contentId}/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + is_published: is_published + }) + }) + .then(response => { + if (response.ok) { + console.log('Publish status toggled successfully'); + // Update the local state to reflect the change + setContentList(contentList.map(content => { + if (content.id === contentId) { + return { ...content, is_published: !content.is_published }; + } + return content; + })); + } else { + console.error('Error toggling publish status:', response.statusText); + } + }) + .catch(error => console.error('Error toggling publish status:', error)); + } + const getContets = () => { @@ -68,6 +96,7 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => { Title Waiting time type + Published Actions @@ -79,6 +108,7 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => { color='primary.dark' sx={{ cursor: 'pointer'}}>{content.title} {formatPeriod(content.waiting_period)} {content.type} + TogglePublishContent(content.id, !content.is_published)} /> deleteContent(content.id)}> diff --git a/frontend/course/components/LessonForm.jsx b/frontend/course/components/LessonForm.jsx index f53296d..d6dc054 100644 --- a/frontend/course/components/LessonForm.jsx +++ b/frontend/course/components/LessonForm.jsx @@ -4,11 +4,11 @@ import RequiredTextField from '../../src/components/RequiredTextField.jsx'; import ContentEditor from '../../src/components/ContentEditor'; import { getCookie } from '../../src/utils.js'; -function LessonForm({ header, initialTitle, initialContent, onContentChange, cancelCallback, successCallback, courseId, lessonId }) { +function LessonForm({ header, initialTitle, initialContent, onContentChange, cancelCallback, successCallback, courseId, lessonId, initialWaitingPeriod, contentId }) { const [title, setTitle] = useState(initialTitle || ""); const [content, setContent] = useState(initialContent || ""); - const [waitingPeriod, setWaitingPeriod] = useState(1); - const [waitingPeriodUnit, setWaitingPeriodUnit] = useState("days"); + const [waitingPeriod, setWaitingPeriod] = useState(initialWaitingPeriod ? initialWaitingPeriod.period : 1); + const [waitingPeriodUnit, setWaitingPeriodUnit] = useState(initialWaitingPeriod ? initialWaitingPeriod.type : "days"); const [titleHelperText, setTitleHelperText] = useState(""); const [contentHelperText, setContentHelperText] = useState(""); const [errorMessage, setErrorMessage] = useState(""); @@ -58,7 +58,8 @@ function LessonForm({ header, initialTitle, initialContent, onContentChange, can } console.log("Updating lesson ID:", lessonId); - fetch(apiBaseUrl + '/organizations/' + orgId + '/lessons/' + lessonId + '/', { + + fetch(apiBaseUrl + '/organizations/' + orgId + '/courses/' + courseId + '/contents/' + contentId + '/', { method: 'POST', credentials: 'include', headers: { @@ -66,16 +67,17 @@ function LessonForm({ header, initialTitle, initialContent, onContentChange, can 'X-CSRFToken': getCookie('csrftoken') }, body: JSON.stringify({ - title: title, - content: content, + lesson: { + title: title, + content: content, + }, + waiting_period: {"period": waitingPeriod, "type": waitingPeriodUnit}, }), }) .then((response) => { console.log(response) - if (response.status === 204) { + if (response.status === 200) { console.log('Lesson updated successfully'); - setContent(""); - setTitle(""); successCallback(); } }) diff --git a/frontend/course/components/QuestionForm.jsx b/frontend/course/components/QuestionForm.jsx index 05c0d1a..08d169e 100644 --- a/frontend/course/components/QuestionForm.jsx +++ b/frontend/course/components/QuestionForm.jsx @@ -7,8 +7,8 @@ import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; -const QuestionForm = ({question, index, deleteCallback}) => { - const [questionText, setQuestionText] = useState(question.question); +const QuestionForm = ({question, index, eventHandler}) => { + const [questionText, setQuestionText] = useState(question.text); const [options, setOptions] = useState(question.options || []); const [editMode, setEditMode] = useState(false); const [addingOption, setAddingOption] = useState(false); @@ -18,27 +18,42 @@ const QuestionForm = ({question, index, deleteCallback}) => { if (editMode && questionText.trim() === '') { return; } + triggerUpdateEvent(); setEditMode(!editMode); } + const triggerUpdateEvent = () => { + console.log("Triggering update event for question index " + index + " with options" + JSON.stringify(options)); + eventHandler({type: 'update_question', question_index: index, question_data: {'text': questionText, 'options': options}}); + } + + const deleteCallback = () => { + eventHandler({type: 'delete_question', question_index: index}); + } + useEffect(() => { if (addingOption && optionInputRef.current) { optionInputRef.current.focus(); } }, [addingOption]); + useEffect(() => { + triggerUpdateEvent(); + }, [options, questionText]); + const addToOptions = (optionText) => { if (optionText.trim() !== "") { - setOptions([...options, {"optionText": optionText.trim(), "isCorrect": false}]); + setOptions([...options, {"optionText": optionText.trim(), "isCorrect": false, "editMode": false}]); } setAddingOption(false); } - const updateOption = (optionIndex, isCorrect) => { + const updateOption = async (optionIndex, isCorrect) => { const updatedOptions = options.map((option, idx) => idx === optionIndex ? { ...option, isCorrect: isCorrect } : option ); - setOptions(updatedOptions); + await setOptions(updatedOptions); + console.log("Updated Options:", updatedOptions); } @@ -81,6 +96,9 @@ const QuestionForm = ({question, index, deleteCallback}) => { if (e.key === 'Enter') { addToOptions(e.target.value); } + if (e.key === 'Escape') { + setAddingOption(false); + } }} /> @@ -94,6 +112,9 @@ const QuestionForm = ({question, index, deleteCallback}) => { Add + )} @@ -110,15 +131,25 @@ const QuestionForm = ({question, index, deleteCallback}) => { {options.map((option, idx) => ( - {option.optionText} - updateOption(idx, e.target.checked)} /> + {!option.editMode ? { + setOptions(options.map((opt, i) => i === idx ? { ...opt, editMode: !opt.editMode } : opt)); + }}>{option.optionText} : ( + { + if (e.key === 'Enter') { + const updatedOptions = options.map((opt, i) => i === idx ? { ...opt, optionText: e.target.value, editMode: false } : opt); + setOptions(updatedOptions); + } + }} + /> + )} + updateOption(idx, e.target.checked)} checked={option.isCorrect} /> { - const newOptionText = prompt("Edit option text:", option.optionText); - if (newOptionText !== null && newOptionText.trim() !== "") { - const updatedOptions = options.map((opt, i) => i === idx ? { ...opt, optionText: newOptionText.trim() } : opt); - setOptions(updatedOptions); - } + setOptions(options.map((opt, i) => i === idx ? { ...opt, editMode: !opt.editMode } : opt)); }} /> { const updatedOptions = options.filter((_, i) => i !== idx); diff --git a/frontend/course/components/QuizForm.jsx b/frontend/course/components/QuizForm.jsx index a206129..06363e9 100644 --- a/frontend/course/components/QuizForm.jsx +++ b/frontend/course/components/QuizForm.jsx @@ -1,29 +1,162 @@ import { useRef, useState, useEffect } from 'react'; -import { Box, Button, Grid, Typography } from '@mui/material'; +import { Alert,Box, Button, Grid, MenuItem, Select, Tooltip, Typography } from '@mui/material'; import QuizIcon from '@mui/icons-material/Quiz'; import RequiredTextField from '../../src/components/RequiredTextField'; import QuestionForm from './QuestionForm'; +import { getCookie } from '../../src/utils'; -const QuizForm = ({cancelCallback, successCallback, courseId, quizId }) => { +const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId, initialRequiredScore, initialTitle, initialQuestions, initialWaitingPeriod }) => { const [showQuestionField, setShowQuestionField] = useState(false); const [newQuestion, setNewQuestion] = useState(""); - const [questions, setQuestions] = useState([]); + const [questions, setQuestions] = useState(initialQuestions || []); + const [errorMessage, setErrorMessage] = useState(""); + const [title, setTitle] = useState(initialTitle || ""); + const [requiredScore , setRequiredScore] = useState(initialRequiredScore || 70); + const [waitingPeriod, setWaitingPeriod] = useState(initialWaitingPeriod ? initialWaitingPeriod.period : 1); + const [waitingPeriodUnit, setWaitingPeriodUnit] = useState(initialWaitingPeriod ? initialWaitingPeriod.type : "days"); const questionInputRef = useRef(null); const dialogRef = useRef(null); + const apiBaseUrl = localStorage.getItem('apiBaseUrl'); + const organizationId = localStorage.getItem('activeOrganizationId'); + const addQuiz = () => { - // Implement add quiz logic here - console.log("Adding quiz to course ID:", courseId); - // After successful addition - successCallback(); + if (!validateQuiz()) { + return; + } + console.log("Adding new quiz to course ID:", courseId); + fetch(`${apiBaseUrl}/organizations/${organizationId}/courses/${courseId}/contents/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + content: { + type: 'quiz', + title: title, + required_score: requiredScore, + questions: questionsPayload(), + }, + waiting_period: { + period: waitingPeriod, + type: waitingPeriodUnit + } + }), + }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); + }) + .then(data => { + console.log('Quiz created successfully:', data); + successCallback(); + }) + .catch(error => { + setErrorMessage("Error creating quiz. Please try again."); + console.error('Error creating quiz:', error); + }); + + } + + const validateQuiz = () => { + if (title.trim() === "") { + setErrorMessage("Quiz title cannot be empty."); + return false; + } + if (questions.length === 0) { + setErrorMessage("Quiz must contain at least one question."); + return false; + } + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + if (question.text.trim() === "") { + setErrorMessage(`Question ${i + 1} cannot be empty.`); + return false; + } + const options = question.options || []; + if (options.length < 2) { + setErrorMessage(`Question ${i + 1} must have at least two answer options.`); + return false; + } + const hasCorrectOption = options.some(option => option.isCorrect); + if (!hasCorrectOption) { + setErrorMessage(`Question ${i + 1} must have at least one correct answer.`); + return false; + } + } + setErrorMessage(""); + return true; + } + + const questionsPayload = () => { + return questions.map((question, index) => ({ + text: question.text, + answers: answersPayload(question.options || []), + priority: index + 1, + })); + } + + const answersPayload = (options) => { + return options.map((option) => ({ + text: option.optionText, + is_correct: option.isCorrect, + })); + } + + const questionEventHandler = (event) => { + if (event.type === 'delete_question') { + const updatedQuestions = questions.filter((_, i) => i !== index); + setQuestions(updatedQuestions); + } + if (event.type === 'update_question') { + const updatedQuestions = questions.map((q, i) => + i === event.question_index ? event.question_data : q + ); + console.log('Updated Questions:', updatedQuestions); + setQuestions(updatedQuestions); + } } const updateQuiz = () => { - // Implement update quiz logic here - console.log("Updating quiz ID:", quizId); - // After successful update - successCallback(); + if (!validateQuiz()) { + return; + } + console.log("Updating quiz ID:", quizId, "for course ID:", courseId); + fetch(`${apiBaseUrl}/organizations/${organizationId}/courses/${courseId}/contents/${contentId}/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + quiz: { + title: title, + required_score: requiredScore, + questions: questionsPayload(), + }, + waiting_period: { + period: waitingPeriod, + type: waitingPeriodUnit + }}), + }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); + }) + .then(data => { + console.log('Quiz updated successfully:', data); + successCallback(); + }) + .catch(error => { + setErrorMessage("Error updating quiz. Please try again."); + console.error('Error updating quiz:', error); + }); } const cancel = () => { @@ -50,17 +183,12 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId }) => { const addToQuestions = () => { if (newQuestion.trim() !== "") { - setQuestions([...questions, {"question": newQuestion.trim()}]); + setQuestions([...questions, {"text": newQuestion.trim()}]); } setNewQuestion(""); setShowQuestionField(false); } - const handleQuestionDelete = (index) => { - const updatedQuestions = questions.filter((_, i) => i !== index); - setQuestions(updatedQuestions); - } - return ( { if (e.key === 'q' && !showQuestionField) { @@ -72,6 +200,8 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId }) => { } }} tabIndex={0} focusable="true"> { quizId ? "Update Quiz" : "New Quiz" } + {errorMessage && {errorMessage}} + setTitle(e.target.value)} sx={{ mb: 2, width: '100%' }} /> { showQuestionField && ( @@ -99,9 +229,37 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId }) => { ) } { questions.map((question, index) => ( - handleQuestionDelete(index)} /> + )) } + + setRequiredScore(e.target.value)} + sx={{ width: '200px', mr: 2 }} + inputProps={{ min: 0, max: 100 }} + > + + + + setWaitingPeriod(e.target.value)} + sx={{ width: '200px', mr: 2 }} + inputProps={{ min: 1 }} + /> + +