Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions django_email_learning/api/serializers.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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])
Expand All @@ -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()
]

Expand Down Expand Up @@ -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
Expand All @@ -363,6 +402,7 @@ class CourseContentSummaryResponse(BaseModel):
title: str
priority: int
waiting_period: int
is_published: bool
type: str

@field_serializer("waiting_period")
Expand Down
6 changes: 0 additions & 6 deletions django_email_learning/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from django_email_learning.api.views import (
CourseView,
ImapConnectionView,
LessonView,
OrganizationsView,
SingleCourseView,
CourseContentView,
Expand Down Expand Up @@ -39,11 +38,6 @@
SingleCourseContentView.as_view(),
name="single_course_content_view",
),
path(
"organizations/<int:organization_id>/lessons/<int:lesson_id>/",
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"),
Expand Down
126 changes: 90 additions & 36 deletions django_email_learning/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
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
from django_email_learning.models import (
Course,
CourseContent,
ImapConnection,
Lesson,
OrganizationUser,
Organization,
)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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")
Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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]
Expand All @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
Loading