diff --git a/django_email_learning/api/serializers.py b/django_email_learning/api/serializers.py index 92739682..483dd735 100644 --- a/django_email_learning/api/serializers.py +++ b/django_email_learning/api/serializers.py @@ -1,7 +1,17 @@ -from pydantic import BaseModel, ConfigDict, Field -from typing import Optional -from django_email_learning.models import Course -from django_email_learning.models import Organization, ImapConnection +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator +from typing import Optional, Literal, Any +from django.core.exceptions import ValidationError +from django_email_learning.models import ( + Organization, + ImapConnection, + Lesson, + Quiz, + Question, + Answer, + CourseContent, + Course, +) +import enum class CreateCourseRequest(BaseModel): @@ -156,3 +166,179 @@ def populate_from_session(cls, session): # type: ignore[no-untyped-def] return super().model_validate( {"active_organization_id": session.get("active_organization_id")} ) + + +class LessonCreate(BaseModel): + title: str + content: str + type: Literal["lesson"] + + +class LessonResponse(BaseModel): + id: int + title: str + content: str + is_published: bool + + model_config = ConfigDict(from_attributes=True) + + +class AnswerCreate(BaseModel): + text: str + is_correct: bool = Field(examples=[True]) + + +class AnswerResponse(BaseModel): + id: int + text: str + is_correct: bool + + model_config = ConfigDict(from_attributes=True) + + +class QuestionCreate(BaseModel): + text: str + priority: int = Field(gt=0, examples=[1]) + answers: list[AnswerCreate] = Field(min_length=2) + + @field_validator("answers") + @classmethod + def at_least_one_correct_answer( + cls, answers: list[AnswerCreate] + ) -> 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.") + return answers + + +class QuestionResponse(BaseModel): + id: int + text: str + priority: int + answers: Any # Will be converted to list in field_serializer + + @field_serializer("answers") + def serialize_answers(self, answers: Any) -> list[dict]: + return [ + AnswerResponse.model_validate(answer).model_dump() + for answer in answers.all() + ] + + model_config = ConfigDict(from_attributes=True) + + +class QuizCreate(BaseModel): + title: str + required_score: int = Field(ge=0, examples=[80]) + questions: list[QuestionCreate] = Field(min_length=1) + type: Literal["quiz"] + + +class QuizResponse(BaseModel): + id: int + title: str + required_score: int + questions: Any # Will be converted to list in field_serializer + is_published: bool + + @field_serializer("questions") + def serialize_questions(self, questions: Any) -> list[dict]: + return [ + QuestionResponse.model_validate(question).model_dump() + for question in questions.all() + ] + + model_config = ConfigDict(from_attributes=True) + + +class PeriodType(enum.StrEnum): + HOURS = "hours" + DAYS = "days" + + +class WaitingPeriod(BaseModel): + period: int = Field(gt=0, examples=[7]) + type: PeriodType + + def to_seconds(self) -> int: + if self.type == PeriodType.HOURS: + return self.period * 3600 + elif self.type == PeriodType.DAYS: + return self.period * 86400 + else: + raise ValueError(f"Unsupported period type: {self.type}") + + @classmethod + def from_seconds(cls, seconds: int) -> "WaitingPeriod": + if seconds % 86400 == 0: + return cls(period=seconds // 86400, type=PeriodType.DAYS) + elif seconds % 3600 == 0: + return cls(period=seconds // 3600, type=PeriodType.HOURS) + else: + raise ValueError( + f"Cannot convert {seconds} seconds to a valid WaitingPeriod." + ) + + +class CreateCourseContentRequest(BaseModel): + priority: int = Field(gt=0, examples=[1]) + waiting_period: WaitingPeriod + content: LessonCreate | QuizCreate = Field(discriminator="type") + + def to_django_model(self, course: Course) -> CourseContent: + lesson = None + quiz = None + if isinstance(self.content, LessonCreate): + lesson = Lesson( + title=self.content.title, + content=self.content.content, + ) + lesson.save() + content_type = "lesson" + elif isinstance(self.content, QuizCreate): + quiz = Quiz( + title=self.content.title, + required_score=self.content.required_score, + ) + quiz.save() + for question_data in self.content.questions: + question = Question( + text=question_data.text, + priority=question_data.priority, + quiz=quiz, + ) + question.save() + for answer_data in question_data.answers: + answer = Answer( + text=answer_data.text, + is_correct=answer_data.is_correct, + question=question, + ) + answer.save() + content_type = "quiz" + course_content = CourseContent.objects.create( + course=course, + priority=self.priority, + waiting_period=self.waiting_period.to_seconds(), + lesson=lesson, + quiz=quiz, + type=content_type, + ) + + return course_content + + +class CourseContentResponse(BaseModel): + id: int + priority: int + waiting_period: int + type: str + lesson: Optional[LessonResponse] = None + quiz: Optional[QuizResponse] = None + + @field_serializer("waiting_period") + def serialize_waiting_period(self, waiting_period: int) -> dict: + return WaitingPeriod.from_seconds(waiting_period).model_dump() + + model_config = ConfigDict(from_attributes=True) diff --git a/django_email_learning/api/urls.py b/django_email_learning/api/urls.py index b3f80c21..d561b580 100644 --- a/django_email_learning/api/urls.py +++ b/django_email_learning/api/urls.py @@ -5,6 +5,7 @@ ImapConnectionView, OrganizationsView, SingleCourseView, + CourseContentView, UpdateSessionView, ) @@ -26,6 +27,11 @@ SingleCourseView.as_view(), name="single_course_view", ), + path( + "organizations//courses//contents/", + CourseContentView.as_view(), + name="course_content_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 5f541564..a41369d4 100644 --- a/django_email_learning/api/views.py +++ b/django_email_learning/api/views.py @@ -3,7 +3,9 @@ from django.views.decorators.csrf import ensure_csrf_cookie from django.db.utils import IntegrityError from django.http import JsonResponse +from django.core.exceptions import ValidationError as DjangoValidationError from pydantic import ValidationError + from django_email_learning.api import serializers from django_email_learning.models import ( Course, @@ -57,6 +59,29 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty return JsonResponse({"courses": response_list}, status=200) +@method_decorator(accessible_for(roles={"admin", "editor"}), name="post") +class CourseContentView(View): + def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + payload = json.loads(request.body) + try: + serializer = serializers.CreateCourseContentRequest.model_validate(payload) + course = Course.objects.get(id=kwargs["course_id"]) + course_content = serializer.to_django_model(course=course) + + return JsonResponse( + serializers.CourseContentResponse.model_validate( + course_content + ).model_dump(), + status=201, + ) + except Course.DoesNotExist: + return JsonResponse({"error": "Course not found"}, status=404) + except ValidationError as e: + return JsonResponse({"error": e.errors()}, status=400) + except DjangoValidationError as e: + return JsonResponse({"error": e.messages}, status=400) + + @method_decorator(accessible_for(roles={"admin", "editor"}), name="post") @method_decorator(accessible_for(roles={"admin", "editor"}), name="delete") @method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get") diff --git a/django_email_learning/models.py b/django_email_learning/models.py index fc4e4c01..23d85991 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -245,6 +245,24 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] self.full_clean() super().save(*args, **kwargs) + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["course", "quiz"], + condition=models.Q(quiz__isnull=False), + name="unique_quiz_per_course", + ), + models.UniqueConstraint( + fields=["course", "lesson"], + condition=models.Q(lesson__isnull=False), + name="unique_lesson_per_course", + ), + models.UniqueConstraint( + fields=["course", "priority"], + name="unique_priority_per_course", + ), + ] + class BlockedEmail(models.Model): email = models.EmailField(unique=True) diff --git a/tests/api/test_views/test_course_content_view.py b/tests/api/test_views/test_course_content_view.py new file mode 100644 index 00000000..9b828c25 --- /dev/null +++ b/tests/api/test_views/test_course_content_view.py @@ -0,0 +1,265 @@ +import pytest + +from django.urls import reverse +import json + +LESSON_TITLE = "Introduction to Python" +LESSON_CONTENT = "Welcome to the Python course!" + + +def get_url() -> str: + return reverse( + "django_email_learning:api:course_content_view", + kwargs={"organization_id": 1, "course_id": 1}, + ) + + +def valid_create_course_payload( + title: str = "Python Course", + slug: str = "python", + description: str = "A beginner's course on Python programming.", +) -> dict: + return { + "title": title, + "slug": slug, + "description": description, + "imap_connection_id": None, + } + + +@pytest.fixture() +def create_course(superadmin_client): + url = reverse( + "django_email_learning:api:course_view", + kwargs={"organization_id": 1}, + ) + payload = valid_create_course_payload() + superadmin_client.post(url, json.dumps(payload), content_type="application/json") + + +def test_create_course_lesson_content(superadmin_client, create_course): + url = get_url() + payload = { + "content": { + "title": LESSON_TITLE, + "content": LESSON_CONTENT, + "type": "lesson", + }, + "priority": 1, + "waiting_period": {"period": 2, "type": "days"}, + } + response = superadmin_client.post( + url, json.dumps(payload), content_type="application/json" + ) + assert response.status_code == 201 + data = response.json() + assert "lesson" in data + assert data["id"] is not None + assert data["lesson"]["id"] is not None + assert data["lesson"]["title"] == LESSON_TITLE + assert data["lesson"]["content"] == LESSON_CONTENT + assert data["lesson"]["is_published"] is False + assert data["type"] == "lesson" + assert data["priority"] == 1 + assert data["waiting_period"] == {"period": 2, "type": "days"} + + +@pytest.mark.parametrize( + "title,content", [(None, LESSON_CONTENT), (LESSON_TITLE, None)] +) +def test_validate_lesson_content(superadmin_client, create_course, title, content): + url = get_url() + payload = { + "content": { + "title": title, + "content": content, + "type": "lesson", + }, + "priority": 1, + "waiting_period": {"period": 2, "type": "days"}, + } + + response = superadmin_client.post( + url, json.dumps(payload), content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.json() + + +def test_viewer_can_not_create_course_content(viewer_client, create_course): + url = get_url() + payload = { + "content": { + "title": LESSON_TITLE, + "content": LESSON_CONTENT, + "type": "lesson", + }, + "priority": 1, + "waiting_period": {"period": 2, "type": "days"}, + } + response = viewer_client.post( + url, json.dumps(payload), content_type="application/json" + ) + assert response.status_code == 403 + + +def test_anonymous_user_cannot_create_course_content(anonymous_client, create_course): + url = get_url() + payload = { + "content": { + "title": LESSON_TITLE, + "content": LESSON_CONTENT, + "type": "lesson", + }, + "priority": 1, + "waiting_period": {"period": 2, "type": "days"}, + } + response = anonymous_client.post( + url, json.dumps(payload), content_type="application/json" + ) + assert response.status_code == 401 + + +def test_content_with_same_priority_cannot_be_created(superadmin_client, create_course): + url = get_url() + payload = { + "content": { + "title": LESSON_TITLE, + "content": LESSON_CONTENT, + "type": "lesson", + }, + "priority": 1, + "waiting_period": {"period": 2, "type": "days"}, + } + response1 = superadmin_client.post( + url, json.dumps(payload), content_type="application/json" + ) + assert response1.status_code == 201 + + response2 = superadmin_client.post( + url, json.dumps(payload), content_type="application/json" + ) + assert response2.status_code == 400 + assert "error" in response2.json() + + +def test_create_quiz_content(superadmin_client, create_course): + url = get_url() + payload = { + "content": { + "type": "quiz", + "title": "Quiz 1", + "required_score": 70, + "questions": [ + { + "text": "What is Python?", + "priority": 1, + "answers": [ + {"text": "A programming language", "is_correct": True}, + {"text": "A snake", "is_correct": False}, + ], + }, + { + "text": "Which of these is a Python data type?", + "priority": 2, + "answers": [ + {"text": "List", "is_correct": True}, + {"text": "Car", "is_correct": False}, + ], + }, + ], + }, + "priority": 2, + "waiting_period": {"period": 1, "type": "hours"}, + } + response = superadmin_client.post( + url, json.dumps(payload), content_type="application/json" + ) + assert response.status_code == 201 + data = response.json() + assert "quiz" in data + assert data["id"] is not None + assert data["quiz"]["id"] is not None + assert data["type"] == "quiz" + assert data["quiz"]["is_published"] is False + assert data["priority"] == 2 + assert data["waiting_period"] == {"period": 1, "type": "hours"} + assert data["quiz"]["title"] == "Quiz 1" + assert data["quiz"]["required_score"] == 70 + assert len(data["quiz"]["questions"]) == 2 + assert len(data["quiz"]["questions"][0]["answers"]) == 2 + assert len(data["quiz"]["questions"][1]["answers"]) == 2 + + +def test_invalid_quiz_content_missing_questions(superadmin_client, create_course): + url = get_url() + payload = { + "content": { + "type": "quiz", + "title": "Quiz without questions", + "required_score": 70, + "questions": [], + }, + "priority": 2, + "waiting_period": {"period": 1, "type": "hours"}, + } + response = superadmin_client.post( + url, json.dumps(payload), content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.json() + + +def test_invalid_quiz_content_insufficient_answers(superadmin_client, create_course): + url = get_url() + payload = { + "content": { + "type": "quiz", + "title": "Quiz with insufficient answers", + "required_score": 70, + "questions": [ + { + "text": "What is Python?", + "priority": 1, + "answers": [ + {"text": "A programming language", "is_correct": True}, + ], + } + ], + }, + "priority": 2, + "waiting_period": {"period": 1, "type": "hours"}, + } + response = superadmin_client.post( + url, json.dumps(payload), content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.json() + + +def test_invalid_quiz_content_no_correct_answer(superadmin_client, create_course): + url = get_url() + payload = { + "content": { + "type": "quiz", + "title": "Quiz with no correct answer", + "required_score": 70, + "questions": [ + { + "text": "What is Python?", + "priority": 1, + "answers": [ + {"text": "A programming language", "is_correct": False}, + {"text": "A snake", "is_correct": False}, + ], + } + ], + }, + "priority": 2, + "waiting_period": {"period": 1, "type": "hours"}, + } + response = superadmin_client.post( + url, json.dumps(payload), content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.json() diff --git a/tests/test_models/test_course_content.py b/tests/test_models/test_course_content.py index 95b8e784..c1622ec7 100644 --- a/tests/test_models/test_course_content.py +++ b/tests/test_models/test_course_content.py @@ -55,3 +55,23 @@ def test_valid_quiz_content_creation(course, quiz): assert content.quiz == quiz assert content.lesson is None assert content.waiting_period == 10 + + +def test_unique_lesson_content_per_course(course, lesson): + CourseContent.objects.create( + course=course, priority=1, type="lesson", lesson=lesson, waiting_period=10 + ) + with pytest.raises(ValidationError): + CourseContent.objects.create( + course=course, priority=2, type="lesson", lesson=lesson, waiting_period=20 + ) + + +def test_unique_quiz_content_per_course(course, quiz): + CourseContent.objects.create( + course=course, priority=1, type="quiz", quiz=quiz, waiting_period=10 + ) + with pytest.raises(ValidationError): + CourseContent.objects.create( + course=course, priority=2, type="quiz", quiz=quiz, waiting_period=20 + )