Skip to content

Commit 4187228

Browse files
committed
Merge pull request #99 from AvaCodeSolutions/feat/23/course-content-ui-page
Feat: #23 course content UI page
2 parents fe91fe4 + 4cf8164 commit 4187228

15 files changed

Lines changed: 640 additions & 184 deletions

File tree

django_email_learning/api/serializers.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
1+
from pydantic import (
2+
BaseModel,
3+
ConfigDict,
4+
Field,
5+
field_serializer,
6+
field_validator,
7+
model_validator,
8+
)
29
from typing import Optional, Literal, Any
3-
from django.core.exceptions import ValidationError
410
from django_email_learning.models import (
511
Organization,
612
ImapConnection,
@@ -195,7 +201,7 @@ class AnswerCreate(BaseModel):
195201
is_correct: bool = Field(examples=[True])
196202

197203

198-
class AnswerResponse(BaseModel):
204+
class AnswerObject(BaseModel):
199205
id: int
200206
text: str
201207
is_correct: bool
@@ -215,11 +221,11 @@ def at_least_one_correct_answer(
215221
) -> list[AnswerCreate]:
216222
correct_answers = [answer for answer in answers if answer.is_correct]
217223
if not correct_answers:
218-
raise ValidationError("At least one answer must be marked as correct.")
224+
raise ValueError("At least one answer must be marked as correct.")
219225
return answers
220226

221227

222-
class QuestionResponse(BaseModel):
228+
class QuestionObject(BaseModel):
223229
id: int
224230
text: str
225231
priority: int
@@ -228,13 +234,20 @@ class QuestionResponse(BaseModel):
228234
@field_serializer("answers")
229235
def serialize_answers(self, answers: Any) -> list[dict]:
230236
return [
231-
AnswerResponse.model_validate(answer).model_dump()
232-
for answer in answers.all()
237+
AnswerObject.model_validate(answer).model_dump() for answer in answers.all()
233238
]
234239

235240
model_config = ConfigDict(from_attributes=True)
236241

237242

243+
class UpdateQuiz(BaseModel):
244+
questions: Optional[list[QuestionCreate]] = Field(min_length=1)
245+
title: Optional[str] = None
246+
required_score: Optional[int] = Field(ge=0, examples=[80], default=None)
247+
248+
model_config = ConfigDict(extra="forbid")
249+
250+
238251
class QuizCreate(BaseModel):
239252
title: str
240253
required_score: int = Field(ge=0, examples=[80])
@@ -252,7 +265,7 @@ class QuizResponse(BaseModel):
252265
@field_serializer("questions")
253266
def serialize_questions(self, questions: Any) -> list[dict]:
254267
return [
255-
QuestionResponse.model_validate(question).model_dump()
268+
QuestionObject.model_validate(question).model_dump()
256269
for question in questions.all()
257270
]
258271

@@ -343,6 +356,32 @@ def to_django_model(self, course: Course) -> CourseContent:
343356
return course_content
344357

345358

359+
class UpdateCourseContentRequest(BaseModel):
360+
priority: Optional[int] = Field(gt=0, examples=[1], default=None)
361+
waiting_period: Optional[WaitingPeriod] = None
362+
lesson: Optional[LessonUpdate] = None
363+
quiz: Optional[UpdateQuiz] = None
364+
is_published: Optional[bool] = None
365+
366+
model_config = ConfigDict(extra="forbid")
367+
368+
@model_validator(mode="after")
369+
def check_at_least_one(self) -> "UpdateCourseContentRequest":
370+
# Check if all fields are None
371+
fields = [
372+
self.priority,
373+
self.waiting_period,
374+
self.lesson,
375+
self.quiz,
376+
self.is_published,
377+
]
378+
if not any(f is not None for f in fields):
379+
raise ValueError(
380+
"At least one of 'priority', 'waiting_period', 'lesson', 'quiz', or 'is_published' must be provided."
381+
)
382+
return self
383+
384+
346385
class CourseContentResponse(BaseModel):
347386
id: int
348387
priority: int
@@ -363,6 +402,7 @@ class CourseContentSummaryResponse(BaseModel):
363402
title: str
364403
priority: int
365404
waiting_period: int
405+
is_published: bool
366406
type: str
367407

368408
@field_serializer("waiting_period")

django_email_learning/api/urls.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from django_email_learning.api.views import (
44
CourseView,
55
ImapConnectionView,
6-
LessonView,
76
OrganizationsView,
87
SingleCourseView,
98
CourseContentView,
@@ -39,11 +38,6 @@
3938
SingleCourseContentView.as_view(),
4039
name="single_course_content_view",
4140
),
42-
path(
43-
"organizations/<int:organization_id>/lessons/<int:lesson_id>/",
44-
LessonView.as_view(),
45-
name="lesson_view",
46-
),
4741
path("organizations/", OrganizationsView.as_view(), name="organizations_view"),
4842
path("session", UpdateSessionView.as_view(), name="update_session_view"),
4943
path("", page_not_found, name="root"),

django_email_learning/api/views.py

Lines changed: 90 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
from django.db.utils import IntegrityError
55
from django.http import JsonResponse
66
from django.core.exceptions import ValidationError as DjangoValidationError
7-
from django.db import models
7+
from django.db import models, transaction
8+
89
from pydantic import ValidationError
910

1011
from django_email_learning.api import serializers
1112
from django_email_learning.models import (
1213
Course,
1314
CourseContent,
1415
ImapConnection,
15-
Lesson,
1616
OrganizationUser,
1717
Organization,
1818
)
@@ -41,7 +41,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
4141
status=201,
4242
)
4343
except ValidationError as e:
44-
return JsonResponse({"error": e.errors()}, status=400)
44+
return JsonResponse({"error": e.json()}, status=400)
4545
except (IntegrityError, ValueError) as e:
4646
return JsonResponse({"error": str(e)}, status=409)
4747

@@ -89,7 +89,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
8989
except Course.DoesNotExist:
9090
return JsonResponse({"error": "Course not found"}, status=404)
9191
except ValidationError as e:
92-
return JsonResponse({"error": e.errors()}, status=400)
92+
return JsonResponse({"error": e.json()}, status=400)
9393
except DjangoValidationError as e:
9494
return JsonResponse({"error": e.messages}, status=400)
9595

@@ -111,6 +111,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
111111

112112
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
113113
@method_decorator(accessible_for(roles={"admin", "editor"}), name="delete")
114+
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
114115
class SingleCourseContentView(View):
115116
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
116117
try:
@@ -124,7 +125,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
124125
except CourseContent.DoesNotExist:
125126
return JsonResponse({"error": "Course content not found"}, status=404)
126127
except ValidationError as e:
127-
return JsonResponse({"error": e.errors()}, status=400)
128+
return JsonResponse({"error": e.json()}, status=400)
128129

129130
def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
130131
try:
@@ -136,11 +137,87 @@ def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
136137
except CourseContent.DoesNotExist:
137138
return JsonResponse({"error": "Course content not found"}, status=404)
138139
except ValidationError as e:
139-
return JsonResponse({"error": e.errors()}, status=400)
140+
return JsonResponse({"error": e.json()}, status=400)
140141
except (IntegrityError, ValueError) as e:
141142
return JsonResponse({"error": str(e)}, status=409)
142143

143-
# TODO: Implement POST method for updating course content.
144+
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
145+
payload = json.loads(request.body)
146+
try:
147+
serializer = serializers.UpdateCourseContentRequest.model_validate(payload)
148+
except ValidationError as e:
149+
return JsonResponse({"error": e.json()}, status=400)
150+
except ValueError as e:
151+
return JsonResponse({"error": str(e)}, status=400)
152+
153+
try:
154+
return self._update_course_content_atomic(
155+
serializer, kwargs["course_content_id"]
156+
)
157+
except CourseContent.DoesNotExist:
158+
return JsonResponse({"error": "Course content not found"}, status=404)
159+
except ValidationError as e:
160+
return JsonResponse({"error": e.json()}, status=400)
161+
except (IntegrityError, ValueError) as e:
162+
return JsonResponse({"error": str(e)}, status=409)
163+
164+
@transaction.atomic
165+
def _update_course_content_atomic(
166+
self, serializer: serializers.UpdateCourseContentRequest, course_content_id: int
167+
) -> JsonResponse:
168+
course_content = CourseContent.objects.get(id=course_content_id)
169+
170+
if serializer.priority is not None:
171+
course_content.priority = serializer.priority
172+
if serializer.waiting_period is not None:
173+
course_content.waiting_period = serializer.waiting_period.to_seconds()
174+
175+
if serializer.is_published is not None:
176+
if course_content.type == "lesson" and course_content.lesson is not None:
177+
lesson = course_content.lesson
178+
lesson.is_published = serializer.is_published
179+
lesson.save()
180+
elif course_content.type == "quiz" and course_content.quiz is not None:
181+
quiz = course_content.quiz
182+
quiz.is_published = serializer.is_published
183+
quiz.save()
184+
185+
if serializer.lesson is not None and course_content.lesson is not None:
186+
lesson_serializer = serializer.lesson
187+
lesson = course_content.lesson
188+
if lesson_serializer.title is not None:
189+
lesson.title = lesson_serializer.title
190+
if lesson_serializer.content is not None:
191+
lesson.content = lesson_serializer.content
192+
lesson.save()
193+
194+
if serializer.quiz is not None and course_content.quiz is not None:
195+
quiz_serializer = serializer.quiz
196+
quiz = course_content.quiz
197+
if quiz_serializer.title is not None:
198+
quiz.title = quiz_serializer.title
199+
if quiz_serializer.required_score is not None:
200+
quiz.required_score = quiz_serializer.required_score
201+
if quiz_serializer.questions is not None:
202+
# Clear existing questions and answers
203+
quiz.questions.all().delete()
204+
for question_data in quiz_serializer.questions:
205+
question = quiz.questions.create(
206+
text=question_data.text, priority=question_data.priority
207+
)
208+
for answer_data in question_data.answers:
209+
question.answers.create(
210+
text=answer_data.text, is_correct=answer_data.is_correct
211+
)
212+
quiz.save()
213+
214+
course_content.save()
215+
return JsonResponse(
216+
serializers.CourseContentResponse.model_validate(
217+
course_content
218+
).model_dump(),
219+
status=200,
220+
)
144221

145222

146223
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@@ -157,7 +234,7 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
157234
except Course.DoesNotExist:
158235
return JsonResponse({"error": "Course not found"}, status=404)
159236
except ValidationError as e:
160-
return JsonResponse({"error": e.errors()}, status=400)
237+
return JsonResponse({"error": e.json()}, status=400)
161238
except (IntegrityError, ValueError) as e:
162239
return JsonResponse({"error": str(e)}, status=409)
163240

@@ -172,7 +249,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
172249
status=200,
173250
)
174251
except ValidationError as e:
175-
return JsonResponse({"error": e.errors()}, status=400)
252+
return JsonResponse({"error": e.json()}, status=400)
176253
except (IntegrityError, ValueError) as e:
177254
return JsonResponse({"error": str(e)}, status=409)
178255

@@ -184,7 +261,7 @@ def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
184261
except Course.DoesNotExist:
185262
return JsonResponse({"error": "Course not found"}, status=404)
186263
except ValidationError as e:
187-
return JsonResponse({"error": e.errors()}, status=400)
264+
return JsonResponse({"error": e.json()}, status=400)
188265
except (IntegrityError, ValueError) as e:
189266
return JsonResponse({"error": str(e)}, status=409)
190267

@@ -220,7 +297,7 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
220297
status=201,
221298
)
222299
except ValidationError as e:
223-
return JsonResponse({"error": e.errors()}, status=400)
300+
return JsonResponse({"error": e.json()}, status=400)
224301
except IntegrityError as e:
225302
return JsonResponse({"error": str(e)}, status=409)
226303

@@ -262,34 +339,11 @@ def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unt
262339
status=201,
263340
)
264341
except ValidationError as e:
265-
return JsonResponse({"error": e.errors()}, status=400)
342+
return JsonResponse({"error": e.json()}, status=400)
266343
except IntegrityError as e:
267344
return JsonResponse({"error": str(e)}, status=409)
268345

269346

270-
@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
271-
class LessonView(View):
272-
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
273-
payload = json.loads(request.body)
274-
try:
275-
serializer = serializers.LessonUpdate.model_validate(payload)
276-
lesson = Lesson.objects.get(id=kwargs["lesson_id"])
277-
if serializer.title is not None:
278-
lesson.title = serializer.title
279-
if serializer.content is not None:
280-
lesson.content = serializer.content
281-
lesson.save()
282-
283-
return JsonResponse(
284-
{},
285-
status=204,
286-
)
287-
except Lesson.DoesNotExist:
288-
return JsonResponse({"error": "Lesson not found"}, status=404)
289-
except ValidationError as e:
290-
return JsonResponse({"error": e.errors()}, status=400)
291-
292-
293347
@method_decorator(is_an_organization_member(), name="post")
294348
class UpdateSessionView(View):
295349
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
298352
serializer = serializers.UpdateSessionRequest.model_validate(payload)
299353
organization_id = serializer.active_organization_id
300354
except ValidationError as e:
301-
return JsonResponse({"error": e.errors()}, status=400)
355+
return JsonResponse({"error": e.json()}, status=400)
302356

303357
if (
304358
not OrganizationUser.objects.filter(

django_email_learning/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,14 @@ def title(self) -> str:
235235
return self.quiz.title
236236
return "Untitled Content"
237237

238+
@property
239+
def is_published(self) -> bool:
240+
if self.type == "lesson" and self.lesson:
241+
return self.lesson.is_published
242+
elif self.type == "quiz" and self.quiz:
243+
return self.quiz.is_published
244+
return False
245+
238246
def _validate_content(self) -> None:
239247
if self.type == "lesson" and not self.lesson:
240248
raise ValidationError("Lesson must be provided for lesson content.")

0 commit comments

Comments
 (0)