Skip to content

Commit 9ec6eb1

Browse files
committed
реализованы сервисы и API курсов (каталог, структура, уроки, submit ответов, visit), унифицирована сериализация file URL и оптимизирован пересчёт прогресса
1 parent f67f96f commit 9ec6eb1

10 files changed

Lines changed: 1464 additions & 5 deletions

File tree

courses/models/answers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ def clean(self):
136136
raise ValidationError(errors)
137137

138138
def save(self, *args, **kwargs):
139-
self.full_clean()
139+
validate = kwargs.pop("validate", True)
140+
if validate:
141+
self.full_clean()
140142
super().save(*args, **kwargs)
141143

142144

courses/models/progress.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def clean(self):
8484
raise ValidationError(errors)
8585

8686
def save(self, *args, **kwargs):
87+
validate = kwargs.pop("validate", True)
8788
if self.status in (ProgressStatus.IN_PROGRESS, ProgressStatus.COMPLETED) and not self.started_at:
8889
self.started_at = timezone.now()
8990

@@ -93,7 +94,8 @@ def save(self, *args, **kwargs):
9394
if self.status != ProgressStatus.COMPLETED:
9495
self.completed_at = None
9596

96-
self.full_clean()
97+
if validate:
98+
self.full_clean()
9799
super().save(*args, **kwargs)
98100

99101

courses/serializers.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
from rest_framework import serializers
2+
3+
from courses.models import (
4+
CourseAccessType,
5+
CourseContentStatus,
6+
CourseLessonContentStatus,
7+
CourseModuleContentStatus,
8+
CourseTaskAnswerType,
9+
CourseTaskCheckType,
10+
CourseTaskContentStatus,
11+
CourseTaskInformationalType,
12+
CourseTaskKind,
13+
CourseTaskQuestionType,
14+
ProgressStatus,
15+
UserTaskAnswerStatus,
16+
)
17+
from courses.services.access import ACTION_CONTINUE, ACTION_LOCK, ACTION_START
18+
from courses.services.answers import TaskAnswerSubmitPayload
19+
20+
21+
class CourseAnalyticsStubSerializer(serializers.Serializer):
22+
enabled = serializers.BooleanField(default=False)
23+
title = serializers.CharField(default="Аналитика")
24+
state = serializers.ChoiceField(choices=("coming_soon",), default="coming_soon")
25+
text = serializers.CharField(default="пока закрыто")
26+
27+
28+
class CourseCardSerializer(serializers.Serializer):
29+
id = serializers.IntegerField()
30+
title = serializers.CharField()
31+
access_type = serializers.ChoiceField(choices=CourseAccessType.choices)
32+
status = serializers.ChoiceField(choices=CourseContentStatus.choices)
33+
avatar_url = serializers.URLField(allow_null=True)
34+
card_cover_url = serializers.URLField(allow_null=True)
35+
start_date = serializers.DateField(allow_null=True)
36+
end_date = serializers.DateField(allow_null=True)
37+
date_label = serializers.CharField()
38+
is_available = serializers.BooleanField()
39+
action_state = serializers.ChoiceField(
40+
choices=(ACTION_START, ACTION_CONTINUE, ACTION_LOCK)
41+
)
42+
progress_status = serializers.ChoiceField(choices=ProgressStatus.choices)
43+
percent = serializers.IntegerField(min_value=0, max_value=100)
44+
45+
46+
class CourseDetailSerializer(serializers.Serializer):
47+
id = serializers.IntegerField()
48+
title = serializers.CharField()
49+
description = serializers.CharField(allow_blank=True)
50+
access_type = serializers.ChoiceField(choices=CourseAccessType.choices)
51+
status = serializers.ChoiceField(choices=CourseContentStatus.choices)
52+
avatar_url = serializers.URLField(allow_null=True)
53+
header_cover_url = serializers.URLField(allow_null=True)
54+
start_date = serializers.DateField(allow_null=True)
55+
end_date = serializers.DateField(allow_null=True)
56+
date_label = serializers.CharField()
57+
is_available = serializers.BooleanField()
58+
progress_status = serializers.ChoiceField(choices=ProgressStatus.choices)
59+
percent = serializers.IntegerField(min_value=0, max_value=100)
60+
analytics_stub = CourseAnalyticsStubSerializer()
61+
62+
63+
class CourseTaskOptionSerializer(serializers.Serializer):
64+
id = serializers.IntegerField()
65+
order = serializers.IntegerField(min_value=1)
66+
text = serializers.CharField()
67+
68+
69+
class LessonTaskSerializer(serializers.Serializer):
70+
id = serializers.IntegerField()
71+
order = serializers.IntegerField(min_value=1)
72+
title = serializers.CharField()
73+
status = serializers.ChoiceField(choices=CourseTaskContentStatus.choices)
74+
task_kind = serializers.ChoiceField(choices=CourseTaskKind.choices)
75+
check_type = serializers.ChoiceField(
76+
choices=CourseTaskCheckType.choices,
77+
allow_null=True,
78+
)
79+
informational_type = serializers.ChoiceField(
80+
choices=CourseTaskInformationalType.choices,
81+
allow_null=True,
82+
)
83+
question_type = serializers.ChoiceField(
84+
choices=CourseTaskQuestionType.choices,
85+
allow_null=True,
86+
)
87+
answer_type = serializers.ChoiceField(
88+
choices=CourseTaskAnswerType.choices,
89+
allow_null=True,
90+
)
91+
body_text = serializers.CharField(allow_blank=True)
92+
video_url = serializers.URLField(allow_null=True)
93+
image_url = serializers.URLField(allow_null=True)
94+
attachment_url = serializers.URLField(allow_null=True)
95+
is_available = serializers.BooleanField(default=False)
96+
is_completed = serializers.BooleanField(default=False)
97+
options = CourseTaskOptionSerializer(many=True, required=False)
98+
99+
100+
class CourseLessonStructureSerializer(serializers.Serializer):
101+
id = serializers.IntegerField()
102+
module_id = serializers.IntegerField()
103+
title = serializers.CharField()
104+
order = serializers.IntegerField(min_value=1)
105+
status = serializers.ChoiceField(choices=CourseLessonContentStatus.choices)
106+
is_available = serializers.BooleanField()
107+
progress_status = serializers.ChoiceField(choices=ProgressStatus.choices)
108+
percent = serializers.IntegerField(min_value=0, max_value=100)
109+
current_task_id = serializers.IntegerField(allow_null=True, required=False)
110+
tasks = LessonTaskSerializer(many=True, required=False)
111+
112+
113+
class CourseModuleStructureSerializer(serializers.Serializer):
114+
id = serializers.IntegerField()
115+
course_id = serializers.IntegerField()
116+
title = serializers.CharField()
117+
order = serializers.IntegerField(min_value=1)
118+
start_date = serializers.DateField()
119+
status = serializers.ChoiceField(choices=CourseModuleContentStatus.choices)
120+
is_available = serializers.BooleanField()
121+
progress_status = serializers.ChoiceField(choices=ProgressStatus.choices)
122+
percent = serializers.IntegerField(min_value=0, max_value=100)
123+
lessons = CourseLessonStructureSerializer(many=True, required=False)
124+
125+
126+
class CourseStructureSerializer(serializers.Serializer):
127+
course_id = serializers.IntegerField()
128+
progress_status = serializers.ChoiceField(choices=ProgressStatus.choices)
129+
percent = serializers.IntegerField(min_value=0, max_value=100)
130+
modules = CourseModuleStructureSerializer(many=True)
131+
132+
133+
class LessonDetailSerializer(serializers.Serializer):
134+
id = serializers.IntegerField()
135+
module_id = serializers.IntegerField()
136+
course_id = serializers.IntegerField()
137+
title = serializers.CharField()
138+
progress_status = serializers.ChoiceField(choices=ProgressStatus.choices)
139+
percent = serializers.IntegerField(min_value=0, max_value=100)
140+
current_task_id = serializers.IntegerField(allow_null=True)
141+
tasks = LessonTaskSerializer(many=True)
142+
143+
144+
class TaskAnswerSubmitSerializer(serializers.Serializer):
145+
answer_text = serializers.CharField(required=False, allow_blank=True, default="")
146+
option_ids = serializers.ListField(
147+
child=serializers.IntegerField(min_value=1),
148+
required=False,
149+
default=list,
150+
)
151+
file_ids = serializers.ListField(
152+
child=serializers.URLField(),
153+
required=False,
154+
default=list,
155+
)
156+
157+
def validate_option_ids(self, value: list[int]) -> list[int]:
158+
if len(set(value)) != len(value):
159+
raise serializers.ValidationError(
160+
"Поле option_ids содержит повторяющиеся значения."
161+
)
162+
return value
163+
164+
def validate_file_ids(self, value: list[str]) -> list[str]:
165+
if len(set(value)) != len(value):
166+
raise serializers.ValidationError(
167+
"Поле file_ids содержит повторяющиеся значения."
168+
)
169+
return value
170+
171+
def to_payload(self) -> TaskAnswerSubmitPayload:
172+
data = self.validated_data
173+
return TaskAnswerSubmitPayload(
174+
answer_text=data.get("answer_text", ""),
175+
option_ids=data.get("option_ids", []),
176+
file_ids=data.get("file_ids", []),
177+
)
178+
179+
180+
class TaskAnswerSubmitResultSerializer(serializers.Serializer):
181+
answer_id = serializers.IntegerField(source="answer.id")
182+
status = serializers.ChoiceField(
183+
choices=UserTaskAnswerStatus.choices,
184+
source="answer.status",
185+
)
186+
is_correct = serializers.BooleanField(allow_null=True)
187+
can_continue = serializers.BooleanField()
188+
next_task_id = serializers.IntegerField(allow_null=True)
189+
submitted_at = serializers.DateTimeField(source="answer.submitted_at")
190+
191+
192+
class CourseVisitSerializer(serializers.Serializer):
193+
pass
194+
195+
196+
class CourseVisitResultSerializer(serializers.Serializer):
197+
last_visit_at = serializers.DateTimeField()

courses/services/__init__.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from .access import (
2+
ACTION_CONTINUE,
3+
ACTION_LOCK,
4+
ACTION_START,
5+
CourseAvailability,
6+
CourseCardState,
7+
is_lesson_available,
8+
is_module_available,
9+
resolve_course_action_state,
10+
resolve_course_availability,
11+
resolve_course_card_state,
12+
resolve_course_date_label,
13+
)
14+
from .answers import (
15+
SubmitAnswerResult,
16+
TaskAnswerSubmitPayload,
17+
get_next_published_task,
18+
submit_user_task_answer,
19+
)
20+
from .learning_flow import (
21+
answers_by_task,
22+
ensure_lesson_access,
23+
ensure_task_submission_access,
24+
first_unfinished_task,
25+
is_answer_completed,
26+
not_started_progress_payload,
27+
progress_payload,
28+
recalculate_user_progresses_for_lesson,
29+
task_completion_map,
30+
task_completion_map_from_answers,
31+
)
32+
from .progress import (
33+
ProgressSnapshot,
34+
build_progress_snapshot,
35+
percent_from_counts,
36+
status_from_percent,
37+
touch_course_visit,
38+
upsert_course_progress,
39+
upsert_lesson_progress,
40+
upsert_module_progress,
41+
)
42+
from .querysets import (
43+
published_course_queryset,
44+
published_lessons_prefetch,
45+
)
46+
47+
__all__ = [
48+
"ACTION_START",
49+
"ACTION_CONTINUE",
50+
"ACTION_LOCK",
51+
"CourseAvailability",
52+
"CourseCardState",
53+
"resolve_course_availability",
54+
"resolve_course_action_state",
55+
"resolve_course_date_label",
56+
"resolve_course_card_state",
57+
"is_module_available",
58+
"is_lesson_available",
59+
"TaskAnswerSubmitPayload",
60+
"SubmitAnswerResult",
61+
"submit_user_task_answer",
62+
"get_next_published_task",
63+
"not_started_progress_payload",
64+
"progress_payload",
65+
"is_answer_completed",
66+
"answers_by_task",
67+
"task_completion_map",
68+
"task_completion_map_from_answers",
69+
"first_unfinished_task",
70+
"ensure_lesson_access",
71+
"ensure_task_submission_access",
72+
"recalculate_user_progresses_for_lesson",
73+
"published_course_queryset",
74+
"published_lessons_prefetch",
75+
"ProgressSnapshot",
76+
"percent_from_counts",
77+
"status_from_percent",
78+
"build_progress_snapshot",
79+
"upsert_course_progress",
80+
"upsert_module_progress",
81+
"upsert_lesson_progress",
82+
"touch_course_visit",
83+
]

0 commit comments

Comments
 (0)