Skip to content

Commit c1be08d

Browse files
committed
Обновлена логика прохождения информационных заданий и агрегированного прогресса курсов, добавлена валидация изображений для курсов, модулей и заданий
1 parent a66c3e8 commit c1be08d

9 files changed

Lines changed: 259 additions & 87 deletions

File tree

courses/admin.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
UserTaskAnswerFile,
2020
UserTaskAnswerOption,
2121
)
22-
from .models.content import looks_like_image_file
22+
from .models.file_validation import looks_like_image_file
2323

2424
# Admin-only captions for sections in app index
2525
CourseModule._meta.verbose_name = "Модуль"
@@ -77,6 +77,15 @@ def _courses_get_app_list(self, request, app_label=None):
7777
admin.site._courses_order_patched = True
7878

7979

80+
def _validate_image_upload(form: forms.ModelForm, field_name: str, error_message: str) -> None:
81+
uploaded_file = form.cleaned_data.get(field_name)
82+
if uploaded_file and not looks_like_image_file(
83+
mime_type=getattr(uploaded_file, "content_type", ""),
84+
extension=getattr(uploaded_file, "name", "").rsplit(".", 1)[-1],
85+
):
86+
form.add_error(field_name, error_message)
87+
88+
8089
class OrderUniqueInlineFormSet(BaseInlineFormSet):
8190
duplicate_field_error = "Такой порядковый номер уже используется в этом разделе."
8291
duplicate_form_error = "Найдены дублирующиеся значения. Исправьте строки ниже."
@@ -134,6 +143,25 @@ class Meta:
134143
model = Course
135144
fields = "__all__"
136145

146+
def clean(self):
147+
cleaned_data = super().clean()
148+
_validate_image_upload(
149+
self,
150+
"avatar_upload",
151+
"В поле аватара можно загрузить только файл изображения.",
152+
)
153+
_validate_image_upload(
154+
self,
155+
"card_cover_upload",
156+
"В поле обложки карточки можно загрузить только файл изображения.",
157+
)
158+
_validate_image_upload(
159+
self,
160+
"header_cover_upload",
161+
"В поле обложки шапки можно загрузить только файл изображения.",
162+
)
163+
return cleaned_data
164+
137165

138166
class CourseTaskAdminForm(forms.ModelForm):
139167
image_upload = forms.FileField(
@@ -153,14 +181,11 @@ def clean(self):
153181
cleaned_data = super().clean()
154182
image_upload = cleaned_data.get("image_upload")
155183
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-
)
184+
_validate_image_upload(
185+
self,
186+
"image_upload",
187+
"В поле изображения можно загрузить только файл изображения.",
188+
)
164189

165190
# Preserve the fact that a file was provided so model validation
166191
# doesn't add a second "required image" error for the same field.
@@ -179,6 +204,15 @@ class Meta:
179204
model = CourseModule
180205
fields = "__all__"
181206

207+
def clean(self):
208+
cleaned_data = super().clean()
209+
_validate_image_upload(
210+
self,
211+
"avatar_upload",
212+
"В поле аватара можно загрузить только файл изображения.",
213+
)
214+
return cleaned_data
215+
182216

183217
class CourseModuleInline(admin.TabularInline):
184218
model = CourseModule

courses/models/answers.py

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -96,23 +96,45 @@ def clean(self):
9696
if self.task_id is None:
9797
return
9898

99-
if self.task.task_kind != CourseTaskKind.QUESTION:
100-
errors["task"] = "Ответ можно отправлять только на вопросное задание."
101-
102-
answer_type = self.task.answer_type
103-
is_text_filled = CourseTask._require_non_blank(self.answer_text)
104-
if answer_type in (
105-
CourseTaskAnswerType.TEXT,
106-
CourseTaskAnswerType.TEXT_AND_FILES,
107-
) and not is_text_filled:
108-
errors["answer_text"] = "Для выбранного типа ответа требуется заполнить текст."
109-
110-
if answer_type in (
111-
CourseTaskAnswerType.SINGLE_CHOICE,
112-
CourseTaskAnswerType.MULTIPLE_CHOICE,
113-
CourseTaskAnswerType.FILES,
114-
) and is_text_filled:
115-
errors["answer_text"] = "Для выбранного типа ответа текст не используется."
99+
if self.task.task_kind == CourseTaskKind.INFORMATIONAL:
100+
if CourseTask._require_non_blank(self.answer_text):
101+
errors["answer_text"] = (
102+
"Для информационного задания текст ответа не требуется."
103+
)
104+
if self.status != UserTaskAnswerStatus.SUBMITTED:
105+
errors["status"] = (
106+
"Для информационного задания допустим только статус submitted."
107+
)
108+
if CourseTask._require_non_blank(self.review_comment):
109+
errors["review_comment"] = (
110+
"Для информационного задания комментарий проверки не используется."
111+
)
112+
if self.reviewed_by_id or self.reviewed_at:
113+
errors["reviewed_by"] = (
114+
"Для информационного задания поля reviewed_by и reviewed_at не используются."
115+
)
116+
errors["reviewed_at"] = (
117+
"Для информационного задания поля reviewed_by и reviewed_at не используются."
118+
)
119+
else:
120+
answer_type = self.task.answer_type
121+
is_text_filled = CourseTask._require_non_blank(self.answer_text)
122+
if answer_type in (
123+
CourseTaskAnswerType.TEXT,
124+
CourseTaskAnswerType.TEXT_AND_FILES,
125+
) and not is_text_filled:
126+
errors["answer_text"] = (
127+
"Для выбранного типа ответа требуется заполнить текст."
128+
)
129+
130+
if answer_type in (
131+
CourseTaskAnswerType.SINGLE_CHOICE,
132+
CourseTaskAnswerType.MULTIPLE_CHOICE,
133+
CourseTaskAnswerType.FILES,
134+
) and is_text_filled:
135+
errors["answer_text"] = (
136+
"Для выбранного типа ответа текст не используется."
137+
)
116138

117139
if (
118140
self.task.check_type == CourseTaskCheckType.WITHOUT_REVIEW

courses/models/content.py

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,7 @@
1515
CourseTaskQuestionType,
1616
)
1717
from .course import Course
18-
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
18+
from .file_validation import looks_like_image_file
3719

3820

3921
class CourseModule(models.Model):
@@ -95,6 +77,17 @@ class Meta:
9577
def __str__(self):
9678
return f"CourseModule<{self.id}> - {self.title}"
9779

80+
def clean(self):
81+
super().clean()
82+
83+
if self.avatar_file_id is not None and not looks_like_image_file(
84+
mime_type=self.avatar_file.mime_type,
85+
extension=self.avatar_file.extension,
86+
):
87+
raise ValidationError(
88+
{"avatar_file": "В поле аватара можно выбрать только файл изображения."}
89+
)
90+
9891
def save(self, *args, **kwargs):
9992
self.full_clean()
10093
super().save(*args, **kwargs)

courses/models/course.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from partner_programs.models import PartnerProgram
99

1010
from .choices import CourseAccessType, CourseContentStatus
11+
from .file_validation import looks_like_image_file
1112

1213

1314
class Course(models.Model):
@@ -137,6 +138,38 @@ def is_completed_by_date(self) -> bool:
137138
def clean(self):
138139
super().clean()
139140

141+
if self.avatar_file_id is not None and not looks_like_image_file(
142+
mime_type=self.avatar_file.mime_type,
143+
extension=self.avatar_file.extension,
144+
):
145+
raise ValidationError(
146+
{"avatar_file": "В поле аватара можно выбрать только файл изображения."}
147+
)
148+
149+
if self.card_cover_file_id is not None and not looks_like_image_file(
150+
mime_type=self.card_cover_file.mime_type,
151+
extension=self.card_cover_file.extension,
152+
):
153+
raise ValidationError(
154+
{
155+
"card_cover_file": (
156+
"В поле обложки карточки можно выбрать только файл изображения."
157+
)
158+
}
159+
)
160+
161+
if self.header_cover_file_id is not None and not looks_like_image_file(
162+
mime_type=self.header_cover_file.mime_type,
163+
extension=self.header_cover_file.extension,
164+
):
165+
raise ValidationError(
166+
{
167+
"header_cover_file": (
168+
"В поле обложки шапки можно выбрать только файл изображения."
169+
)
170+
}
171+
)
172+
140173
if (
141174
self.access_type == CourseAccessType.PROGRAM_MEMBERS
142175
and self.partner_program_id is None

courses/models/file_validation.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
ALLOWED_IMAGE_EXTENSIONS = {
2+
"bmp",
3+
"gif",
4+
"jpg",
5+
"jpeg",
6+
"png",
7+
"svg",
8+
"webp",
9+
}
10+
11+
12+
def looks_like_image_file(
13+
*,
14+
mime_type: str | None = None,
15+
extension: str | None = None,
16+
) -> bool:
17+
normalized_mime_type = (mime_type or "").strip().lower()
18+
if normalized_mime_type.startswith("image/"):
19+
return True
20+
21+
normalized_extension = (extension or "").strip().lower().lstrip(".")
22+
return normalized_extension in ALLOWED_IMAGE_EXTENSIONS

courses/services/answers.py

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,51 @@ def submit_user_task_answer(
193193
{"task": "Отправка ответа доступна только для опубликованных заданий."}
194194
)
195195

196-
if task.task_kind != CourseTaskKind.QUESTION:
197-
raise ValidationError(
198-
{"task": "Отправка ответа доступна только для вопросных заданий."}
196+
submitted_at = timezone.now()
197+
manager = UserTaskAnswer.objects
198+
if transaction.get_connection().in_atomic_block:
199+
manager = manager.select_for_update()
200+
answer = manager.filter(user=user, task=task).first()
201+
if answer is None:
202+
answer = UserTaskAnswer(user=user, task=task)
203+
204+
if task.task_kind == CourseTaskKind.INFORMATIONAL:
205+
answer.answer_text = ""
206+
answer.submitted_at = submitted_at
207+
answer.review_comment = ""
208+
answer.reviewed_by = None
209+
answer.reviewed_at = None
210+
answer.status = UserTaskAnswerStatus.SUBMITTED
211+
answer.is_correct = True
212+
213+
try:
214+
answer.save(validate=False)
215+
except DjangoValidationError as exc:
216+
if hasattr(exc, "message_dict"):
217+
raise ValidationError(exc.message_dict) from exc
218+
raise ValidationError({"detail": exc.messages}) from exc
219+
except IntegrityError:
220+
retry_manager = UserTaskAnswer.objects
221+
if transaction.get_connection().in_atomic_block:
222+
retry_manager = retry_manager.select_for_update()
223+
answer = retry_manager.get(user=user, task=task)
224+
answer.answer_text = ""
225+
answer.submitted_at = submitted_at
226+
answer.review_comment = ""
227+
answer.reviewed_by = None
228+
answer.reviewed_at = None
229+
answer.status = UserTaskAnswerStatus.SUBMITTED
230+
answer.is_correct = True
231+
answer.save(validate=False)
232+
233+
answer.selected_options.all().delete()
234+
answer.files.all().delete()
235+
next_task = get_next_published_task(task)
236+
return SubmitAnswerResult(
237+
answer=answer,
238+
is_correct=True,
239+
can_continue=True,
240+
next_task_id=next_task.id if next_task else None,
199241
)
200242

201243
if not task.answer_type:
@@ -213,13 +255,6 @@ def submit_user_task_answer(
213255
)
214256

215257
normalized_text = (payload.answer_text or "").strip()
216-
submitted_at = timezone.now()
217-
manager = UserTaskAnswer.objects
218-
if transaction.get_connection().in_atomic_block:
219-
manager = manager.select_for_update()
220-
answer = manager.filter(user=user, task=task).first()
221-
if answer is None:
222-
answer = UserTaskAnswer(user=user, task=task)
223258

224259
answer.answer_text = normalized_text
225260
answer.submitted_at = submitted_at

0 commit comments

Comments
 (0)