diff --git a/courses/admin.py b/courses/admin.py index 3036c358..ea522116 100644 --- a/courses/admin.py +++ b/courses/admin.py @@ -19,6 +19,7 @@ UserTaskAnswerFile, UserTaskAnswerOption, ) +from .models.content import looks_like_image_file # Admin-only captions for sections in app index CourseModule._meta.verbose_name = "Модуль" @@ -148,6 +149,25 @@ class Meta: model = CourseTask fields = "__all__" + def clean(self): + cleaned_data = super().clean() + image_upload = cleaned_data.get("image_upload") + attachment_upload = cleaned_data.get("attachment_upload") + if image_upload and not looks_like_image_file( + mime_type=getattr(image_upload, "content_type", ""), + extension=getattr(image_upload, "name", "").rsplit(".", 1)[-1], + ): + self.add_error( + "image_upload", + "В поле изображения можно загрузить только файл изображения.", + ) + + # Preserve the fact that a file was provided so model validation + # doesn't add a second "required image" error for the same field. + self.instance._has_pending_image_upload = bool(image_upload) + self.instance._has_pending_attachment_upload = bool(attachment_upload) + return cleaned_data + class CourseModuleAdminForm(forms.ModelForm): avatar_upload = forms.FileField( diff --git a/courses/models/content.py b/courses/models/content.py index ddbc37a6..8baec9db 100644 --- a/courses/models/content.py +++ b/courses/models/content.py @@ -16,6 +16,25 @@ ) from .course import Course +ALLOWED_IMAGE_EXTENSIONS = { + "bmp", + "gif", + "jpg", + "jpeg", + "png", + "svg", + "webp", +} + + +def looks_like_image_file(*, mime_type: str | None = None, extension: str | None = None) -> bool: + normalized_mime_type = (mime_type or "").strip().lower() + if normalized_mime_type.startswith("image/"): + return True + + normalized_extension = (extension or "").strip().lower().lstrip(".") + return normalized_extension in ALLOWED_IMAGE_EXTENSIONS + class CourseModule(models.Model): course = models.ForeignKey( @@ -273,11 +292,36 @@ def __str__(self): def _require_non_blank(value: str | None) -> bool: return bool(value and value.strip()) + def _has_image_source(self) -> bool: + return self.image_file_id is not None or getattr( + self, + "_has_pending_image_upload", + False, + ) + + def _has_attachment_source(self) -> bool: + return self.attachment_file_id is not None or getattr( + self, + "_has_pending_attachment_upload", + False, + ) + + def _has_valid_image_file(self) -> bool: + if self.image_file_id is None: + return False + return looks_like_image_file( + mime_type=self.image_file.mime_type, + extension=self.image_file.extension, + ) + def clean(self): super().clean() errors = {} + if self.image_file_id is not None and not self._has_valid_image_file(): + errors["image_file"] = "В поле изображения можно выбрать только файл изображения." + if self.task_kind == CourseTaskKind.INFORMATIONAL: if self.informational_type == CourseTaskInformationalType.VIDEO_TEXT: if not self._require_non_blank(self.body_text): @@ -292,7 +336,7 @@ def clean(self): errors["body_text"] = ( "Поле обязательно для типа 'Текст и изображение'." ) - if self.image_file_id is None: + if not self._has_image_source(): errors["image_file"] = ( "Поле обязательно для типа 'Текст и изображение'." ) @@ -303,7 +347,7 @@ def clean(self): errors["body_text"] = ( "Поле обязательно для типа вопроса 'Изображение и текст'." ) - if self.image_file_id is None: + if not self._has_image_source(): errors["image_file"] = ( "Поле обязательно для типа вопроса 'Изображение и текст'." ) @@ -311,7 +355,7 @@ def clean(self): if not self._require_non_blank(self.video_url): errors["video_url"] = "Поле обязательно для типа вопроса 'Видео'." elif self.question_type == CourseTaskQuestionType.IMAGE: - if self.image_file_id is None: + if not self._has_image_source(): errors["image_file"] = ( "Поле обязательно для типа вопроса 'Изображение'." ) @@ -320,7 +364,7 @@ def clean(self): errors["body_text"] = ( "Поле обязательно для типа вопроса 'Текст с файлом'." ) - if self.attachment_file_id is None: + if not self._has_attachment_source(): errors["attachment_file"] = ( "Поле обязательно для типа вопроса 'Текст с файлом'." ) diff --git a/courses/serializers.py b/courses/serializers.py index 709df8fe..a1afb77e 100644 --- a/courses/serializers.py +++ b/courses/serializers.py @@ -27,6 +27,7 @@ class CourseAnalyticsStubSerializer(serializers.Serializer): class CourseCardSerializer(serializers.Serializer): id = serializers.IntegerField() + partner_program_id = serializers.IntegerField(allow_null=True) title = serializers.CharField() access_type = serializers.ChoiceField(choices=CourseAccessType.choices) status = serializers.ChoiceField(choices=CourseContentStatus.choices) @@ -45,6 +46,7 @@ class CourseCardSerializer(serializers.Serializer): class CourseDetailSerializer(serializers.Serializer): id = serializers.IntegerField() + partner_program_id = serializers.IntegerField(allow_null=True) title = serializers.CharField() description = serializers.CharField(allow_blank=True) access_type = serializers.ChoiceField(choices=CourseAccessType.choices) @@ -127,6 +129,7 @@ class CourseModuleStructureSerializer(serializers.Serializer): class CourseStructureSerializer(serializers.Serializer): course_id = serializers.IntegerField() + partner_program_id = serializers.IntegerField(allow_null=True) progress_status = serializers.ChoiceField(choices=ProgressStatus.choices) percent = serializers.IntegerField(min_value=0, max_value=100) modules = CourseModuleStructureSerializer(many=True) diff --git a/courses/views.py b/courses/views.py index 28c50c43..26820140 100644 --- a/courses/views.py +++ b/courses/views.py @@ -82,6 +82,7 @@ def get(self, request): data.append( { "id": course.id, + "partner_program_id": course.partner_program_id, "title": course.title, "access_type": course.access_type, "status": course.status, @@ -122,6 +123,7 @@ def get(self, request, pk: int): serializer = CourseDetailSerializer( data={ "id": course.id, + "partner_program_id": course.partner_program_id, "title": course.title, "description": course.description, "access_type": course.access_type, @@ -255,6 +257,7 @@ def get(self, request, pk: int): serializer = CourseStructureSerializer( data={ "course_id": course.id, + "partner_program_id": course.partner_program_id, "progress_status": course_progress_payload["status"], "percent": course_progress_payload["percent"], "modules": modules_payload,