From eea8c1eeb7acfab148bf454da413d90a4800ccfc Mon Sep 17 00:00:00 2001 From: Payam Date: Wed, 24 Dec 2025 15:09:36 +0400 Subject: [PATCH 1/3] minor frontend fix --- frontend/src/render.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/render.jsx b/frontend/src/render.jsx index 777b74fa..faf9fda8 100644 --- a/frontend/src/render.jsx +++ b/frontend/src/render.jsx @@ -6,7 +6,7 @@ import './index.css' function render({children}) { - const storedTheme = localStorage.getItem('theme'); + let storedTheme = localStorage.getItem('theme'); if (!storedTheme) { localStorage.setItem('theme', 'light'); storedTheme = 'light'; From f233664906ef468322bef25beb1320975f8eb89e Mon Sep 17 00:00:00 2001 From: Payam Date: Wed, 24 Dec 2025 15:16:15 +0400 Subject: [PATCH 2/3] Update version --- frontend/package-lock.json | 30 +++++++++++------------------- pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 14e8c753..08bb4bb2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -70,7 +70,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -393,7 +392,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -437,7 +435,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1092,6 +1089,17 @@ "@floating-ui/utils": "^0.2.10" } }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, "node_modules/@floating-ui/utils": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", @@ -1247,7 +1255,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz", "integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.6", @@ -2072,7 +2079,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.14.0.tgz", "integrity": "sha512-nm0VWVA1Vq/jaKY3wyRXViL/kf78yMdH7qETpv4qZXDQLU+pdWV3IGoRTQTKESc7d8L1wL/2uCeByLNUJfrSIw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2226,7 +2232,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.14.0.tgz", "integrity": "sha512-xrZmqI5jl4yMeAsu8p8gVP9S3An5h2MBi8BQHNnZmpyzkUrlpd40vlT6u13SWIqVi5ZWhBZ6U3rL7mkVLZuRKg==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -2377,7 +2382,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2387,7 +2391,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2434,7 +2437,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2558,7 +2560,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -2891,7 +2892,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3817,7 +3817,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3993,7 +3992,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -4023,7 +4021,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -4072,7 +4069,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -4103,7 +4099,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4113,7 +4108,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4886,7 +4880,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5014,7 +5007,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/pyproject.toml b/pyproject.toml index 5c2b847e..b8132bb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "django-email-learning" -version = "0.1.11" +version = "0.1.12" description = "A platform for creating and delivering learning materials via email within a Django application. It provides tools for content management, user role-based administration, and scheduler integration for automated content delivery." authors = [ {name = "Payam Najafizadeh",email = "payam.nj@gmail.com"} From 4cf816414397d4bbd574df11ca4fcb289201d840 Mon Sep 17 00:00:00 2001 From: Payam Date: Thu, 25 Dec 2025 16:19:40 +0400 Subject: [PATCH 3/3] Update course content quiz/lesson --- django_email_learning/api/serializers.py | 56 ++++- django_email_learning/api/urls.py | 6 - django_email_learning/api/views.py | 126 ++++++++---- django_email_learning/models.py | 8 + frontend/course/Course.jsx | 37 +++- frontend/course/components/ContentTable.jsx | 32 ++- frontend/course/components/LessonForm.jsx | 20 +- frontend/course/components/QuestionForm.jsx | 55 +++-- frontend/course/components/QuizForm.jsx | 194 ++++++++++++++++-- frontend/src/theme/themes.js | 1 + .../test_views/test_course_content_view.py | 185 +++++++++++++++++ tests/api/test_views/test_lesson_view.py | 70 ------- 12 files changed, 627 insertions(+), 163 deletions(-) delete mode 100644 tests/api/test_views/test_lesson_view.py diff --git a/django_email_learning/api/serializers.py b/django_email_learning/api/serializers.py index 4d242ea4..9fd3690f 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 d03da414..96a788aa 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 9147df13..e6fc729e 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 f1fdaed1..c470c141 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 e14b11a9..fdd1bd6c 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 781ec024..2957b818 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 f53296df..d6dc0540 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 05c0d1a3..08d169ec 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 a206129d..06363e9a 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 }} + /> + +