Skip to content

Commit 1d6aad9

Browse files
authored
Merge pull request #64 from AvaCodeSolutions/feat/18/create-course-content-api
feat: #18 API endpoint for create course content (Lesson/Quiz)
2 parents 1d0c40b + 734cc5c commit 1d6aad9

File tree

6 files changed

+524
-4
lines changed

6 files changed

+524
-4
lines changed

django_email_learning/api/serializers.py

Lines changed: 190 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1-
from pydantic import BaseModel, ConfigDict, Field
2-
from typing import Optional
3-
from django_email_learning.models import Course
4-
from django_email_learning.models import Organization, ImapConnection
1+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
2+
from typing import Optional, Literal, Any
3+
from django.core.exceptions import ValidationError
4+
from django_email_learning.models import (
5+
Organization,
6+
ImapConnection,
7+
Lesson,
8+
Quiz,
9+
Question,
10+
Answer,
11+
CourseContent,
12+
Course,
13+
)
14+
import enum
515

616

717
class CreateCourseRequest(BaseModel):
@@ -156,3 +166,179 @@ def populate_from_session(cls, session): # type: ignore[no-untyped-def]
156166
return super().model_validate(
157167
{"active_organization_id": session.get("active_organization_id")}
158168
)
169+
170+
171+
class LessonCreate(BaseModel):
172+
title: str
173+
content: str
174+
type: Literal["lesson"]
175+
176+
177+
class LessonResponse(BaseModel):
178+
id: int
179+
title: str
180+
content: str
181+
is_published: bool
182+
183+
model_config = ConfigDict(from_attributes=True)
184+
185+
186+
class AnswerCreate(BaseModel):
187+
text: str
188+
is_correct: bool = Field(examples=[True])
189+
190+
191+
class AnswerResponse(BaseModel):
192+
id: int
193+
text: str
194+
is_correct: bool
195+
196+
model_config = ConfigDict(from_attributes=True)
197+
198+
199+
class QuestionCreate(BaseModel):
200+
text: str
201+
priority: int = Field(gt=0, examples=[1])
202+
answers: list[AnswerCreate] = Field(min_length=2)
203+
204+
@field_validator("answers")
205+
@classmethod
206+
def at_least_one_correct_answer(
207+
cls, answers: list[AnswerCreate]
208+
) -> list[AnswerCreate]:
209+
correct_answers = [answer for answer in answers if answer.is_correct]
210+
if not correct_answers:
211+
raise ValidationError("At least one answer must be marked as correct.")
212+
return answers
213+
214+
215+
class QuestionResponse(BaseModel):
216+
id: int
217+
text: str
218+
priority: int
219+
answers: Any # Will be converted to list in field_serializer
220+
221+
@field_serializer("answers")
222+
def serialize_answers(self, answers: Any) -> list[dict]:
223+
return [
224+
AnswerResponse.model_validate(answer).model_dump()
225+
for answer in answers.all()
226+
]
227+
228+
model_config = ConfigDict(from_attributes=True)
229+
230+
231+
class QuizCreate(BaseModel):
232+
title: str
233+
required_score: int = Field(ge=0, examples=[80])
234+
questions: list[QuestionCreate] = Field(min_length=1)
235+
type: Literal["quiz"]
236+
237+
238+
class QuizResponse(BaseModel):
239+
id: int
240+
title: str
241+
required_score: int
242+
questions: Any # Will be converted to list in field_serializer
243+
is_published: bool
244+
245+
@field_serializer("questions")
246+
def serialize_questions(self, questions: Any) -> list[dict]:
247+
return [
248+
QuestionResponse.model_validate(question).model_dump()
249+
for question in questions.all()
250+
]
251+
252+
model_config = ConfigDict(from_attributes=True)
253+
254+
255+
class PeriodType(enum.StrEnum):
256+
HOURS = "hours"
257+
DAYS = "days"
258+
259+
260+
class WaitingPeriod(BaseModel):
261+
period: int = Field(gt=0, examples=[7])
262+
type: PeriodType
263+
264+
def to_seconds(self) -> int:
265+
if self.type == PeriodType.HOURS:
266+
return self.period * 3600
267+
elif self.type == PeriodType.DAYS:
268+
return self.period * 86400
269+
else:
270+
raise ValueError(f"Unsupported period type: {self.type}")
271+
272+
@classmethod
273+
def from_seconds(cls, seconds: int) -> "WaitingPeriod":
274+
if seconds % 86400 == 0:
275+
return cls(period=seconds // 86400, type=PeriodType.DAYS)
276+
elif seconds % 3600 == 0:
277+
return cls(period=seconds // 3600, type=PeriodType.HOURS)
278+
else:
279+
raise ValueError(
280+
f"Cannot convert {seconds} seconds to a valid WaitingPeriod."
281+
)
282+
283+
284+
class CreateCourseContentRequest(BaseModel):
285+
priority: int = Field(gt=0, examples=[1])
286+
waiting_period: WaitingPeriod
287+
content: LessonCreate | QuizCreate = Field(discriminator="type")
288+
289+
def to_django_model(self, course: Course) -> CourseContent:
290+
lesson = None
291+
quiz = None
292+
if isinstance(self.content, LessonCreate):
293+
lesson = Lesson(
294+
title=self.content.title,
295+
content=self.content.content,
296+
)
297+
lesson.save()
298+
content_type = "lesson"
299+
elif isinstance(self.content, QuizCreate):
300+
quiz = Quiz(
301+
title=self.content.title,
302+
required_score=self.content.required_score,
303+
)
304+
quiz.save()
305+
for question_data in self.content.questions:
306+
question = Question(
307+
text=question_data.text,
308+
priority=question_data.priority,
309+
quiz=quiz,
310+
)
311+
question.save()
312+
for answer_data in question_data.answers:
313+
answer = Answer(
314+
text=answer_data.text,
315+
is_correct=answer_data.is_correct,
316+
question=question,
317+
)
318+
answer.save()
319+
content_type = "quiz"
320+
course_content = CourseContent.objects.create(
321+
course=course,
322+
priority=self.priority,
323+
waiting_period=self.waiting_period.to_seconds(),
324+
lesson=lesson,
325+
quiz=quiz,
326+
type=content_type,
327+
)
328+
329+
return course_content
330+
331+
332+
class CourseContentResponse(BaseModel):
333+
id: int
334+
priority: int
335+
waiting_period: int
336+
type: str
337+
lesson: Optional[LessonResponse] = None
338+
quiz: Optional[QuizResponse] = None
339+
340+
@field_serializer("waiting_period")
341+
def serialize_waiting_period(self, waiting_period: int) -> dict:
342+
return WaitingPeriod.from_seconds(waiting_period).model_dump()
343+
344+
model_config = ConfigDict(from_attributes=True)

django_email_learning/api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
ImapConnectionView,
66
OrganizationsView,
77
SingleCourseView,
8+
CourseContentView,
89
UpdateSessionView,
910
)
1011

@@ -26,6 +27,11 @@
2627
SingleCourseView.as_view(),
2728
name="single_course_view",
2829
),
30+
path(
31+
"organizations/<int:organization_id>/courses/<int:course_id>/contents/",
32+
CourseContentView.as_view(),
33+
name="course_content_view",
34+
),
2935
path("organizations/", OrganizationsView.as_view(), name="organizations_view"),
3036
path("session", UpdateSessionView.as_view(), name="update_session_view"),
3137
path("", page_not_found, name="root"),

django_email_learning/api/views.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
from django.views.decorators.csrf import ensure_csrf_cookie
44
from django.db.utils import IntegrityError
55
from django.http import JsonResponse
6+
from django.core.exceptions import ValidationError as DjangoValidationError
67
from pydantic import ValidationError
8+
79
from django_email_learning.api import serializers
810
from django_email_learning.models import (
911
Course,
@@ -57,6 +59,29 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
5759
return JsonResponse({"courses": response_list}, status=200)
5860

5961

62+
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
63+
class CourseContentView(View):
64+
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
65+
payload = json.loads(request.body)
66+
try:
67+
serializer = serializers.CreateCourseContentRequest.model_validate(payload)
68+
course = Course.objects.get(id=kwargs["course_id"])
69+
course_content = serializer.to_django_model(course=course)
70+
71+
return JsonResponse(
72+
serializers.CourseContentResponse.model_validate(
73+
course_content
74+
).model_dump(),
75+
status=201,
76+
)
77+
except Course.DoesNotExist:
78+
return JsonResponse({"error": "Course not found"}, status=404)
79+
except ValidationError as e:
80+
return JsonResponse({"error": e.errors()}, status=400)
81+
except DjangoValidationError as e:
82+
return JsonResponse({"error": e.messages}, status=400)
83+
84+
6085
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
6186
@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
6287
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")

django_email_learning/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,24 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
245245
self.full_clean()
246246
super().save(*args, **kwargs)
247247

248+
class Meta:
249+
constraints = [
250+
models.UniqueConstraint(
251+
fields=["course", "quiz"],
252+
condition=models.Q(quiz__isnull=False),
253+
name="unique_quiz_per_course",
254+
),
255+
models.UniqueConstraint(
256+
fields=["course", "lesson"],
257+
condition=models.Q(lesson__isnull=False),
258+
name="unique_lesson_per_course",
259+
),
260+
models.UniqueConstraint(
261+
fields=["course", "priority"],
262+
name="unique_priority_per_course",
263+
),
264+
]
265+
248266

249267
class BlockedEmail(models.Model):
250268
email = models.EmailField(unique=True)

0 commit comments

Comments
 (0)