Skip to content

Commit 63a1334

Browse files
authored
Merge pull request #617 from PROCOLLAB-github/dev
Dev
2 parents cdac197 + 605f55f commit 63a1334

File tree

11 files changed

+853
-107
lines changed

11 files changed

+853
-107
lines changed

core/utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import logging
22
import io
3+
import urllib.parse
34
import unicodedata
45
import pandas as pd
56

67
from django.core.mail import EmailMultiAlternatives
8+
from django.http import HttpResponse
9+
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
710

811

912
logger = logging.getLogger()
13+
EXCEL_CELL_MAX = 32767
1014

1115

1216
class Email:
@@ -88,3 +92,33 @@ def ascii_filename(filename: str) -> str:
8892
ascii_name = "".join(char if char.isascii() else "_" for char in safe_name)
8993
ascii_name = " ".join(ascii_name.split())
9094
return ascii_name or "export"
95+
96+
97+
def sanitize_excel_value(value):
98+
if value is None:
99+
return ""
100+
if isinstance(value, (int, float, bool)):
101+
return value
102+
103+
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
104+
text = ILLEGAL_CHARACTERS_RE.sub(" ", text)
105+
if len(text) > EXCEL_CELL_MAX:
106+
text = text[: EXCEL_CELL_MAX - 3] + "..."
107+
return text
108+
109+
110+
def build_xlsx_download_response(binary_data: bytes, *, base_name: str) -> HttpResponse:
111+
safe_name = sanitize_filename(base_name)
112+
encoded_file_name = urllib.parse.quote(f"{safe_name}.xlsx")
113+
fallback_filename = f"{ascii_filename(base_name)}.xlsx"
114+
115+
response = HttpResponse(
116+
binary_data,
117+
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
118+
)
119+
response["Content-Disposition"] = (
120+
"attachment; "
121+
f"filename=\"{fallback_filename}\"; "
122+
f"filename*=UTF-8''{encoded_file_name}"
123+
)
124+
return response

courses/admin_config/content.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from django.contrib import admin
2+
from django.http import Http404
3+
from django.urls import path
24

35
from courses.models import Course, CourseLesson, CourseModule, CourseTask, CourseTaskOption
6+
from courses.services.export_course_results import build_course_results_export_response
47

58
from .forms import CourseAdminForm, CourseModuleAdminForm, CourseTaskAdminForm
69
from .helpers import UserFileUploadAdminMixin
@@ -13,6 +16,7 @@
1316

1417
@admin.register(Course)
1518
class CourseAdmin(UserFileUploadAdminMixin, admin.ModelAdmin):
19+
change_form_template = "courses/admin/course_change_form.html"
1620
form = CourseAdminForm
1721
list_display = (
1822
"id",
@@ -74,6 +78,34 @@ class CourseAdmin(UserFileUploadAdminMixin, admin.ModelAdmin):
7478
),
7579
)
7680

81+
def get_urls(self):
82+
default_urls = super().get_urls()
83+
custom_urls = [
84+
path(
85+
"<int:object_id>/export-results/",
86+
self.admin_site.admin_view(self.export_results_view),
87+
name="courses_export_results",
88+
),
89+
]
90+
return custom_urls + default_urls
91+
92+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
93+
extra_context = extra_context or {}
94+
if object_id is not None:
95+
extra_context["object_id"] = int(object_id)
96+
return super().changeform_view(
97+
request,
98+
object_id=object_id,
99+
form_url=form_url,
100+
extra_context=extra_context,
101+
)
102+
103+
def export_results_view(self, request, object_id):
104+
course = self.get_object(request, object_id)
105+
if course is None:
106+
raise Http404("Курс не найден.")
107+
return build_course_results_export_response(course)
108+
77109
def save_model(self, request, obj, form, change):
78110
avatar_upload = form.cleaned_data.get("avatar_upload")
79111
if avatar_upload:
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import io
2+
from collections import defaultdict
3+
from zoneinfo import ZoneInfo
4+
5+
from django.utils import timezone
6+
from django.db.models import Prefetch
7+
from openpyxl import Workbook
8+
9+
from core.utils import build_xlsx_download_response, sanitize_excel_value
10+
from courses.models import (
11+
Course,
12+
CourseLesson,
13+
CourseLessonContentStatus,
14+
CourseModuleContentStatus,
15+
CourseTask,
16+
CourseTaskContentStatus,
17+
CourseTaskKind,
18+
ProgressStatus,
19+
UserCourseProgress,
20+
UserLessonProgress,
21+
UserTaskAnswer,
22+
UserTaskAnswerFile,
23+
UserTaskAnswerOption,
24+
)
25+
26+
27+
MSK_TZ = ZoneInfo("Europe/Moscow")
28+
BASE_HEADERS = (
29+
"Имя и Фамилия",
30+
"Email",
31+
"Прогресс курса, %",
32+
"Дата начала прохождения",
33+
"Название курса",
34+
"Текущий этап",
35+
)
36+
37+
38+
def _format_msk_datetime(value) -> str:
39+
if value is None:
40+
return ""
41+
if timezone.is_naive(value):
42+
value = timezone.make_aware(value, timezone.get_current_timezone())
43+
return value.astimezone(MSK_TZ).strftime("%d.%m.%Y %H:%M:%S")
44+
45+
46+
def _full_name(user) -> str:
47+
full_name = f"{user.first_name} {user.last_name}".strip()
48+
return full_name or user.email
49+
50+
51+
def _export_tasks(course: Course) -> list[CourseTask]:
52+
return list(
53+
CourseTask.objects.filter(
54+
lesson__module__course=course,
55+
lesson__module__status=CourseModuleContentStatus.PUBLISHED,
56+
lesson__status=CourseLessonContentStatus.PUBLISHED,
57+
status=CourseTaskContentStatus.PUBLISHED,
58+
task_kind=CourseTaskKind.QUESTION,
59+
)
60+
.select_related("lesson__module")
61+
.order_by(
62+
"lesson__module__order",
63+
"lesson__module__id",
64+
"lesson__order",
65+
"lesson__id",
66+
"order",
67+
"id",
68+
)
69+
)
70+
71+
72+
def _started_course_progresses(course: Course) -> list[UserCourseProgress]:
73+
return list(
74+
UserCourseProgress.objects.filter(
75+
course=course,
76+
started_at__isnull=False,
77+
)
78+
.select_related("user", "course")
79+
.order_by("user__last_name", "user__first_name", "user__email", "id")
80+
)
81+
82+
83+
def _lesson_progresses_by_user(user_ids: list[int], course: Course) -> dict[int, list[UserLessonProgress]]:
84+
if not user_ids:
85+
return {}
86+
87+
progress_map: dict[int, list[UserLessonProgress]] = defaultdict(list)
88+
progresses = (
89+
UserLessonProgress.objects.filter(
90+
user_id__in=user_ids,
91+
lesson__module__course=course,
92+
lesson__module__status=CourseModuleContentStatus.PUBLISHED,
93+
lesson__status=CourseLessonContentStatus.PUBLISHED,
94+
)
95+
.select_related("lesson__module", "current_task")
96+
.order_by(
97+
"lesson__module__order",
98+
"lesson__module__id",
99+
"lesson__order",
100+
"lesson__id",
101+
"id",
102+
)
103+
)
104+
for progress in progresses:
105+
progress_map[progress.user_id].append(progress)
106+
return progress_map
107+
108+
109+
def _published_lessons_with_tasks(course: Course) -> list[CourseLesson]:
110+
published_tasks_qs = CourseTask.objects.filter(
111+
status=CourseTaskContentStatus.PUBLISHED,
112+
).order_by("order", "id")
113+
return list(
114+
CourseLesson.objects.filter(
115+
module__course=course,
116+
module__status=CourseModuleContentStatus.PUBLISHED,
117+
status=CourseLessonContentStatus.PUBLISHED,
118+
)
119+
.select_related("module")
120+
.prefetch_related(
121+
Prefetch("tasks", queryset=published_tasks_qs, to_attr="_published_tasks")
122+
)
123+
.order_by("module__order", "module__id", "order", "id")
124+
)
125+
126+
127+
def _answers_by_user_and_task(
128+
user_ids: list[int],
129+
task_ids: list[int],
130+
) -> dict[tuple[int, int], UserTaskAnswer]:
131+
if not user_ids or not task_ids:
132+
return {}
133+
134+
answers = (
135+
UserTaskAnswer.objects.filter(user_id__in=user_ids, task_id__in=task_ids)
136+
.prefetch_related(
137+
Prefetch(
138+
"selected_options",
139+
queryset=UserTaskAnswerOption.objects.select_related("option").order_by(
140+
"option__order",
141+
"option__id",
142+
"id",
143+
),
144+
),
145+
Prefetch(
146+
"files",
147+
queryset=UserTaskAnswerFile.objects.select_related("file").order_by(
148+
"datetime_uploaded",
149+
"id",
150+
),
151+
),
152+
)
153+
)
154+
return {(answer.user_id, answer.task_id): answer for answer in answers}
155+
156+
157+
def _task_header(task: CourseTask) -> str:
158+
return (
159+
f"Модуль {task.lesson.module.order}: {task.lesson.module.title} / "
160+
f"Урок {task.lesson.order}: {task.lesson.title} / "
161+
f"Задание {task.order}: {task.title}"
162+
)
163+
164+
165+
def _lesson_stage_header(lesson: CourseLesson) -> str:
166+
return (
167+
f"Модуль {lesson.module.order}: {lesson.module.title} / "
168+
f"Урок {lesson.order}: {lesson.title}"
169+
)
170+
171+
172+
def _format_stage(
173+
course_progress: UserCourseProgress,
174+
lesson_progresses: list[UserLessonProgress],
175+
published_lessons: list[CourseLesson],
176+
) -> str:
177+
if course_progress.status == ProgressStatus.COMPLETED:
178+
return "Курс завершён"
179+
180+
lesson_progress_by_lesson_id = {
181+
progress.lesson_id: progress for progress in lesson_progresses
182+
}
183+
184+
current_progress = next(
185+
(
186+
progress
187+
for progress in lesson_progresses
188+
if progress.current_task_id
189+
and progress.current_task is not None
190+
and progress.current_task.status == CourseTaskContentStatus.PUBLISHED
191+
),
192+
None,
193+
)
194+
if current_progress is not None:
195+
current_task = current_progress.current_task
196+
return _task_header(current_task)
197+
198+
in_progress_lesson = next(
199+
(progress for progress in lesson_progresses if progress.status == ProgressStatus.IN_PROGRESS),
200+
None,
201+
)
202+
if in_progress_lesson is not None:
203+
return _lesson_stage_header(in_progress_lesson.lesson)
204+
205+
for lesson in published_lessons:
206+
lesson_progress = lesson_progress_by_lesson_id.get(lesson.id)
207+
if lesson_progress and lesson_progress.status == ProgressStatus.COMPLETED:
208+
continue
209+
210+
published_tasks = getattr(lesson, "_published_tasks", [])
211+
if published_tasks:
212+
return _task_header(published_tasks[0])
213+
return _lesson_stage_header(lesson)
214+
215+
return "Этап не определён"
216+
217+
218+
def _format_answer_cell(answer: UserTaskAnswer | None) -> str:
219+
if answer is None:
220+
return ""
221+
222+
parts: list[str] = []
223+
if answer.answer_text:
224+
parts.append(answer.answer_text.strip())
225+
226+
selected_options = [
227+
selected.option.text.strip()
228+
for selected in answer.selected_options.all()
229+
if selected.option.text.strip()
230+
]
231+
if selected_options:
232+
parts.append("\n".join(selected_options))
233+
234+
file_links = [attachment.file.link for attachment in answer.files.all()]
235+
if file_links:
236+
parts.append("\n".join(file_links))
237+
238+
return "\n".join(part for part in parts if part)
239+
240+
241+
def _build_headers(tasks: list[CourseTask]) -> list[str]:
242+
return [*BASE_HEADERS, *[_task_header(task) for task in tasks]]
243+
244+
245+
def build_course_results_workbook_bytes(course: Course) -> bytes:
246+
tasks = _export_tasks(course)
247+
published_lessons = _published_lessons_with_tasks(course)
248+
course_progresses = _started_course_progresses(course)
249+
user_ids = [progress.user_id for progress in course_progresses]
250+
task_ids = [task.id for task in tasks]
251+
252+
lesson_progress_map = _lesson_progresses_by_user(user_ids, course)
253+
answers_map = _answers_by_user_and_task(user_ids, task_ids)
254+
255+
workbook = Workbook(write_only=True)
256+
worksheet = workbook.create_sheet(title="Результаты курса")
257+
worksheet.append([sanitize_excel_value(value) for value in _build_headers(tasks)])
258+
259+
for course_progress in course_progresses:
260+
lesson_progresses = lesson_progress_map.get(course_progress.user_id, [])
261+
row = [
262+
_full_name(course_progress.user),
263+
course_progress.user.email,
264+
course_progress.percent,
265+
_format_msk_datetime(course_progress.started_at),
266+
course.title,
267+
_format_stage(course_progress, lesson_progresses, published_lessons),
268+
]
269+
for task in tasks:
270+
row.append(
271+
_format_answer_cell(
272+
answers_map.get((course_progress.user_id, task.id))
273+
)
274+
)
275+
worksheet.append([sanitize_excel_value(value) for value in row])
276+
277+
buffer = io.BytesIO()
278+
workbook.save(buffer)
279+
buffer.seek(0)
280+
return buffer.getvalue()
281+
282+
283+
def build_course_results_export_response(course: Course):
284+
binary_data = build_course_results_workbook_bytes(course)
285+
286+
date_suffix = timezone.now().astimezone(MSK_TZ).strftime("%d.%m.%Y")
287+
base_name = f"course-results - {course.title} - {date_suffix}"
288+
return build_xlsx_download_response(binary_data, base_name=base_name)

0 commit comments

Comments
 (0)