Skip to content

Commit 89306e7

Browse files
committed
refactor(core): вынесены общие excel-хелперы в core и переиспользованы в courses, partner_programs, users и vacancy
1 parent 4ff04a2 commit 89306e7

7 files changed

Lines changed: 57 additions & 142 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/services/export_course_results.py

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import io
2-
import urllib.parse
32
from collections import defaultdict
43
from zoneinfo import ZoneInfo
54

6-
from django.http import HttpResponse
75
from django.utils import timezone
86
from django.db.models import Prefetch
97
from openpyxl import Workbook
10-
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
118

12-
from core.utils import ascii_filename, sanitize_filename
9+
from core.utils import build_xlsx_download_response, sanitize_excel_value
1310
from courses.models import (
1411
Course,
1512
CourseLessonContentStatus,
@@ -27,7 +24,6 @@
2724

2825

2926
MSK_TZ = ZoneInfo("Europe/Moscow")
30-
EXCEL_CELL_MAX = 32767
3127
BASE_HEADERS = (
3228
"Имя и Фамилия",
3329
"Email",
@@ -38,19 +34,6 @@
3834
)
3935

4036

41-
def sanitize_excel_value(value):
42-
if value is None:
43-
return ""
44-
if isinstance(value, (int, float, bool)):
45-
return value
46-
47-
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
48-
text = ILLEGAL_CHARACTERS_RE.sub(" ", text)
49-
if len(text) > EXCEL_CELL_MAX:
50-
text = text[: EXCEL_CELL_MAX - 3] + "..."
51-
return text
52-
53-
5437
def _format_msk_datetime(value) -> str:
5538
if value is None:
5639
return ""
@@ -256,22 +239,9 @@ def build_course_results_workbook_bytes(course: Course) -> bytes:
256239
return buffer.getvalue()
257240

258241

259-
def build_course_results_export_response(course: Course) -> HttpResponse:
242+
def build_course_results_export_response(course: Course):
260243
binary_data = build_course_results_workbook_bytes(course)
261244

262245
date_suffix = timezone.now().astimezone(MSK_TZ).strftime("%d.%m.%Y")
263246
base_name = f"course-results - {course.title} - {date_suffix}"
264-
safe_name = sanitize_filename(base_name)
265-
encoded_file_name = urllib.parse.quote(f"{safe_name}.xlsx")
266-
fallback_filename = f"{ascii_filename(base_name)}.xlsx"
267-
268-
response = HttpResponse(
269-
binary_data,
270-
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
271-
)
272-
response["Content-Disposition"] = (
273-
"attachment; "
274-
f"filename=\"{fallback_filename}\"; "
275-
f"filename*=UTF-8''{encoded_file_name}"
276-
)
277-
return response
247+
return build_xlsx_download_response(binary_data, base_name=base_name)

partner_programs/admin.py

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import re
2-
import urllib.parse
32

43
import tablib
54
from django import forms
65
from django.contrib import admin
76
from django.db.models import QuerySet
8-
from django.http import HttpRequest, HttpResponse
7+
from django.http import HttpRequest
98
from django.urls import path
109
from django.utils import timezone
1110

12-
from core.utils import XlsxFileToExport, ascii_filename, sanitize_filename
11+
from core.utils import XlsxFileToExport, build_xlsx_download_response
1312
from mailing.views import MailingTemplateRender
1413
from partner_programs.models import (
1514
PartnerProgram,
@@ -220,12 +219,7 @@ def get_export_file(self, partner_program: PartnerProgram):
220219
file_name = (
221220
f"{partner_program.name} {timezone.now().strftime('%d-%m-%Y %H:%M:%S')}"
222221
)
223-
response = HttpResponse(
224-
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
225-
headers={"Content-Disposition": f'attachment; filename="{file_name}.xlsx"'},
226-
)
227-
response.write(binary_data)
228-
return response
222+
return build_xlsx_download_response(binary_data, base_name=file_name)
229223

230224
def get_export_rates_view(self, request, object_id):
231225
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):
240234
program_name = PartnerProgram.objects.get(pk=object_id).name
241235
date_suffix = timezone.now().strftime("%d.%m.%y")
242236
base_name = f"scores - {program_name or 'program'} - {date_suffix}"
243-
safe_name = sanitize_filename(base_name)
244-
encoded_file_name: str = urllib.parse.quote(f"{safe_name}.xlsx")
245-
fallback_filename = f"{ascii_filename(base_name)}.xlsx"
246-
response = HttpResponse(
247-
binary_data_to_export,
248-
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
249-
)
250-
response["Content-Disposition"] = (
251-
"attachment; "
252-
f"filename=\"{fallback_filename}\"; "
253-
f"filename*=UTF-8''{encoded_file_name}"
254-
)
255-
return response
237+
return build_xlsx_download_response(binary_data_to_export, base_name=base_name)
256238

257239
def _get_prepared_rates_data_for_export(self, program_id: int) -> list[dict]:
258240
"""

partner_programs/services.py

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import logging
22
from collections import OrderedDict
33

4-
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
54
from django.db.models import Prefetch
65
from django.utils import timezone
76

7+
from core.utils import sanitize_excel_value
88
from partner_programs.models import (
99
PartnerProgram,
1010
PartnerProgramField,
@@ -171,33 +171,6 @@ def get_project_scores_info(self) -> dict[str, str]:
171171
("team_members", "Состав команды"),
172172
("leader_full_name", "Имя фамилия лидера"),
173173
]
174-
EXCEL_CELL_MAX = 32767 # лимит символов в ячейке Excel
175-
176-
177-
def sanitize_excel_value(value):
178-
"""
179-
Приводит значение к безопасному для openpyxl виду:
180-
- None -> ""
181-
- для строк: вычищает запрещённые символы, нормализует переносы строк,
182-
и обрезает до лимита Excel (32767).
183-
- для чисел/булевых оставляет как есть.
184-
"""
185-
if value is None:
186-
return ""
187-
188-
if isinstance(value, (int, float, bool)):
189-
return value
190-
191-
text = str(value)
192-
text = text.replace("\r\n", "\n").replace("\r", "\n")
193-
text = ILLEGAL_CHARACTERS_RE.sub(" ", text)
194-
195-
if len(text) > EXCEL_CELL_MAX:
196-
text = text[: EXCEL_CELL_MAX - 3] + "..."
197-
198-
return text
199-
200-
201174
def _leader_full_name(user):
202175
if not user:
203176
return ""

partner_programs/views.py

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import io
2-
import urllib.parse
32

43
from django.contrib.auth import get_user_model
54
from django.db import IntegrityError, transaction
65
from django.db.models import Exists, OuterRef, Prefetch
7-
from django.http import FileResponse, HttpResponse
86
from django.shortcuts import get_object_or_404
97
from django.utils import timezone
108
from django.utils.timezone import now
@@ -20,7 +18,11 @@
2018

2119
from core.serializers import EmptySerializer, SetLikedSerializer, SetViewedSerializer
2220
from core.services import add_view, set_like
23-
from core.utils import XlsxFileToExport, ascii_filename, sanitize_filename
21+
from core.utils import (
22+
XlsxFileToExport,
23+
build_xlsx_download_response,
24+
sanitize_excel_value,
25+
)
2426
from partner_programs.helpers import date_to_iso
2527
from partner_programs.models import (
2628
PartnerProgram,
@@ -50,7 +52,6 @@
5052
build_program_field_columns,
5153
prepare_project_scores_export_data,
5254
row_dict_for_link,
53-
sanitize_excel_value,
5455
)
5556
from partner_programs.utils import filter_program_projects_by_field_name
5657
from projects.models import Collaborator, Project
@@ -655,20 +656,7 @@ def get(self, request, pk: int):
655656

656657
date_suffix = timezone.now().strftime("%d.%m.%y")
657658
base_name = f"scores - {program.name or 'program'} - {date_suffix}"
658-
safe_name = sanitize_filename(base_name)
659-
filename = f"{safe_name}.xlsx"
660-
encoded_file_name: str = urllib.parse.quote(filename)
661-
fallback_filename = f"{ascii_filename(base_name)}.xlsx"
662-
response = HttpResponse(
663-
binary_data_to_export,
664-
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
665-
)
666-
response["Content-Disposition"] = (
667-
"attachment; "
668-
f"filename=\"{fallback_filename}\"; "
669-
f"filename*=UTF-8''{encoded_file_name}"
670-
)
671-
return response
659+
return build_xlsx_download_response(binary_data_to_export, base_name=base_name)
672660

673661

674662
class PartnerProgramExportProjectsAPIView(APIView):
@@ -732,23 +720,7 @@ def _export(self, program: PartnerProgram, only_submitted: bool):
732720
label = "projects_review" if only_submitted else "projects"
733721
date_suffix = timezone.now().strftime("%d.%m.%y")
734722
base_name = f"{label} - {program.name or 'program'} - {date_suffix}"
735-
fname_base = sanitize_filename(base_name)
736-
filename = f"{fname_base}.xlsx"
737-
encoded_file_name: str = urllib.parse.quote(filename)
738-
fallback_filename = f"{ascii_filename(base_name)}.xlsx"
739-
740-
response = FileResponse(
741-
bio,
742-
as_attachment=True,
743-
filename=filename,
744-
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
745-
)
746-
response["Content-Disposition"] = (
747-
"attachment; "
748-
f"filename=\"{fallback_filename}\"; "
749-
f"filename*=UTF-8''{encoded_file_name}"
750-
)
751-
return response
723+
return build_xlsx_download_response(bio.getvalue(), base_name=base_name)
752724

753725
def get(self, request, pk: int):
754726
program = self._get_program(pk)

users/admin.py

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import urllib.parse
21
from datetime import date
32

43
import tablib
@@ -12,7 +11,7 @@
1211
from django.utils.timezone import now
1312

1413
from core.admin import SkillToObjectInline
15-
from core.utils import XlsxFileToExport
14+
from core.utils import XlsxFileToExport, build_xlsx_download_response
1615
from mailing.views import MailingTemplateRender
1716
from users.models import UserAchievementFile
1817
from users.services.users_activity import UserActivityDataPreparer
@@ -296,16 +295,10 @@ def get_users_activity(self, _) -> HttpResponse:
296295
binary_data_to_export: bytes = xlsx_file_writer.get_binary_data_from_self_file()
297296
xlsx_file_writer.clear_buffer()
298297

299-
encoded_file_name: str = urllib.parse.quote("активность_пользователей.xlsx")
300-
response = HttpResponse(
298+
return build_xlsx_download_response(
301299
binary_data_to_export,
302-
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
300+
base_name="активность_пользователей",
303301
)
304-
response["Content-Disposition"] = (
305-
f"attachment; filename*=UTF-8''{encoded_file_name}"
306-
)
307-
308-
return response
309302

310303
def get_export_users_emails(self, users):
311304
response_data = tablib.Dataset(
@@ -396,13 +389,7 @@ def get_export_users_emails(self, users):
396389
# response_data.append([user.first_name + " " + user.last_name, user.email])
397390

398391
binary_data = response_data.export("xlsx")
399-
file_name = "users"
400-
response = HttpResponse(
401-
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
402-
headers={"Content-Disposition": f'attachment; filename="{file_name}.xlsx"'},
403-
)
404-
response.write(binary_data)
405-
return response
392+
return build_xlsx_download_response(binary_data, base_name="users")
406393

407394

408395
class UserAchievementFileInline(admin.TabularInline):

vacancy/admin.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import tablib
22
from django.contrib import admin
3-
from django.http import HttpResponse
43
from django.urls import path
54

65
from core.admin import SkillToObjectInline
6+
from core.utils import build_xlsx_download_response
77
from vacancy.models import Vacancy, VacancyResponse
88

99

@@ -64,13 +64,10 @@ def excel_email_leaders_vacancies(self, data: list):
6464
response_data.append(row_to_add)
6565

6666
binary_data = response_data.export("xlsx")
67-
file_name = "email_of_leaders_with_users"
68-
response = HttpResponse(
69-
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
70-
headers={"Content-Disposition": f'attachment; filename="{file_name}.xlsx"'},
67+
return build_xlsx_download_response(
68+
binary_data,
69+
base_name="email_of_leaders_with_users",
7170
)
72-
response.write(binary_data)
73-
return response
7471

7572

7673
@admin.register(VacancyResponse)

0 commit comments

Comments
 (0)