Skip to content

Commit 53d610c

Browse files
committed
Переработан модуль курсов: в ответах прпнимаются только файлы активного пользователя, добавлен пересчёт прогресса после ручной проверки ответов через Django admin, вынесена явная сериализация response payload для read API курсов. Добавлены regression-тесты
1 parent 12bc951 commit 53d610c

8 files changed

Lines changed: 127 additions & 21 deletions

File tree

courses/admin_config/answers.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
from django.contrib import admin
22

3-
from courses.models import UserTaskAnswer, UserTaskAnswerFile, UserTaskAnswerOption
3+
from courses.models import (
4+
CourseTaskCheckType,
5+
UserTaskAnswer,
6+
UserTaskAnswerFile,
7+
UserTaskAnswerOption,
8+
)
9+
from courses.services.progress import recalculate_user_progresses_for_lesson
410

511
from .inlines import UserTaskAnswerFileInline, UserTaskAnswerOptionInline
612

713

14+
REVIEW_PROGRESS_FIELDS = {
15+
"status",
16+
"is_correct",
17+
"review_comment",
18+
"reviewed_by",
19+
"reviewed_at",
20+
}
21+
22+
823
@admin.register(UserTaskAnswer)
924
class UserTaskAnswerAdmin(admin.ModelAdmin):
1025
list_display = (
@@ -44,6 +59,16 @@ class UserTaskAnswerAdmin(admin.ModelAdmin):
4459
)
4560
inlines = [UserTaskAnswerOptionInline, UserTaskAnswerFileInline]
4661

62+
def save_model(self, request, obj, form, change):
63+
super().save_model(request, obj, form, change)
64+
65+
changed_fields = set(getattr(form, "changed_data", []) or [])
66+
if (
67+
obj.task.check_type == CourseTaskCheckType.WITH_REVIEW
68+
and changed_fields & REVIEW_PROGRESS_FIELDS
69+
):
70+
recalculate_user_progresses_for_lesson(obj.user, obj.task.lesson)
71+
4772

4873
@admin.register(UserTaskAnswerOption)
4974
class UserTaskAnswerOptionAdmin(admin.ModelAdmin):

courses/admin_config/forms.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ def clean(self):
117117
"image_upload",
118118
"В поле изображения можно загрузить только файл изображения.",
119119
)
120+
# TODO: убрать временные флаги, когда upload -> UserFile будет вынесен
121+
# в явный admin/service слой до запуска model validation.
120122
self.instance._has_pending_image_upload = bool(image_upload)
121123
self.instance._has_pending_attachment_upload = bool(attachment_upload)
122124
return cleaned_data

courses/api/response.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from rest_framework import serializers
2+
3+
4+
def serialize_response(
5+
serializer_class: type[serializers.Serializer],
6+
payload,
7+
*,
8+
many: bool = False,
9+
):
10+
serializer = serializer_class(data=payload, many=many)
11+
serializer.is_valid(raise_exception=True)
12+
return serializer.data

courses/api/views/course_read.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
CourseDetailSerializer,
66
CourseStructureSerializer,
77
)
8+
from courses.api.response import serialize_response
89
from courses.queries import (
910
build_course_detail_payload,
1011
build_course_list_payload,
@@ -17,29 +18,32 @@
1718
class CourseListAPIView(AuthenticatedCourseAPIView):
1819

1920
def get(self, request):
20-
serializer = CourseCardSerializer(
21-
data=build_course_list_payload(request.user),
22-
many=True,
21+
return Response(
22+
serialize_response(
23+
CourseCardSerializer,
24+
build_course_list_payload(request.user),
25+
many=True,
26+
)
2327
)
24-
serializer.is_valid(raise_exception=True)
25-
return Response(serializer.data)
2628

2729

2830
class CourseDetailAPIView(AuthenticatedCourseAPIView):
2931

3032
def get(self, request, pk: int):
31-
serializer = CourseDetailSerializer(
32-
data=build_course_detail_payload(request.user, pk)
33+
return Response(
34+
serialize_response(
35+
CourseDetailSerializer,
36+
build_course_detail_payload(request.user, pk),
37+
)
3338
)
34-
serializer.is_valid(raise_exception=True)
35-
return Response(serializer.data)
3639

3740

3841
class CourseStructureAPIView(AuthenticatedCourseAPIView):
3942

4043
def get(self, request, pk: int):
41-
serializer = CourseStructureSerializer(
42-
data=build_course_structure_payload(request.user, pk)
44+
return Response(
45+
serialize_response(
46+
CourseStructureSerializer,
47+
build_course_structure_payload(request.user, pk),
48+
)
4349
)
44-
serializer.is_valid(raise_exception=True)
45-
return Response(serializer.data)

courses/api/views/lesson_read.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from rest_framework.response import Response
22

3+
from courses.api.response import serialize_response
34
from courses.api.serializers import LessonDetailSerializer
45
from courses.queries import build_lesson_detail_payload
56

@@ -9,8 +10,9 @@
910
class LessonDetailAPIView(AuthenticatedCourseAPIView):
1011

1112
def get(self, request, pk: int):
12-
serializer = LessonDetailSerializer(
13-
data=build_lesson_detail_payload(request.user, pk)
13+
return Response(
14+
serialize_response(
15+
LessonDetailSerializer,
16+
build_lesson_detail_payload(request.user, pk),
17+
)
1418
)
15-
serializer.is_valid(raise_exception=True)
16-
return Response(serializer.data)

courses/services/answers.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,15 @@ def _resolve_task_options(
6161
return options
6262

6363

64-
def _resolve_user_files(file_ids: list[str]) -> list[UserFile]:
64+
def _resolve_user_files(user, file_ids: list[str]) -> list[UserFile]:
6565
if not file_ids:
6666
return []
6767

6868
unique_ids = list(dict.fromkeys(file_ids))
6969
if len(unique_ids) != len(file_ids):
7070
raise ValidationError({"file_ids": "Переданы дублирующиеся файлы."})
7171

72-
files = list(UserFile.objects.filter(pk__in=unique_ids))
72+
files = list(UserFile.objects.filter(pk__in=unique_ids, user=user))
7373
files_by_id = {file.pk: file for file in files}
7474
missing_ids = [file_id for file_id in unique_ids if file_id not in files_by_id]
7575
if missing_ids:
@@ -305,11 +305,12 @@ def _validate_question_task(task: CourseTask) -> None:
305305

306306

307307
def _resolve_question_payload(
308+
user,
308309
task: CourseTask,
309310
payload: TaskAnswerSubmitPayload,
310311
) -> tuple[str, list[CourseTaskOption], list[UserFile]]:
311312
selected_options = _resolve_task_options(task, payload.option_ids)
312-
selected_files = _resolve_user_files(payload.file_ids)
313+
selected_files = _resolve_user_files(user, payload.file_ids)
313314
_validate_payload_by_answer_type(
314315
task,
315316
payload,
@@ -348,6 +349,7 @@ def _submit_question_answer(
348349
) -> SubmitAnswerResult:
349350
_validate_question_task(task)
350351
normalized_text, selected_options, selected_files = _resolve_question_payload(
352+
user,
351353
task,
352354
payload,
353355
)

courses/tests/test_answers.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
from types import SimpleNamespace
2+
3+
from django.contrib import admin
14
from django.test import TestCase
5+
from django.test import RequestFactory
6+
from django.utils import timezone
27

8+
from courses.admin_config.answers import UserTaskAnswerAdmin
39
from courses.models import UserTaskAnswer, UserTaskAnswerStatus
410
from courses.services.answers import TaskAnswerSubmitPayload, submit_user_task_answer
511

@@ -68,3 +74,40 @@ def test_submit_text_question_with_review_blocks_continue(self):
6874
self.assertIsNone(answer.is_correct)
6975
self.assertFalse(result.can_continue)
7076
self.assertIsNone(result.next_task_id)
77+
78+
def test_admin_review_recalculates_progress_after_accept(self):
79+
reviewer = create_user(prefix="reviewer")
80+
question_task = create_text_question_task(
81+
self.lesson,
82+
order=1,
83+
check_type="with_review",
84+
)
85+
submit_user_task_answer(
86+
self.user,
87+
question_task,
88+
TaskAnswerSubmitPayload(answer_text="ok"),
89+
)
90+
answer = UserTaskAnswer.objects.get(user=self.user, task=question_task)
91+
request = RequestFactory().post("/")
92+
request.user = reviewer
93+
form = SimpleNamespace(
94+
changed_data=["status", "is_correct", "reviewed_by", "reviewed_at"]
95+
)
96+
97+
answer.status = UserTaskAnswerStatus.ACCEPTED
98+
answer.is_correct = True
99+
answer.reviewed_by = reviewer
100+
answer.reviewed_at = timezone.now()
101+
UserTaskAnswerAdmin(UserTaskAnswer, admin.site).save_model(
102+
request,
103+
answer,
104+
form,
105+
change=True,
106+
)
107+
108+
lesson_progress = self.lesson.user_progresses.get(user=self.user)
109+
module_progress = self.module.user_progresses.get(user=self.user)
110+
course_progress = self.course.user_progresses.get(user=self.user)
111+
self.assertEqual(lesson_progress.percent, 100)
112+
self.assertEqual(module_progress.percent, 100)
113+
self.assertEqual(course_progress.percent, 100)

courses/tests/test_api_extended.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,16 @@ def test_files_and_text_and_files_flow_validates_payload_and_completes_course(se
172172
"vnd.openxmlformats-officedocument.presentationml.presentation"
173173
),
174174
)
175+
other_user = create_user(prefix="other-file-owner")
176+
other_user_file = create_user_file(
177+
other_user,
178+
name="foreign-model",
179+
extension="xlsx",
180+
mime_type=(
181+
"application/"
182+
"vnd.openxmlformats-officedocument.spreadsheetml.sheet"
183+
),
184+
)
175185

176186
files_task = create_files_question_task(
177187
lesson,
@@ -194,6 +204,11 @@ def test_files_and_text_and_files_flow_validates_payload_and_completes_course(se
194204
{"file_ids": ["https://cdn.example.com/missing/file.pdf"]},
195205
format="json",
196206
)
207+
foreign_file_response = self.client.post(
208+
f"/courses/tasks/{files_task.id}/answer/",
209+
{"file_ids": [other_user_file.pk]},
210+
format="json",
211+
)
197212
files_response = self.client.post(
198213
f"/courses/tasks/{files_task.id}/answer/",
199214
{"file_ids": [answer_file_1.pk]},
@@ -216,6 +231,7 @@ def test_files_and_text_and_files_flow_validates_payload_and_completes_course(se
216231
course_detail = self.client.get(f"/courses/{course.id}/").json()
217232

218233
self.assertEqual(invalid_file_response.status_code, 400)
234+
self.assertEqual(foreign_file_response.status_code, 400)
219235
self.assertEqual(files_response.status_code, 200)
220236
self.assertTrue(files_response.json()["can_continue"])
221237
self.assertEqual(invalid_text_and_files_response.status_code, 400)

0 commit comments

Comments
 (0)