From 36059a05c5df09a7eaa3323a5635dfe5b4e9c622 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 15 Oct 2025 12:01:47 +0500 Subject: [PATCH 1/2] =?UTF-8?q?=D0=9A=D0=BE=D0=B4=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=B2=D0=B5=D0=B4=D1=91=D0=BD=20=D0=B2=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=20PEP8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit d2cc7407246df4f15ee497ac73ffbe3704a9cdd6) --- partner_programs/services.py | 73 +++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/partner_programs/services.py b/partner_programs/services.py index a374a741..f14c7b65 100644 --- a/partner_programs/services.py +++ b/partner_programs/services.py @@ -3,7 +3,6 @@ from partner_programs.models import PartnerProgramUserProfile from project_rates.models import Criteria, ProjectScore - logger = logging.getLogger() @@ -23,16 +22,14 @@ class ProjectScoreDataPreparer: "Класс_курс": "ОШИБКА", } - EXPERT_ERROR_FIELDS = { - "Фамилия эксперта": "ОШИБКА" - } + EXPERT_ERROR_FIELDS = {"Фамилия эксперта": "ОШИБКА"} def __init__( self, user_profiles: dict[int, PartnerProgramUserProfile], scores: dict[int, list[ProjectScore]], project_id: int, - program_id: int + program_id: int, ): self._project_id = project_id self._user_profiles = user_profiles @@ -41,35 +38,54 @@ def __init__( def get_project_user_info(self) -> dict[str, str]: try: - user_program_profile: PartnerProgramUserProfile = self._user_profiles.get(self._project_id) - user_program_profile_json: dict = user_program_profile.partner_program_data if user_program_profile else {} + user_program_profile: PartnerProgramUserProfile = self._user_profiles.get( + self._project_id + ) + user_program_profile_json: dict = ( + user_program_profile.partner_program_data if user_program_profile else {} + ) user_info: dict[str, str] = { - "Фамилия": user_program_profile.user.last_name if user_program_profile else '', - "Имя": user_program_profile.user.first_name if user_program_profile else '', - "Отчество": user_program_profile.user.patronymic if user_program_profile else '', + "Фамилия": ( + user_program_profile.user.last_name if user_program_profile else "" + ), + "Имя": ( + user_program_profile.user.first_name if user_program_profile else "" + ), + "Отчество": ( + user_program_profile.user.patronymic if user_program_profile else "" + ), "Email": ( - user_program_profile_json.get('email') if user_program_profile_json.get('email') + user_program_profile_json.get("email") + if user_program_profile_json.get("email") else user_program_profile.user.email ), - "Регион_РФ": user_program_profile_json.get('region', ''), - "Учебное_заведение": user_program_profile_json.get('education_type', ''), - "Название_учебного_заведения": user_program_profile_json.get('institution_name', ''), - "Класс_курс": user_program_profile_json.get('class_course', ''), + "Регион_РФ": user_program_profile_json.get("region", ""), + "Учебное_заведение": user_program_profile_json.get("education_type", ""), + "Название_учебного_заведения": user_program_profile_json.get( + "institution_name", "" + ), + "Класс_курс": user_program_profile_json.get("class_course", ""), } return user_info except Exception as e: - logger.error(f"Prepare export rates data about user error: {str(e)}", exc_info=True) + logger.error( + f"Prepare export rates data about user error: {str(e)}", exc_info=True + ) return self.USER_ERROR_FIELDS def get_project_expert_info(self) -> dict[str, str]: try: project_scores: list[ProjectScore] = self._scores.get(self._project_id, []) first_score = project_scores[0] if project_scores else None - expert_last_name: dict[str, str] = {"Фамилия эксперта": first_score.user.last_name if first_score else ''} + expert_last_name: dict[str, str] = { + "Фамилия эксперта": first_score.user.last_name if first_score else "" + } return expert_last_name except Exception as e: - logger.error(f"Prepare export rates data about expert error: {str(e)}", exc_info=True) + logger.error( + f"Prepare export rates data about expert error: {str(e)}", exc_info=True + ) return self.EXPERT_ERROR_FIELDS def get_project_scores_info(self) -> dict[str, str]: @@ -78,16 +94,29 @@ def get_project_scores_info(self) -> dict[str, str]: project_scores: list[ProjectScore] = self._scores.get(self._project_id, []) score_info_with_out_comment: dict[str, str] = { score.criteria.name: score.value - for score in project_scores if score.criteria.name != "Комментарий" + for score in project_scores + if score.criteria.name != "Комментарий" } project_scores_dict.update(score_info_with_out_comment) - comment = next((score for score in project_scores if score.criteria.name == "Комментарий"), None) + comment = next( + ( + score + for score in project_scores + if score.criteria.name == "Комментарий" + ), + None, + ) if comment is not None: project_scores_dict["Комментарий"] = comment.value return project_scores_dict except Exception as e: - logger.error(f"Prepare export rates data about project_scores error: {str(e)}", exc_info=True) + logger.error( + f"Prepare export rates data about project_scores error: {str(e)}", + exc_info=True, + ) return { criteria.name: "ОШИБКА" - for criteria in Criteria.objects.filter(partner_program__id=self._program_id) + for criteria in Criteria.objects.filter( + partner_program__id=self._program_id + ) } From 9a94aeb3e3969d0f7ada573afde81eb5e3dc9a54 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 15 Oct 2025 13:58:38 +0500 Subject: [PATCH 2/2] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=80=D1=83=D1=87=D0=BA=D0=B0=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=B2=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=BC=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 2b83e93599d61d953718648d58643f54275d5bb7) --- partner_programs/serializers.py | 4 +- partner_programs/services.py | 86 +++++++++++++++++++++++++ partner_programs/urls.py | 6 ++ partner_programs/views.py | 110 ++++++++++++++++++++++++++++++-- 4 files changed, 196 insertions(+), 10 deletions(-) diff --git a/partner_programs/serializers.py b/partner_programs/serializers.py index 43e2806f..3bd6a09a 100644 --- a/partner_programs/serializers.py +++ b/partner_programs/serializers.py @@ -80,9 +80,7 @@ class PartnerProgramForMemberSerializer(PartnerProgramBaseSerializerMixin): views_count = serializers.SerializerMethodField(method_name="count_views") links = serializers.SerializerMethodField(method_name="get_links") - is_user_manager = serializers.SerializerMethodField( - method_name="get_is_user_manager" - ) + is_user_manager = serializers.SerializerMethodField(method_name="get_is_user_manager") def count_views(self, program): return get_views_count(program) diff --git a/partner_programs/services.py b/partner_programs/services.py index f14c7b65..b10e23c4 100644 --- a/partner_programs/services.py +++ b/partner_programs/services.py @@ -1,4 +1,5 @@ import logging +from collections import OrderedDict from partner_programs.models import PartnerProgramUserProfile from project_rates.models import Criteria, ProjectScore @@ -120,3 +121,88 @@ def get_project_scores_info(self) -> dict[str, str]: partner_program__id=self._program_id ) } + + +BASE_COLUMNS = [ + ("row_number", "№ п/п"), + ("project_name", "Название проекта"), + ("project_description", "Описание проекта"), + ("project_region", "Регион проекта"), + ("project_presentation", "Ссылка на презентацию"), + ("team_size", "Количество человек в команде"), + ("leader_full_name", "Имя фамилия лидера"), +] + + +def _leader_full_name(user): + if not user: + return "" + if hasattr(user, "get_full_name") and callable(user.get_full_name): + full = user.get_full_name() + if full: + return full + first = getattr(user, "first_name", "") or "" + last = getattr(user, "last_name", "") or "" + return (first + " " + last).strip() or getattr(user, "username", "") or str(user.pk) + + +def _calc_team_size(project): + try: + if hasattr(project, "get_collaborators_user_list"): + return 1 + len(project.get_collaborators_user_list()) + if hasattr(project, "collaborator_set"): + return 1 + project.collaborator_set.count() + except Exception: + pass + return 1 + + +def build_program_field_columns(program) -> list[tuple[str, str]]: + program_fields = program.fields.all().order_by("pk") + return [ + (f"name:{program_field.name}", program_field.label) + for program_field in program_fields + ] + + +def row_dict_for_link( + program_project_link, + extra_field_keys_order: list[str], + row_number: int, +) -> OrderedDict: + """ + program_project_link: PartnerProgramProject + extra_field_keys_order: список псевдоключей "name:" в нужном порядке + row_number: порядковый номер строки в Excel (начиная с 1) + """ + project = program_project_link.project + row = OrderedDict() + + row["row_number"] = row_number + + row["project_name"] = project.name or "" + row["project_description"] = project.description or "" + row["project_region"] = project.region or "" + row["project_presentation"] = project.presentation_address or "" + row["team_size"] = _calc_team_size(project) + row["leader_full_name"] = _leader_full_name(getattr(project, "leader", None)) + + values_map: dict[str, str] = {} + prefetched_values = getattr(program_project_link, "_prefetched_field_values", None) + field_values_iterable = ( + prefetched_values + if prefetched_values is not None + else program_project_link.field_values.all() + ) + + for field_value in field_values_iterable: + if ( + field_value.field.partner_program_id + == program_project_link.partner_program_id + ): + values_map[f"name:{field_value.field.name}"] = field_value.get_value() + + for field_key in extra_field_keys_order: + row[field_key] = values_map.get(field_key, "") + + return row diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 423d24d5..269e7c16 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -5,6 +5,7 @@ PartnerProgramCreateUserAndRegister, PartnerProgramDataSchema, PartnerProgramDetail, + PartnerProgramExportProjectsAPIView, PartnerProgramList, PartnerProgramProjectsAPIView, PartnerProgramProjectSubmitView, @@ -50,4 +51,9 @@ PartnerProgramProjectsAPIView.as_view(), name="partner-program-projects", ), + path( + "/export-projects/", + PartnerProgramExportProjectsAPIView.as_view(), + name="partner-program-export-projects", + ), ] diff --git a/partner_programs/views.py b/partner_programs/views.py index 9f981509..e985ff1c 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -1,10 +1,17 @@ +import io +import unicodedata +from datetime import date + from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction +from django.db.models import Prefetch +from django.http import FileResponse from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.timezone import now from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema +from openpyxl import Workbook from rest_framework import generics, permissions, status from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.generics import GenericAPIView @@ -34,16 +41,18 @@ PartnerProgramUserSerializer, ProgramProjectFilterRequestSerializer, ) +from partner_programs.services import ( + BASE_COLUMNS, + build_program_field_columns, + row_dict_for_link, +) from partner_programs.utils import filter_program_projects_by_field_name from projects.models import Project from projects.serializers import ( PartnerProgramFieldValueUpdateSerializer, ProjectListSerializer, ) -from vacancy.mapping import ( - MessageTypeEnum, - UserProgramRegisterParams, -) +from vacancy.mapping import MessageTypeEnum, UserProgramRegisterParams from vacancy.tasks import send_email User = get_user_model() @@ -255,9 +264,7 @@ def get_project(self, project_id): except Project.DoesNotExist: raise NotFound("Проект не найден") - @swagger_auto_schema( - request_body=PartnerProgramFieldValueUpdateSerializer(many=True) - ) + @swagger_auto_schema(request_body=PartnerProgramFieldValueUpdateSerializer(many=True)) def put(self, request, project_id, *args, **kwargs): project = self.get_project(project_id) @@ -439,3 +446,92 @@ class PartnerProgramProjectsAPIView(generics.ListAPIView): def get_queryset(self): program = get_object_or_404(PartnerProgram, pk=self.kwargs["pk"]) 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-файл со всеми проектами программы.""" + + permission_classes = [permissions.IsAdminUser] + + def get(self, request, pk: int): + try: + program = PartnerProgram.objects.get(pk=pk) + except PartnerProgram.DoesNotExist: + return Response( + {"detail": "Программа не найдена."}, status=status.HTTP_404_NOT_FOUND + ) + + user = request.user + if not ( + getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + or program.is_manager(user) + ): + return Response( + {"detail": "Недостаточно прав."}, status=status.HTTP_403_FORBIDDEN + ) + + only_submitted = request.query_params.get("only_submitted") in ( + "1", + "true", + "True", + ) + + extra_cols = build_program_field_columns(program) + header_pairs = BASE_COLUMNS + extra_cols + + fv_qs = PartnerProgramFieldValue.objects.select_related("field").filter( + field__partner_program_id=program.id + ) + links_qs = program.program_projects.select_related( + "project", "project__leader" + ).prefetch_related( + Prefetch("field_values", queryset=fv_qs, to_attr="_prefetched_field_values") + ) + if only_submitted: + links_qs = links_qs.filter(submitted=True) + + wb = Workbook(write_only=True) + ws = wb.create_sheet(title="Проекты") + ws.append([title for _, title in header_pairs]) + + extra_keys_order = [key for key, _ in extra_cols] + + for row_number, program_project_link in enumerate(links_qs, start=1): + row_dict = row_dict_for_link( + program_project_link=program_project_link, + extra_field_keys_order=extra_keys_order, + row_number=row_number, + ) + ws.append([row_dict.get(key, "") for key, _ in header_pairs]) + + bio = io.BytesIO() + wb.save(bio) + bio.seek(0) + + fname_base = _slugify_filename( + f"{program.name or 'program'}-{program.pk}-projects-{date.today():%Y-%m-%d}" + ) + filename = f"{fname_base}.xlsx" + + return FileResponse( + bio, + as_attachment=True, + filename=filename, + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + )