Skip to content

Commit a00a7e7

Browse files
authored
Merge pull request #589 from PROCOLLAB-github/feature/partner-program-exports
Реализован экспорт данных о проектах и их оценках в xlsx формате
2 parents 8ab5fab + 7f0307c commit a00a7e7

7 files changed

Lines changed: 298 additions & 171 deletions

File tree

core/utils.py

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import os
21
import logging
2+
import io
3+
import unicodedata
34
import pandas as pd
45

56
from django.core.mail import EmailMultiAlternatives
@@ -35,32 +36,55 @@ def get_users_online_cache_key() -> str:
3536

3637
class XlsxFileToExport:
3738
"""
38-
Writing data to `xlsx` file.
39-
`filename` must contain `.xlsx` format prefix.
40-
All data on 1 page.
39+
Формирует XLSX в памяти.
40+
`filename` сохранён для совместимости, но не используется для записи на диск.
41+
Все данные пишутся на один лист.
4142
"""
4243

4344
def __init__(self, filename="output.xlsx"):
4445
self.filename = filename
46+
self._buffer = None
4547

4648
def write_data_to_xlsx(self, data: list[dict], sheet_name: str = "scores") -> None:
4749
try:
4850
data_frames = pd.DataFrame(data)
49-
with pd.ExcelWriter(self.filename) as writer:
51+
buffer = io.BytesIO()
52+
with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
5053
data_frames.to_excel(writer, sheet_name=sheet_name, index=False)
54+
buffer.seek(0)
55+
self._buffer = buffer
5156
except Exception as e:
5257
logger.error(f"Write export rates data error: {str(e)}", exc_info=True)
5358
raise
5459

5560
def get_binary_data_from_self_file(self) -> bytes:
5661
try:
57-
with open(self.filename, "rb") as f:
58-
binary_data = f.read()
59-
return binary_data
62+
if not self._buffer:
63+
raise ValueError("XLSX buffer is empty")
64+
return self._buffer.getvalue()
6065
except Exception as e:
6166
logger.error(f"Read export rates data error: {str(e)}", exc_info=True)
6267
raise
6368

64-
def delete_self_xlsx_file_from_local_machine(self) -> None:
65-
if os.path.isfile(self.filename) and self.filename.endswith(".xlsx"):
66-
os.remove(self.filename)
69+
def clear_buffer(self) -> None:
70+
if self._buffer:
71+
self._buffer.close()
72+
self._buffer = None
73+
74+
75+
def sanitize_filename(filename: str) -> str:
76+
normalized_name = unicodedata.normalize("NFKD", filename)
77+
safe_chars = [
78+
char
79+
for char in normalized_name
80+
if char.isalnum() or char in ("-", "_", " ", ".")
81+
]
82+
cleaned_name = "".join(safe_chars)
83+
return " ".join(cleaned_name.split())
84+
85+
86+
def ascii_filename(filename: str) -> str:
87+
safe_name = sanitize_filename(filename)
88+
ascii_name = "".join(char if char.isascii() else "_" for char in safe_name)
89+
ascii_name = " ".join(ascii_name.split())
90+
return ascii_name or "export"

partner_programs/admin.py

Lines changed: 15 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
import tablib
55
from django import forms
66
from django.contrib import admin
7-
from django.db.models import Prefetch, QuerySet
7+
from django.db.models import QuerySet
88
from django.http import HttpRequest, HttpResponse
99
from django.urls import path
1010
from django.utils import timezone
1111

12-
from core.utils import XlsxFileToExport
12+
from core.utils import XlsxFileToExport, ascii_filename, sanitize_filename
1313
from mailing.views import MailingTemplateRender
1414
from partner_programs.models import (
1515
PartnerProgram,
@@ -19,7 +19,7 @@
1919
PartnerProgramProject,
2020
PartnerProgramUserProfile,
2121
)
22-
from project_rates.models import Criteria, ProjectScore
22+
from partner_programs.services import prepare_project_scores_export_data
2323

2424

2525
class PartnerProgramMaterialInline(admin.StackedInline):
@@ -234,18 +234,22 @@ def get_export_rates_view(self, request, object_id):
234234
xlsx_file_writer = XlsxFileToExport()
235235
xlsx_file_writer.write_data_to_xlsx(rates_data_to_write)
236236
binary_data_to_export: bytes = xlsx_file_writer.get_binary_data_from_self_file()
237-
xlsx_file_writer.delete_self_xlsx_file_from_local_machine()
238-
239-
encoded_file_name: str = urllib.parse.quote(
240-
f"{PartnerProgram.objects.get(pk=object_id).name}_оценки {timezone.now().strftime('%d-%m-%Y %H:%M:%S')}"
241-
f".xlsx"
242-
)
237+
xlsx_file_writer.clear_buffer()
238+
239+
program_name = PartnerProgram.objects.get(pk=object_id).name
240+
date_suffix = timezone.now().strftime("%d.%m.%y")
241+
base_name = f"scores - {program_name or 'program'} - {date_suffix}"
242+
safe_name = sanitize_filename(base_name)
243+
encoded_file_name: str = urllib.parse.quote(f"{safe_name}.xlsx")
244+
fallback_filename = f"{ascii_filename(base_name)}.xlsx"
243245
response = HttpResponse(
244246
binary_data_to_export,
245247
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
246248
)
247249
response["Content-Disposition"] = (
248-
f"attachment; filename*=UTF-8''{encoded_file_name}"
250+
"attachment; "
251+
f"filename=\"{fallback_filename}\"; "
252+
f"filename*=UTF-8''{encoded_file_name}"
249253
)
250254
return response
251255

@@ -256,119 +260,7 @@ def _get_prepared_rates_data_for_export(self, program_id: int) -> list[dict]:
256260
критерии → комментарий.
257261
Если у проекта несколько экспертов, на каждый проект-эксперт создаётся отдельная строка.
258262
"""
259-
criterias = list(
260-
Criteria.objects.filter(partner_program__id=program_id)
261-
.select_related("partner_program")
262-
.order_by("id")
263-
)
264-
if not criterias:
265-
return []
266-
267-
comment_criteria = next(
268-
(criteria for criteria in criterias if criteria.name == "Комментарий"),
269-
None,
270-
)
271-
criterias_without_comment = [
272-
criteria for criteria in criterias if criteria != comment_criteria
273-
]
274-
275-
program_fields = list(
276-
PartnerProgramField.objects.filter(partner_program_id=program_id).order_by(
277-
"id"
278-
)
279-
)
280-
281-
scores = (
282-
ProjectScore.objects.filter(criteria__in=criterias)
283-
.select_related("user", "criteria", "project")
284-
.order_by("project_id", "criteria_id", "id")
285-
)
286-
scores_dict: dict[int, list[ProjectScore]] = {}
287-
for score in scores:
288-
scores_dict.setdefault(score.project_id, []).append(score)
289-
290-
if not scores_dict:
291-
empty_row: dict[str, str] = {
292-
"Название проекта": "",
293-
"Фамилия эксперта": "",
294-
}
295-
for field in program_fields:
296-
empty_row[field.label] = ""
297-
for criteria in criterias_without_comment:
298-
empty_row[criteria.name] = ""
299-
if comment_criteria:
300-
empty_row["Комментарий"] = ""
301-
return [empty_row]
302-
303-
project_ids = list(scores_dict.keys())
304-
305-
field_values_prefetch = Prefetch(
306-
"field_values",
307-
queryset=PartnerProgramFieldValue.objects.select_related("field").filter(
308-
program_project__partner_program_id=program_id,
309-
program_project__project_id__in=project_ids,
310-
),
311-
to_attr="_prefetched_field_values",
312-
)
313-
program_projects = (
314-
PartnerProgramProject.objects.filter(
315-
partner_program_id=program_id, project_id__in=project_ids
316-
)
317-
.select_related("project")
318-
.prefetch_related(field_values_prefetch)
319-
)
320-
program_project_by_project_id: dict[int, PartnerProgramProject] = {
321-
link.project_id: link for link in program_projects
322-
}
323-
324-
prepared_projects_rates_data: list[dict] = []
325-
for project_id, project_scores in scores_dict.items():
326-
project_link = program_project_by_project_id.get(project_id)
327-
project = (
328-
project_link.project
329-
if project_link
330-
else (project_scores[0].project if project_scores else None)
331-
)
332-
333-
field_values_map: dict[int, str] = {}
334-
field_values = (
335-
getattr(project_link, "_prefetched_field_values", None)
336-
if project_link
337-
else None
338-
)
339-
if field_values:
340-
for field_value in field_values:
341-
field_values_map[field_value.field_id] = field_value.get_value()
342-
343-
scores_by_expert: dict[int, list[ProjectScore]] = {}
344-
for score in project_scores:
345-
scores_by_expert.setdefault(score.user_id, []).append(score)
346-
347-
for _, expert_scores in scores_by_expert.items():
348-
row_data: dict[str, str] = {}
349-
row_data["Название проекта"] = (
350-
getattr(project, "name", "") if project else ""
351-
)
352-
row_data["Фамилия эксперта"] = (
353-
expert_scores[0].user.last_name if expert_scores else ""
354-
)
355-
356-
for field in program_fields:
357-
row_data[field.label] = field_values_map.get(field.id, "")
358-
359-
scores_map: dict[int, str] = {
360-
score.criteria_id: score.value for score in expert_scores
361-
}
362-
363-
for criteria in criterias_without_comment:
364-
row_data[criteria.name] = scores_map.get(criteria.id, "")
365-
366-
if comment_criteria:
367-
row_data["Комментарий"] = scores_map.get(comment_criteria.id, "")
368-
369-
prepared_projects_rates_data.append(row_data)
370-
371-
return prepared_projects_rates_data
263+
return prepare_project_scores_export_data(program_id)
372264

373265

374266
@admin.register(PartnerProgramUserProfile)

partner_programs/permissions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,28 @@ def has_permission(self, request, view):
3030
return True
3131

3232
return program.experts.filter(user=request.user).exists()
33+
34+
35+
class IsAdminOrManagerOfProgram(BasePermission):
36+
"""
37+
Доступ разрешён только админам и менеджерам конкретной программы.
38+
"""
39+
40+
def has_permission(self, request, view):
41+
user = request.user
42+
if not user or not user.is_authenticated:
43+
return False
44+
45+
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
46+
return True
47+
48+
program_id = view.kwargs.get("pk") or view.kwargs.get("program_id")
49+
if not program_id:
50+
return False
51+
52+
try:
53+
program = PartnerProgram.objects.get(pk=program_id)
54+
except PartnerProgram.DoesNotExist:
55+
return False
56+
57+
return program.is_manager(user)

0 commit comments

Comments
 (0)