diff --git a/core/utils.py b/core/utils.py index 018e1862..563fa650 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,12 +1,16 @@ import logging import io +import urllib.parse import unicodedata import pandas as pd from django.core.mail import EmailMultiAlternatives +from django.http import HttpResponse +from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE logger = logging.getLogger() +EXCEL_CELL_MAX = 32767 class Email: @@ -88,3 +92,33 @@ def ascii_filename(filename: str) -> str: ascii_name = "".join(char if char.isascii() else "_" for char in safe_name) ascii_name = " ".join(ascii_name.split()) return ascii_name or "export" + + +def sanitize_excel_value(value): + if value is None: + return "" + if isinstance(value, (int, float, bool)): + return value + + text = str(value).replace("\r\n", "\n").replace("\r", "\n") + text = ILLEGAL_CHARACTERS_RE.sub(" ", text) + if len(text) > EXCEL_CELL_MAX: + text = text[: EXCEL_CELL_MAX - 3] + "..." + return text + + +def build_xlsx_download_response(binary_data: bytes, *, base_name: str) -> HttpResponse: + safe_name = sanitize_filename(base_name) + encoded_file_name = urllib.parse.quote(f"{safe_name}.xlsx") + fallback_filename = f"{ascii_filename(base_name)}.xlsx" + + response = HttpResponse( + binary_data, + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response["Content-Disposition"] = ( + "attachment; " + f"filename=\"{fallback_filename}\"; " + f"filename*=UTF-8''{encoded_file_name}" + ) + return response diff --git a/courses/admin_config/content.py b/courses/admin_config/content.py index b80fb86f..e2aa564c 100644 --- a/courses/admin_config/content.py +++ b/courses/admin_config/content.py @@ -1,6 +1,9 @@ from django.contrib import admin +from django.http import Http404 +from django.urls import path from courses.models import Course, CourseLesson, CourseModule, CourseTask, CourseTaskOption +from courses.services.export_course_results import build_course_results_export_response from .forms import CourseAdminForm, CourseModuleAdminForm, CourseTaskAdminForm from .helpers import UserFileUploadAdminMixin @@ -13,6 +16,7 @@ @admin.register(Course) class CourseAdmin(UserFileUploadAdminMixin, admin.ModelAdmin): + change_form_template = "courses/admin/course_change_form.html" form = CourseAdminForm list_display = ( "id", @@ -74,6 +78,34 @@ class CourseAdmin(UserFileUploadAdminMixin, admin.ModelAdmin): ), ) + def get_urls(self): + default_urls = super().get_urls() + custom_urls = [ + path( + "/export-results/", + self.admin_site.admin_view(self.export_results_view), + name="courses_export_results", + ), + ] + return custom_urls + default_urls + + def changeform_view(self, request, object_id=None, form_url="", extra_context=None): + extra_context = extra_context or {} + if object_id is not None: + extra_context["object_id"] = int(object_id) + return super().changeform_view( + request, + object_id=object_id, + form_url=form_url, + extra_context=extra_context, + ) + + def export_results_view(self, request, object_id): + course = self.get_object(request, object_id) + if course is None: + raise Http404("Курс не найден.") + return build_course_results_export_response(course) + def save_model(self, request, obj, form, change): avatar_upload = form.cleaned_data.get("avatar_upload") if avatar_upload: diff --git a/courses/services/export_course_results.py b/courses/services/export_course_results.py new file mode 100644 index 00000000..57aee4a8 --- /dev/null +++ b/courses/services/export_course_results.py @@ -0,0 +1,247 @@ +import io +from collections import defaultdict +from zoneinfo import ZoneInfo + +from django.utils import timezone +from django.db.models import Prefetch +from openpyxl import Workbook + +from core.utils import build_xlsx_download_response, sanitize_excel_value +from courses.models import ( + Course, + CourseLessonContentStatus, + CourseModuleContentStatus, + CourseTask, + CourseTaskContentStatus, + CourseTaskKind, + ProgressStatus, + UserCourseProgress, + UserLessonProgress, + UserTaskAnswer, + UserTaskAnswerFile, + UserTaskAnswerOption, +) + + +MSK_TZ = ZoneInfo("Europe/Moscow") +BASE_HEADERS = ( + "Имя и Фамилия", + "Email", + "Прогресс курса, %", + "Дата начала прохождения", + "Название курса", + "Текущий этап", +) + + +def _format_msk_datetime(value) -> str: + if value is None: + return "" + if timezone.is_naive(value): + value = timezone.make_aware(value, timezone.get_current_timezone()) + return value.astimezone(MSK_TZ).strftime("%d.%m.%Y %H:%M:%S") + + +def _full_name(user) -> str: + full_name = f"{user.first_name} {user.last_name}".strip() + return full_name or user.email + + +def _export_tasks(course: Course) -> list[CourseTask]: + return list( + CourseTask.objects.filter( + lesson__module__course=course, + lesson__module__status=CourseModuleContentStatus.PUBLISHED, + lesson__status=CourseLessonContentStatus.PUBLISHED, + status=CourseTaskContentStatus.PUBLISHED, + task_kind=CourseTaskKind.QUESTION, + ) + .select_related("lesson__module") + .order_by( + "lesson__module__order", + "lesson__module__id", + "lesson__order", + "lesson__id", + "order", + "id", + ) + ) + + +def _started_course_progresses(course: Course) -> list[UserCourseProgress]: + return list( + UserCourseProgress.objects.filter( + course=course, + started_at__isnull=False, + ) + .select_related("user", "course") + .order_by("user__last_name", "user__first_name", "user__email", "id") + ) + + +def _lesson_progresses_by_user(user_ids: list[int], course: Course) -> dict[int, list[UserLessonProgress]]: + if not user_ids: + return {} + + progress_map: dict[int, list[UserLessonProgress]] = defaultdict(list) + progresses = ( + UserLessonProgress.objects.filter( + user_id__in=user_ids, + lesson__module__course=course, + lesson__module__status=CourseModuleContentStatus.PUBLISHED, + lesson__status=CourseLessonContentStatus.PUBLISHED, + ) + .select_related("lesson__module", "current_task") + .order_by( + "lesson__module__order", + "lesson__module__id", + "lesson__order", + "lesson__id", + "id", + ) + ) + for progress in progresses: + progress_map[progress.user_id].append(progress) + return progress_map + + +def _answers_by_user_and_task( + user_ids: list[int], + task_ids: list[int], +) -> dict[tuple[int, int], UserTaskAnswer]: + if not user_ids or not task_ids: + return {} + + answers = ( + UserTaskAnswer.objects.filter(user_id__in=user_ids, task_id__in=task_ids) + .prefetch_related( + Prefetch( + "selected_options", + queryset=UserTaskAnswerOption.objects.select_related("option").order_by( + "option__order", + "option__id", + "id", + ), + ), + Prefetch( + "files", + queryset=UserTaskAnswerFile.objects.select_related("file").order_by( + "datetime_uploaded", + "id", + ), + ), + ) + ) + return {(answer.user_id, answer.task_id): answer for answer in answers} + + +def _task_header(task: CourseTask) -> str: + return ( + f"Модуль {task.lesson.module.order}: {task.lesson.module.title} / " + f"Урок {task.lesson.order}: {task.lesson.title} / " + f"Задание {task.order}: {task.title}" + ) + + +def _format_stage( + course_progress: UserCourseProgress, + lesson_progresses: list[UserLessonProgress], +) -> str: + if course_progress.status == ProgressStatus.COMPLETED: + return "Курс завершён" + + current_progress = next( + (progress for progress in lesson_progresses if progress.current_task_id), + None, + ) + if current_progress is not None: + current_task = current_progress.current_task + return ( + f"Модуль {current_progress.lesson.module.order}: {current_progress.lesson.module.title} / " + f"Урок {current_progress.lesson.order}: {current_progress.lesson.title} / " + f"Задание {current_task.order}: {current_task.title}" + ) + + in_progress_lesson = next( + (progress for progress in lesson_progresses if progress.status == ProgressStatus.IN_PROGRESS), + None, + ) + if in_progress_lesson is not None: + return ( + f"Модуль {in_progress_lesson.lesson.module.order}: {in_progress_lesson.lesson.module.title} / " + f"Урок {in_progress_lesson.lesson.order}: {in_progress_lesson.lesson.title}" + ) + + return "Этап не определён" + + +def _format_answer_cell(answer: UserTaskAnswer | None) -> str: + if answer is None: + return "" + + parts: list[str] = [] + if answer.answer_text: + parts.append(answer.answer_text.strip()) + + selected_options = [ + selected.option.text.strip() + for selected in answer.selected_options.all() + if selected.option.text.strip() + ] + if selected_options: + parts.append("\n".join(selected_options)) + + file_links = [attachment.file.link for attachment in answer.files.all()] + if file_links: + parts.append("\n".join(file_links)) + + return "\n".join(part for part in parts if part) + + +def _build_headers(tasks: list[CourseTask]) -> list[str]: + return [*BASE_HEADERS, *[_task_header(task) for task in tasks]] + + +def build_course_results_workbook_bytes(course: Course) -> bytes: + tasks = _export_tasks(course) + course_progresses = _started_course_progresses(course) + user_ids = [progress.user_id for progress in course_progresses] + task_ids = [task.id for task in tasks] + + lesson_progress_map = _lesson_progresses_by_user(user_ids, course) + answers_map = _answers_by_user_and_task(user_ids, task_ids) + + workbook = Workbook(write_only=True) + worksheet = workbook.create_sheet(title="Результаты курса") + worksheet.append([sanitize_excel_value(value) for value in _build_headers(tasks)]) + + for course_progress in course_progresses: + lesson_progresses = lesson_progress_map.get(course_progress.user_id, []) + row = [ + _full_name(course_progress.user), + course_progress.user.email, + course_progress.percent, + _format_msk_datetime(course_progress.started_at), + course.title, + _format_stage(course_progress, lesson_progresses), + ] + for task in tasks: + row.append( + _format_answer_cell( + answers_map.get((course_progress.user_id, task.id)) + ) + ) + worksheet.append([sanitize_excel_value(value) for value in row]) + + buffer = io.BytesIO() + workbook.save(buffer) + buffer.seek(0) + return buffer.getvalue() + + +def build_course_results_export_response(course: Course): + binary_data = build_course_results_workbook_bytes(course) + + date_suffix = timezone.now().astimezone(MSK_TZ).strftime("%d.%m.%Y") + base_name = f"course-results - {course.title} - {date_suffix}" + return build_xlsx_download_response(binary_data, base_name=base_name) diff --git a/courses/tests/helpers.py b/courses/tests/helpers.py index 5c716318..fdc5dbe7 100644 --- a/courses/tests/helpers.py +++ b/courses/tests/helpers.py @@ -41,6 +41,16 @@ def create_user(*, prefix: str = "courses-test") -> CustomUser: ) +def create_staff_user(*, prefix: str = "courses-admin") -> CustomUser: + suffix = unique_suffix() + return CustomUser.objects.create_superuser( + email=f"{prefix}-{suffix}@example.com", + password="testpass123", + first_name="Admin", + last_name="User", + ) + + def create_partner_program(*, name: str = "Program") -> PartnerProgram: suffix = unique_suffix() now = timezone.now() diff --git a/courses/tests/test_export.py b/courses/tests/test_export.py new file mode 100644 index 00000000..b0903a87 --- /dev/null +++ b/courses/tests/test_export.py @@ -0,0 +1,176 @@ +from io import BytesIO + +from django.test import Client, TestCase +from django.urls import reverse +from openpyxl import load_workbook + +from courses.services.answers import TaskAnswerSubmitPayload, submit_user_task_answer +from courses.services.progress import recalculate_user_progresses_for_lesson + +from .helpers import ( + create_choice_question_task, + create_course, + create_files_question_task, + create_informational_task, + create_lesson, + create_module, + create_staff_user, + create_text_and_files_question_task, + create_text_question_task, + create_user, + create_user_file, +) + + +class CourseResultsExportTests(TestCase): + def setUp(self): + self.admin_user = create_staff_user() + self.client = Client() + self.client.force_login(self.admin_user) + + def _read_workbook_rows(self, response): + workbook = load_workbook(filename=BytesIO(response.content), read_only=True) + worksheet = workbook[workbook.sheetnames[0]] + return list(worksheet.iter_rows(values_only=True)) + + def test_course_admin_export_contains_dynamic_task_columns_and_answers(self): + course = create_course(title="Экспорт SQL") + module = create_module(course, title="SQL basics", order=1) + lesson = create_lesson(module, title="SELECT", order=1) + student = create_user(prefix="export-student") + create_user(prefix="export-not-started") + + info_task = create_informational_task(lesson, title="Введение", order=1) + text_task = create_text_question_task(lesson, title="Опишите юнит-экономику", order=2) + choice_task, options = create_choice_question_task( + lesson, + title="Какие метрики важны", + order=3, + answer_type="multiple_choice", + options=[ + ("Retention", True), + ("LTV", True), + ("Bounce Rate", False), + ], + ) + spreadsheet = create_user_file( + student, + name="economics-model", + extension="xlsx", + mime_type=( + "application/" + "vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + files_task = create_files_question_task( + lesson, + attachment_file=spreadsheet, + title="Приложите расчёт", + order=4, + ) + create_text_and_files_question_task( + lesson, + title="Финальный вывод", + order=5, + ) + + submit_user_task_answer(student, info_task, TaskAnswerSubmitPayload()) + recalculate_user_progresses_for_lesson(student, lesson) + submit_user_task_answer( + student, + text_task, + TaskAnswerSubmitPayload(answer_text="Указал юнит-экономику."), + ) + recalculate_user_progresses_for_lesson(student, lesson) + submit_user_task_answer( + student, + choice_task, + TaskAnswerSubmitPayload(option_ids=[options[0].id, options[1].id]), + ) + recalculate_user_progresses_for_lesson(student, lesson) + submit_user_task_answer( + student, + files_task, + TaskAnswerSubmitPayload(file_ids=[spreadsheet.pk]), + ) + recalculate_user_progresses_for_lesson(student, lesson) + + response = self.client.get( + reverse("admin:courses_export_results", args=[course.id]) + ) + rows = self._read_workbook_rows(response) + + self.assertEqual(response.status_code, 200) + self.assertIn(".xlsx", response["Content-Disposition"]) + self.assertEqual(len(rows), 2) + + header = rows[0] + data_row = rows[1] + + self.assertEqual( + header[:6], + ( + "Имя и Фамилия", + "Email", + "Прогресс курса, %", + "Дата начала прохождения", + "Название курса", + "Текущий этап", + ), + ) + self.assertNotIn("Введение", "\n".join(str(value) for value in header)) + self.assertIn( + "Модуль 1: SQL basics / Урок 1: SELECT / Задание 2: Опишите юнит-экономику", + header, + ) + self.assertIn( + "Модуль 1: SQL basics / Урок 1: SELECT / Задание 5: Финальный вывод", + header, + ) + + self.assertEqual(data_row[0], f"{student.first_name} {student.last_name}") + self.assertEqual(data_row[1], student.email) + self.assertEqual(data_row[2], 80) + self.assertEqual(data_row[4], course.title) + self.assertEqual( + data_row[6:10], + ( + "Указал юнит-экономику.", + "Retention\nLTV", + spreadsheet.link, + None, + ), + ) + self.assertEqual( + data_row[5], + "Модуль 1: SQL basics / Урок 1: SELECT / Задание 5: Финальный вывод", + ) + + def test_course_admin_export_marks_completed_course_stage(self): + course = create_course(title="Экспорт аналитики") + module = create_module(course, title="Метрики", order=1) + lesson = create_lesson(module, title="Базовые метрики", order=1) + student = create_user(prefix="export-completed") + task = create_text_question_task( + lesson, + title="Что такое retention", + order=1, + ) + + submit_user_task_answer( + student, + task, + TaskAnswerSubmitPayload(answer_text="Retention отражает возврат пользователей."), + ) + recalculate_user_progresses_for_lesson(student, lesson) + + response = self.client.get( + reverse("admin:courses_export_results", args=[course.id]) + ) + rows = self._read_workbook_rows(response) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[1][2], 100) + self.assertEqual(rows[1][5], "Курс завершён") + self.assertEqual(rows[1][6], "Retention отражает возврат пользователей.") diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 75390aea..17709716 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -1,15 +1,14 @@ import re -import urllib.parse import tablib from django import forms from django.contrib import admin from django.db.models import QuerySet -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest from django.urls import path from django.utils import timezone -from core.utils import XlsxFileToExport, ascii_filename, sanitize_filename +from core.utils import XlsxFileToExport, build_xlsx_download_response from mailing.views import MailingTemplateRender from partner_programs.models import ( PartnerProgram, @@ -220,12 +219,7 @@ def get_export_file(self, partner_program: PartnerProgram): file_name = ( f"{partner_program.name} {timezone.now().strftime('%d-%m-%Y %H:%M:%S')}" ) - response = HttpResponse( - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - headers={"Content-Disposition": f'attachment; filename="{file_name}.xlsx"'}, - ) - response.write(binary_data) - return response + return build_xlsx_download_response(binary_data, base_name=file_name) def get_export_rates_view(self, request, object_id): rates_data_to_write: list[dict] = self._get_prepared_rates_data_for_export( @@ -240,19 +234,7 @@ def get_export_rates_view(self, request, object_id): program_name = PartnerProgram.objects.get(pk=object_id).name date_suffix = timezone.now().strftime("%d.%m.%y") base_name = f"scores - {program_name or 'program'} - {date_suffix}" - safe_name = sanitize_filename(base_name) - encoded_file_name: str = urllib.parse.quote(f"{safe_name}.xlsx") - fallback_filename = f"{ascii_filename(base_name)}.xlsx" - response = HttpResponse( - binary_data_to_export, - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ) - response["Content-Disposition"] = ( - "attachment; " - f"filename=\"{fallback_filename}\"; " - f"filename*=UTF-8''{encoded_file_name}" - ) - return response + return build_xlsx_download_response(binary_data_to_export, base_name=base_name) def _get_prepared_rates_data_for_export(self, program_id: int) -> list[dict]: """ diff --git a/partner_programs/services.py b/partner_programs/services.py index b75126b9..d758de6b 100644 --- a/partner_programs/services.py +++ b/partner_programs/services.py @@ -1,7 +1,6 @@ import logging from collections import OrderedDict -from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE from django.db.models import Prefetch from django.utils import timezone @@ -171,31 +170,6 @@ def get_project_scores_info(self) -> dict[str, str]: ("team_members", "Состав команды"), ("leader_full_name", "Имя фамилия лидера"), ] -EXCEL_CELL_MAX = 32767 # лимит символов в ячейке Excel - - -def sanitize_excel_value(value): - """ - Приводит значение к безопасному для openpyxl виду: - - None -> "" - - для строк: вычищает запрещённые символы, нормализует переносы строк, - и обрезает до лимита Excel (32767). - - для чисел/булевых оставляет как есть. - """ - if value is None: - return "" - - if isinstance(value, (int, float, bool)): - return value - - text = str(value) - text = text.replace("\r\n", "\n").replace("\r", "\n") - text = ILLEGAL_CHARACTERS_RE.sub(" ", text) - - if len(text) > EXCEL_CELL_MAX: - text = text[: EXCEL_CELL_MAX - 3] + "..." - - return text def _leader_full_name(user): diff --git a/partner_programs/views.py b/partner_programs/views.py index 5f16c713..e4c53cb0 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -1,10 +1,8 @@ import io -import urllib.parse from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction from django.db.models import Exists, OuterRef, Prefetch -from django.http import FileResponse, HttpResponse from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.timezone import now @@ -20,7 +18,11 @@ from core.serializers import EmptySerializer, SetLikedSerializer, SetViewedSerializer from core.services import add_view, set_like -from core.utils import XlsxFileToExport, ascii_filename, sanitize_filename +from core.utils import ( + XlsxFileToExport, + build_xlsx_download_response, + sanitize_excel_value, +) from partner_programs.helpers import date_to_iso from partner_programs.models import ( PartnerProgram, @@ -50,7 +52,6 @@ build_program_field_columns, prepare_project_scores_export_data, row_dict_for_link, - sanitize_excel_value, ) from partner_programs.utils import filter_program_projects_by_field_name from projects.models import Collaborator, Project @@ -655,20 +656,7 @@ def get(self, request, pk: int): date_suffix = timezone.now().strftime("%d.%m.%y") base_name = f"scores - {program.name or 'program'} - {date_suffix}" - safe_name = sanitize_filename(base_name) - filename = f"{safe_name}.xlsx" - encoded_file_name: str = urllib.parse.quote(filename) - fallback_filename = f"{ascii_filename(base_name)}.xlsx" - response = HttpResponse( - binary_data_to_export, - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ) - response["Content-Disposition"] = ( - "attachment; " - f"filename=\"{fallback_filename}\"; " - f"filename*=UTF-8''{encoded_file_name}" - ) - return response + return build_xlsx_download_response(binary_data_to_export, base_name=base_name) class PartnerProgramExportProjectsAPIView(APIView): @@ -732,23 +720,7 @@ def _export(self, program: PartnerProgram, only_submitted: bool): label = "projects_review" if only_submitted else "projects" date_suffix = timezone.now().strftime("%d.%m.%y") base_name = f"{label} - {program.name or 'program'} - {date_suffix}" - fname_base = sanitize_filename(base_name) - filename = f"{fname_base}.xlsx" - encoded_file_name: str = urllib.parse.quote(filename) - fallback_filename = f"{ascii_filename(base_name)}.xlsx" - - response = FileResponse( - bio, - as_attachment=True, - filename=filename, - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ) - response["Content-Disposition"] = ( - "attachment; " - f"filename=\"{fallback_filename}\"; " - f"filename*=UTF-8''{encoded_file_name}" - ) - return response + return build_xlsx_download_response(bio.getvalue(), base_name=base_name) def get(self, request, pk: int): program = self._get_program(pk) diff --git a/templates/courses/admin/course_change_form.html b/templates/courses/admin/course_change_form.html new file mode 100644 index 00000000..1f3646b9 --- /dev/null +++ b/templates/courses/admin/course_change_form.html @@ -0,0 +1,16 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block submit_buttons_bottom %} + {{ block.super }} +
+ +
+ +{% endblock %} diff --git a/users/admin.py b/users/admin.py index adf3c16b..ff993c4f 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,4 +1,3 @@ -import urllib.parse from datetime import date import tablib @@ -12,7 +11,7 @@ from django.utils.timezone import now from core.admin import SkillToObjectInline -from core.utils import XlsxFileToExport +from core.utils import XlsxFileToExport, build_xlsx_download_response from mailing.views import MailingTemplateRender from users.models import UserAchievementFile from users.services.users_activity import UserActivityDataPreparer @@ -296,16 +295,10 @@ def get_users_activity(self, _) -> HttpResponse: binary_data_to_export: bytes = xlsx_file_writer.get_binary_data_from_self_file() xlsx_file_writer.clear_buffer() - encoded_file_name: str = urllib.parse.quote("активность_пользователей.xlsx") - response = HttpResponse( + return build_xlsx_download_response( binary_data_to_export, - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + base_name="активность_пользователей", ) - response["Content-Disposition"] = ( - f"attachment; filename*=UTF-8''{encoded_file_name}" - ) - - return response def get_export_users_emails(self, users): response_data = tablib.Dataset( @@ -396,13 +389,7 @@ def get_export_users_emails(self, users): # response_data.append([user.first_name + " " + user.last_name, user.email]) binary_data = response_data.export("xlsx") - file_name = "users" - response = HttpResponse( - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - headers={"Content-Disposition": f'attachment; filename="{file_name}.xlsx"'}, - ) - response.write(binary_data) - return response + return build_xlsx_download_response(binary_data, base_name="users") class UserAchievementFileInline(admin.TabularInline): diff --git a/vacancy/admin.py b/vacancy/admin.py index 9245d43c..2d5a6b6b 100644 --- a/vacancy/admin.py +++ b/vacancy/admin.py @@ -1,9 +1,9 @@ import tablib from django.contrib import admin -from django.http import HttpResponse from django.urls import path from core.admin import SkillToObjectInline +from core.utils import build_xlsx_download_response from vacancy.models import Vacancy, VacancyResponse @@ -64,13 +64,10 @@ def excel_email_leaders_vacancies(self, data: list): response_data.append(row_to_add) binary_data = response_data.export("xlsx") - file_name = "email_of_leaders_with_users" - response = HttpResponse( - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - headers={"Content-Disposition": f'attachment; filename="{file_name}.xlsx"'}, + return build_xlsx_download_response( + binary_data, + base_name="email_of_leaders_with_users", ) - response.write(binary_data) - return response @admin.register(VacancyResponse)