Skip to content

Commit 3822faa

Browse files
authored
Merge pull request #608 from PROCOLLAB-github/feature/program-courses
Добавлены partner_program_id в API курсов и валидация изображений
2 parents 90ccb89 + a66c3e8 commit 3822faa

File tree

4 files changed

+74
-4
lines changed

4 files changed

+74
-4
lines changed

courses/admin.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
UserTaskAnswerFile,
2020
UserTaskAnswerOption,
2121
)
22+
from .models.content import looks_like_image_file
2223

2324
# Admin-only captions for sections in app index
2425
CourseModule._meta.verbose_name = "Модуль"
@@ -148,6 +149,25 @@ class Meta:
148149
model = CourseTask
149150
fields = "__all__"
150151

152+
def clean(self):
153+
cleaned_data = super().clean()
154+
image_upload = cleaned_data.get("image_upload")
155+
attachment_upload = cleaned_data.get("attachment_upload")
156+
if image_upload and not looks_like_image_file(
157+
mime_type=getattr(image_upload, "content_type", ""),
158+
extension=getattr(image_upload, "name", "").rsplit(".", 1)[-1],
159+
):
160+
self.add_error(
161+
"image_upload",
162+
"В поле изображения можно загрузить только файл изображения.",
163+
)
164+
165+
# Preserve the fact that a file was provided so model validation
166+
# doesn't add a second "required image" error for the same field.
167+
self.instance._has_pending_image_upload = bool(image_upload)
168+
self.instance._has_pending_attachment_upload = bool(attachment_upload)
169+
return cleaned_data
170+
151171

152172
class CourseModuleAdminForm(forms.ModelForm):
153173
avatar_upload = forms.FileField(

courses/models/content.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,25 @@
1616
)
1717
from .course import Course
1818

19+
ALLOWED_IMAGE_EXTENSIONS = {
20+
"bmp",
21+
"gif",
22+
"jpg",
23+
"jpeg",
24+
"png",
25+
"svg",
26+
"webp",
27+
}
28+
29+
30+
def looks_like_image_file(*, mime_type: str | None = None, extension: str | None = None) -> bool:
31+
normalized_mime_type = (mime_type or "").strip().lower()
32+
if normalized_mime_type.startswith("image/"):
33+
return True
34+
35+
normalized_extension = (extension or "").strip().lower().lstrip(".")
36+
return normalized_extension in ALLOWED_IMAGE_EXTENSIONS
37+
1938

2039
class CourseModule(models.Model):
2140
course = models.ForeignKey(
@@ -273,11 +292,36 @@ def __str__(self):
273292
def _require_non_blank(value: str | None) -> bool:
274293
return bool(value and value.strip())
275294

295+
def _has_image_source(self) -> bool:
296+
return self.image_file_id is not None or getattr(
297+
self,
298+
"_has_pending_image_upload",
299+
False,
300+
)
301+
302+
def _has_attachment_source(self) -> bool:
303+
return self.attachment_file_id is not None or getattr(
304+
self,
305+
"_has_pending_attachment_upload",
306+
False,
307+
)
308+
309+
def _has_valid_image_file(self) -> bool:
310+
if self.image_file_id is None:
311+
return False
312+
return looks_like_image_file(
313+
mime_type=self.image_file.mime_type,
314+
extension=self.image_file.extension,
315+
)
316+
276317
def clean(self):
277318
super().clean()
278319

279320
errors = {}
280321

322+
if self.image_file_id is not None and not self._has_valid_image_file():
323+
errors["image_file"] = "В поле изображения можно выбрать только файл изображения."
324+
281325
if self.task_kind == CourseTaskKind.INFORMATIONAL:
282326
if self.informational_type == CourseTaskInformationalType.VIDEO_TEXT:
283327
if not self._require_non_blank(self.body_text):
@@ -292,7 +336,7 @@ def clean(self):
292336
errors["body_text"] = (
293337
"Поле обязательно для типа 'Текст и изображение'."
294338
)
295-
if self.image_file_id is None:
339+
if not self._has_image_source():
296340
errors["image_file"] = (
297341
"Поле обязательно для типа 'Текст и изображение'."
298342
)
@@ -303,15 +347,15 @@ def clean(self):
303347
errors["body_text"] = (
304348
"Поле обязательно для типа вопроса 'Изображение и текст'."
305349
)
306-
if self.image_file_id is None:
350+
if not self._has_image_source():
307351
errors["image_file"] = (
308352
"Поле обязательно для типа вопроса 'Изображение и текст'."
309353
)
310354
elif self.question_type == CourseTaskQuestionType.VIDEO:
311355
if not self._require_non_blank(self.video_url):
312356
errors["video_url"] = "Поле обязательно для типа вопроса 'Видео'."
313357
elif self.question_type == CourseTaskQuestionType.IMAGE:
314-
if self.image_file_id is None:
358+
if not self._has_image_source():
315359
errors["image_file"] = (
316360
"Поле обязательно для типа вопроса 'Изображение'."
317361
)
@@ -320,7 +364,7 @@ def clean(self):
320364
errors["body_text"] = (
321365
"Поле обязательно для типа вопроса 'Текст с файлом'."
322366
)
323-
if self.attachment_file_id is None:
367+
if not self._has_attachment_source():
324368
errors["attachment_file"] = (
325369
"Поле обязательно для типа вопроса 'Текст с файлом'."
326370
)

courses/serializers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class CourseAnalyticsStubSerializer(serializers.Serializer):
2727

2828
class CourseCardSerializer(serializers.Serializer):
2929
id = serializers.IntegerField()
30+
partner_program_id = serializers.IntegerField(allow_null=True)
3031
title = serializers.CharField()
3132
access_type = serializers.ChoiceField(choices=CourseAccessType.choices)
3233
status = serializers.ChoiceField(choices=CourseContentStatus.choices)
@@ -45,6 +46,7 @@ class CourseCardSerializer(serializers.Serializer):
4546

4647
class CourseDetailSerializer(serializers.Serializer):
4748
id = serializers.IntegerField()
49+
partner_program_id = serializers.IntegerField(allow_null=True)
4850
title = serializers.CharField()
4951
description = serializers.CharField(allow_blank=True)
5052
access_type = serializers.ChoiceField(choices=CourseAccessType.choices)
@@ -127,6 +129,7 @@ class CourseModuleStructureSerializer(serializers.Serializer):
127129

128130
class CourseStructureSerializer(serializers.Serializer):
129131
course_id = serializers.IntegerField()
132+
partner_program_id = serializers.IntegerField(allow_null=True)
130133
progress_status = serializers.ChoiceField(choices=ProgressStatus.choices)
131134
percent = serializers.IntegerField(min_value=0, max_value=100)
132135
modules = CourseModuleStructureSerializer(many=True)

courses/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def get(self, request):
8282
data.append(
8383
{
8484
"id": course.id,
85+
"partner_program_id": course.partner_program_id,
8586
"title": course.title,
8687
"access_type": course.access_type,
8788
"status": course.status,
@@ -122,6 +123,7 @@ def get(self, request, pk: int):
122123
serializer = CourseDetailSerializer(
123124
data={
124125
"id": course.id,
126+
"partner_program_id": course.partner_program_id,
125127
"title": course.title,
126128
"description": course.description,
127129
"access_type": course.access_type,
@@ -255,6 +257,7 @@ def get(self, request, pk: int):
255257
serializer = CourseStructureSerializer(
256258
data={
257259
"course_id": course.id,
260+
"partner_program_id": course.partner_program_id,
258261
"progress_status": course_progress_payload["status"],
259262
"percent": course_progress_payload["percent"],
260263
"modules": modules_payload,

0 commit comments

Comments
 (0)