Skip to content

Commit f29ab94

Browse files
authored
Merge pull request #615 from PROCOLLAB-github/feature/program-courses
Feature/program courses
2 parents 59666ee + 1bad80f commit f29ab94

11 files changed

Lines changed: 534 additions & 107 deletions

File tree

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

courses/tests/helpers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ def create_user(*, prefix: str = "courses-test") -> CustomUser:
4141
)
4242

4343

44+
def create_staff_user(*, prefix: str = "courses-admin") -> CustomUser:
45+
suffix = unique_suffix()
46+
return CustomUser.objects.create_superuser(
47+
email=f"{prefix}-{suffix}@example.com",
48+
password="testpass123",
49+
first_name="Admin",
50+
last_name="User",
51+
)
52+
53+
4454
def create_partner_program(*, name: str = "Program") -> PartnerProgram:
4555
suffix = unique_suffix()
4656
now = timezone.now()

0 commit comments

Comments
 (0)