diff --git a/core/utils.py b/core/utils.py index 12e32311..018e1862 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,5 +1,6 @@ -import os import logging +import io +import unicodedata import pandas as pd from django.core.mail import EmailMultiAlternatives @@ -35,32 +36,55 @@ def get_users_online_cache_key() -> str: class XlsxFileToExport: """ - Writing data to `xlsx` file. - `filename` must contain `.xlsx` format prefix. - All data on 1 page. + Формирует XLSX в памяти. + `filename` сохранён для совместимости, но не используется для записи на диск. + Все данные пишутся на один лист. """ def __init__(self, filename="output.xlsx"): self.filename = filename + self._buffer = None def write_data_to_xlsx(self, data: list[dict], sheet_name: str = "scores") -> None: try: data_frames = pd.DataFrame(data) - with pd.ExcelWriter(self.filename) as writer: + buffer = io.BytesIO() + with pd.ExcelWriter(buffer, engine="openpyxl") as writer: data_frames.to_excel(writer, sheet_name=sheet_name, index=False) + buffer.seek(0) + self._buffer = buffer except Exception as e: logger.error(f"Write export rates data error: {str(e)}", exc_info=True) raise def get_binary_data_from_self_file(self) -> bytes: try: - with open(self.filename, "rb") as f: - binary_data = f.read() - return binary_data + if not self._buffer: + raise ValueError("XLSX buffer is empty") + return self._buffer.getvalue() except Exception as e: logger.error(f"Read export rates data error: {str(e)}", exc_info=True) raise - def delete_self_xlsx_file_from_local_machine(self) -> None: - if os.path.isfile(self.filename) and self.filename.endswith(".xlsx"): - os.remove(self.filename) + def clear_buffer(self) -> None: + if self._buffer: + self._buffer.close() + self._buffer = None + + +def sanitize_filename(filename: str) -> str: + normalized_name = unicodedata.normalize("NFKD", filename) + safe_chars = [ + char + for char in normalized_name + if char.isalnum() or char in ("-", "_", " ", ".") + ] + cleaned_name = "".join(safe_chars) + return " ".join(cleaned_name.split()) + + +def ascii_filename(filename: str) -> str: + safe_name = sanitize_filename(filename) + ascii_name = "".join(char if char.isascii() else "_" for char in safe_name) + ascii_name = " ".join(ascii_name.split()) + return ascii_name or "export" diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 5144210b..28a7f4f2 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -4,12 +4,12 @@ import tablib from django import forms from django.contrib import admin -from django.db.models import Prefetch, QuerySet +from django.db.models import QuerySet from django.http import HttpRequest, HttpResponse from django.urls import path from django.utils import timezone -from core.utils import XlsxFileToExport +from core.utils import XlsxFileToExport, ascii_filename, sanitize_filename from mailing.views import MailingTemplateRender from partner_programs.models import ( PartnerProgram, @@ -19,7 +19,7 @@ PartnerProgramProject, PartnerProgramUserProfile, ) -from project_rates.models import Criteria, ProjectScore +from partner_programs.services import prepare_project_scores_export_data class PartnerProgramMaterialInline(admin.StackedInline): @@ -234,18 +234,22 @@ def get_export_rates_view(self, request, object_id): xlsx_file_writer = XlsxFileToExport() xlsx_file_writer.write_data_to_xlsx(rates_data_to_write) binary_data_to_export: bytes = xlsx_file_writer.get_binary_data_from_self_file() - xlsx_file_writer.delete_self_xlsx_file_from_local_machine() - - encoded_file_name: str = urllib.parse.quote( - f"{PartnerProgram.objects.get(pk=object_id).name}_оценки {timezone.now().strftime('%d-%m-%Y %H:%M:%S')}" - f".xlsx" - ) + xlsx_file_writer.clear_buffer() + + 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"] = ( - f"attachment; filename*=UTF-8''{encoded_file_name}" + "attachment; " + f"filename=\"{fallback_filename}\"; " + f"filename*=UTF-8''{encoded_file_name}" ) return response @@ -256,119 +260,7 @@ def _get_prepared_rates_data_for_export(self, program_id: int) -> list[dict]: критерии → комментарий. Если у проекта несколько экспертов, на каждый проект-эксперт создаётся отдельная строка. """ - criterias = list( - Criteria.objects.filter(partner_program__id=program_id) - .select_related("partner_program") - .order_by("id") - ) - if not criterias: - return [] - - comment_criteria = next( - (criteria for criteria in criterias if criteria.name == "Комментарий"), - None, - ) - criterias_without_comment = [ - criteria for criteria in criterias if criteria != comment_criteria - ] - - program_fields = list( - PartnerProgramField.objects.filter(partner_program_id=program_id).order_by( - "id" - ) - ) - - scores = ( - ProjectScore.objects.filter(criteria__in=criterias) - .select_related("user", "criteria", "project") - .order_by("project_id", "criteria_id", "id") - ) - scores_dict: dict[int, list[ProjectScore]] = {} - for score in scores: - scores_dict.setdefault(score.project_id, []).append(score) - - if not scores_dict: - empty_row: dict[str, str] = { - "Название проекта": "", - "Фамилия эксперта": "", - } - for field in program_fields: - empty_row[field.label] = "" - for criteria in criterias_without_comment: - empty_row[criteria.name] = "" - if comment_criteria: - empty_row["Комментарий"] = "" - return [empty_row] - - project_ids = list(scores_dict.keys()) - - field_values_prefetch = Prefetch( - "field_values", - queryset=PartnerProgramFieldValue.objects.select_related("field").filter( - program_project__partner_program_id=program_id, - program_project__project_id__in=project_ids, - ), - to_attr="_prefetched_field_values", - ) - program_projects = ( - PartnerProgramProject.objects.filter( - partner_program_id=program_id, project_id__in=project_ids - ) - .select_related("project") - .prefetch_related(field_values_prefetch) - ) - program_project_by_project_id: dict[int, PartnerProgramProject] = { - link.project_id: link for link in program_projects - } - - prepared_projects_rates_data: list[dict] = [] - for project_id, project_scores in scores_dict.items(): - project_link = program_project_by_project_id.get(project_id) - project = ( - project_link.project - if project_link - else (project_scores[0].project if project_scores else None) - ) - - field_values_map: dict[int, str] = {} - field_values = ( - getattr(project_link, "_prefetched_field_values", None) - if project_link - else None - ) - if field_values: - for field_value in field_values: - field_values_map[field_value.field_id] = field_value.get_value() - - scores_by_expert: dict[int, list[ProjectScore]] = {} - for score in project_scores: - scores_by_expert.setdefault(score.user_id, []).append(score) - - for _, expert_scores in scores_by_expert.items(): - row_data: dict[str, str] = {} - row_data["Название проекта"] = ( - getattr(project, "name", "") if project else "" - ) - row_data["Фамилия эксперта"] = ( - expert_scores[0].user.last_name if expert_scores else "" - ) - - for field in program_fields: - row_data[field.label] = field_values_map.get(field.id, "") - - scores_map: dict[int, str] = { - score.criteria_id: score.value for score in expert_scores - } - - for criteria in criterias_without_comment: - row_data[criteria.name] = scores_map.get(criteria.id, "") - - if comment_criteria: - row_data["Комментарий"] = scores_map.get(comment_criteria.id, "") - - prepared_projects_rates_data.append(row_data) - - return prepared_projects_rates_data + return prepare_project_scores_export_data(program_id) @admin.register(PartnerProgramUserProfile) diff --git a/partner_programs/permissions.py b/partner_programs/permissions.py index ec2bc4c7..33c6a28c 100644 --- a/partner_programs/permissions.py +++ b/partner_programs/permissions.py @@ -30,3 +30,28 @@ def has_permission(self, request, view): return True return program.experts.filter(user=request.user).exists() + + +class IsAdminOrManagerOfProgram(BasePermission): + """ + Доступ разрешён только админам и менеджерам конкретной программы. + """ + + def has_permission(self, request, view): + user = request.user + if not user or not user.is_authenticated: + return False + + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + + program_id = view.kwargs.get("pk") or view.kwargs.get("program_id") + if not program_id: + return False + + try: + program = PartnerProgram.objects.get(pk=program_id) + except PartnerProgram.DoesNotExist: + return False + + return program.is_manager(user) diff --git a/partner_programs/services.py b/partner_programs/services.py index 7a6ea0fb..9beb5e57 100644 --- a/partner_programs/services.py +++ b/partner_programs/services.py @@ -2,8 +2,14 @@ from collections import OrderedDict from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE - -from partner_programs.models import PartnerProgramUserProfile +from django.db.models import Prefetch + +from partner_programs.models import ( + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, + PartnerProgramUserProfile, +) from project_rates.models import Criteria, ProjectScore logger = logging.getLogger() @@ -274,3 +280,123 @@ def row_dict_for_link( row[field_key] = values_map.get(field_key, "") return row + + +def prepare_project_scores_export_data(program_id: int) -> list[dict]: + """ + Готовит данные для выгрузки оценок проектов. + Порядок колонок: название проекта → фамилия эксперта → доп. поля программы → + критерии → комментарий. + Если у проекта несколько экспертов, на каждый проект-эксперт создаётся отдельная строка. + """ + criterias = list( + Criteria.objects.filter(partner_program__id=program_id) + .select_related("partner_program") + .order_by("id") + ) + if not criterias: + return [] + + comment_criteria = next( + (criteria for criteria in criterias if criteria.name == "Комментарий"), + None, + ) + criterias_without_comment = [ + criteria for criteria in criterias if criteria != comment_criteria + ] + + program_fields = list( + PartnerProgramField.objects.filter(partner_program_id=program_id).order_by("id") + ) + + scores = ( + ProjectScore.objects.filter(criteria__in=criterias) + .select_related("user", "criteria", "project") + .order_by("project_id", "criteria_id", "id") + ) + scores_dict: dict[int, list[ProjectScore]] = {} + for score in scores: + scores_dict.setdefault(score.project_id, []).append(score) + + if not scores_dict: + empty_row: dict[str, str] = { + "Название проекта": "", + "Фамилия эксперта": "", + } + for field in program_fields: + empty_row[field.label] = "" + for criteria in criterias_without_comment: + empty_row[criteria.name] = "" + if comment_criteria: + empty_row["Комментарий"] = "" + return [empty_row] + + project_ids = list(scores_dict.keys()) + + field_values_prefetch = Prefetch( + "field_values", + queryset=PartnerProgramFieldValue.objects.select_related("field").filter( + program_project__partner_program_id=program_id, + program_project__project_id__in=project_ids, + ), + to_attr="_prefetched_field_values", + ) + program_projects = ( + PartnerProgramProject.objects.filter( + partner_program_id=program_id, project_id__in=project_ids + ) + .select_related("project") + .prefetch_related(field_values_prefetch) + ) + program_project_by_project_id: dict[int, PartnerProgramProject] = { + link.project_id: link for link in program_projects + } + + prepared_projects_rates_data: list[dict] = [] + for project_id, project_scores in scores_dict.items(): + project_link = program_project_by_project_id.get(project_id) + project = ( + project_link.project + if project_link + else (project_scores[0].project if project_scores else None) + ) + + field_values_map: dict[int, str] = {} + field_values = ( + getattr(project_link, "_prefetched_field_values", None) + if project_link + else None + ) + if field_values: + for field_value in field_values: + field_values_map[field_value.field_id] = field_value.get_value() + + scores_by_expert: dict[int, list[ProjectScore]] = {} + for score in project_scores: + scores_by_expert.setdefault(score.user_id, []).append(score) + + for _, expert_scores in scores_by_expert.items(): + row_data: dict[str, str] = {} + row_data["Название проекта"] = ( + getattr(project, "name", "") if project else "" + ) + row_data["Фамилия эксперта"] = ( + expert_scores[0].user.last_name if expert_scores else "" + ) + + for field in program_fields: + row_data[field.label] = field_values_map.get(field.id, "") + + scores_map: dict[int, str] = { + score.criteria_id: score.value for score in expert_scores + } + + for criteria in criterias_without_comment: + row_data[criteria.name] = scores_map.get(criteria.id, "") + + if comment_criteria: + row_data["Комментарий"] = scores_map.get(comment_criteria.id, "") + + prepared_projects_rates_data.append(row_data) + + return prepared_projects_rates_data diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 92403184..41313a82 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -6,6 +6,7 @@ PartnerProgramDataSchema, PartnerProgramDetail, PartnerProgramExportProjectsAPIView, + PartnerProgramExportRatesAPIView, PartnerProgramList, PartnerProgramProjectApplyView, PartnerProgramProjectsAPIView, @@ -62,4 +63,9 @@ PartnerProgramExportProjectsAPIView.as_view(), name="partner-program-export-projects", ), + path( + "/export-rates/", + PartnerProgramExportRatesAPIView.as_view(), + name="partner-program-export-rates", + ), ] diff --git a/partner_programs/views.py b/partner_programs/views.py index 7e5d6634..fa6a7728 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -1,11 +1,10 @@ import io -import unicodedata -from datetime import date +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 +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 @@ -21,6 +20,7 @@ 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 partner_programs.helpers import date_to_iso from partner_programs.models import ( PartnerProgram, @@ -30,7 +30,10 @@ PartnerProgramUserProfile, ) from partner_programs.pagination import PartnerProgramPagination -from partner_programs.permissions import IsExpertOrManagerOfProgram, IsProjectLeader +from partner_programs.permissions import ( + IsAdminOrManagerOfProgram, + IsProjectLeader, +) from partner_programs.serializers import ( PartnerProgramDataSchemaSerializer, PartnerProgramFieldSerializer, @@ -45,6 +48,7 @@ from partner_programs.services import ( BASE_COLUMNS, build_program_field_columns, + prepare_project_scores_export_data, row_dict_for_link, sanitize_excel_value, ) @@ -521,7 +525,7 @@ def post(self, request, pk, *args, **kwargs): class ProgramFiltersAPIView(APIView): - permission_classes = [permissions.IsAuthenticated] + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] def get(self, request, pk): program = get_object_or_404(PartnerProgram, pk=pk) @@ -534,7 +538,7 @@ def get(self, request, pk): class ProgramProjectFilterAPIView(GenericAPIView): serializer_class = ProgramProjectFilterRequestSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] pagination_class = PartnerProgramPagination queryset = PartnerProgram.objects.none() @@ -599,11 +603,11 @@ def post(self, request, pk): class PartnerProgramProjectsAPIView(generics.ListAPIView): """ Список всех проектов участников конкретной партнёрской программы. - Доступ разрешён только менеджерам и экспертам программы. + Доступ разрешён только менеджерам и администраторам программы. """ serializer_class = ProjectListSerializer - permission_classes = [IsAuthenticated, IsExpertOrManagerOfProgram] + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] pagination_class = PartnerProgramPagination def get_queryset(self): @@ -614,25 +618,10 @@ def get_queryset(self): return Project.objects.filter(program_links__partner_program=program).distinct() -def _slugify_filename(filename: str) -> str: - """ - Преобразует произвольную строку в безопасное имя файла: - - нормализует Unicode; - - оставляет только буквы, цифры, дефисы, подчёркивания и пробелы; - - заменяет группы пробелов на один дефис. - """ - normalized_name = unicodedata.normalize("NFKD", filename) - safe_chars = [ - char for char in normalized_name if char.isalnum() or char in ("-", "_", " ") - ] - cleaned_name = "".join(safe_chars) - return "-".join(cleaned_name.split()) - - -class PartnerProgramExportProjectsAPIView(APIView): - """Возвращает Excel-файл со всеми проектами программы.""" +class PartnerProgramExportRatesAPIView(APIView): + """Возвращает Excel-файл с оценками проектов программы.""" - permission_classes = [permissions.IsAdminUser] + permission_classes = [IsAdminOrManagerOfProgram] def get(self, request, pk: int): try: @@ -652,12 +641,49 @@ def get(self, request, pk: int): {"detail": "Недостаточно прав."}, status=status.HTTP_403_FORBIDDEN ) - only_submitted = request.query_params.get("only_submitted") in ( - "1", - "true", - "True", + rates_data_to_write = prepare_project_scores_export_data(program.id) + xlsx_file_writer = XlsxFileToExport() + xlsx_file_writer.write_data_to_xlsx(rates_data_to_write) + binary_data_to_export: bytes = xlsx_file_writer.get_binary_data_from_self_file() + xlsx_file_writer.clear_buffer() + + 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 + + +class PartnerProgramExportProjectsAPIView(APIView): + """Возвращает Excel-файл со всеми проектами программы.""" + + permission_classes = [IsAdminOrManagerOfProgram] + + def _get_program(self, pk: int) -> PartnerProgram | None: + try: + return PartnerProgram.objects.get(pk=pk) + except PartnerProgram.DoesNotExist: + return None + def _has_access(self, user, program: PartnerProgram) -> bool: + return bool( + getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + or program.is_manager(user) + ) + + def _export(self, program: PartnerProgram, only_submitted: bool): extra_cols = build_program_field_columns(program) header_pairs = BASE_COLUMNS + extra_cols @@ -697,14 +723,42 @@ def get(self, request, pk: int): wb.save(bio) bio.seek(0) - fname_base = _slugify_filename( - f"{program.name or 'program'}-{program.pk}-projects-{date.today():%Y-%m-%d}" - ) + 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" - return FileResponse( + 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 + + def get(self, request, pk: int): + program = self._get_program(pk) + if not program: + return Response( + {"detail": "Программа не найдена."}, status=status.HTTP_404_NOT_FOUND + ) + + if not self._has_access(request.user, program): + return Response( + {"detail": "Недостаточно прав."}, status=status.HTTP_403_FORBIDDEN + ) + + only_submitted = request.query_params.get("only_submitted") in ( + "1", + "true", + "True", + ) + return self._export(program=program, only_submitted=only_submitted) diff --git a/users/admin.py b/users/admin.py index d393b47b..3df9eba7 100644 --- a/users/admin.py +++ b/users/admin.py @@ -268,7 +268,7 @@ def get_users_activity(self, _) -> HttpResponse: xlsx_file_writer = XlsxFileToExport("активность_пользователей.xlsx") xlsx_file_writer.write_data_to_xlsx(activity_prepare.get_users_prepared_data()) binary_data_to_export: bytes = xlsx_file_writer.get_binary_data_from_self_file() - xlsx_file_writer.delete_self_xlsx_file_from_local_machine() + xlsx_file_writer.clear_buffer() encoded_file_name: str = urllib.parse.quote("активность_пользователей.xlsx") response = HttpResponse(