|
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 |
5 | 15 |
|
6 | 16 |
|
7 | 17 | class CreateCourseRequest(BaseModel): |
@@ -156,3 +166,179 @@ def populate_from_session(cls, session): # type: ignore[no-untyped-def] |
156 | 166 | return super().model_validate( |
157 | 167 | {"active_organization_id": session.get("active_organization_id")} |
158 | 168 | ) |
| 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) |
0 commit comments