Skip to content

Commit e8c930e

Browse files
authored
Merge pull request #551 from PROCOLLAB-github/hotfix/export_program_info
Hotfix/export program info
2 parents 31630f3 + 9a94aeb commit e8c930e

4 files changed

Lines changed: 247 additions & 32 deletions

File tree

partner_programs/serializers.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,7 @@ class PartnerProgramForMemberSerializer(PartnerProgramBaseSerializerMixin):
8080

8181
views_count = serializers.SerializerMethodField(method_name="count_views")
8282
links = serializers.SerializerMethodField(method_name="get_links")
83-
is_user_manager = serializers.SerializerMethodField(
84-
method_name="get_is_user_manager"
85-
)
83+
is_user_manager = serializers.SerializerMethodField(method_name="get_is_user_manager")
8684

8785
def count_views(self, program):
8886
return get_views_count(program)

partner_programs/services.py

Lines changed: 137 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import logging
2+
from collections import OrderedDict
23

34
from partner_programs.models import PartnerProgramUserProfile
45
from project_rates.models import Criteria, ProjectScore
56

6-
77
logger = logging.getLogger()
88

99

@@ -23,16 +23,14 @@ class ProjectScoreDataPreparer:
2323
"Класс_курс": "ОШИБКА",
2424
}
2525

26-
EXPERT_ERROR_FIELDS = {
27-
"Фамилия эксперта": "ОШИБКА"
28-
}
26+
EXPERT_ERROR_FIELDS = {"Фамилия эксперта": "ОШИБКА"}
2927

3028
def __init__(
3129
self,
3230
user_profiles: dict[int, PartnerProgramUserProfile],
3331
scores: dict[int, list[ProjectScore]],
3432
project_id: int,
35-
program_id: int
33+
program_id: int,
3634
):
3735
self._project_id = project_id
3836
self._user_profiles = user_profiles
@@ -41,35 +39,54 @@ def __init__(
4139

4240
def get_project_user_info(self) -> dict[str, str]:
4341
try:
44-
user_program_profile: PartnerProgramUserProfile = self._user_profiles.get(self._project_id)
45-
user_program_profile_json: dict = user_program_profile.partner_program_data if user_program_profile else {}
42+
user_program_profile: PartnerProgramUserProfile = self._user_profiles.get(
43+
self._project_id
44+
)
45+
user_program_profile_json: dict = (
46+
user_program_profile.partner_program_data if user_program_profile else {}
47+
)
4648

4749
user_info: dict[str, str] = {
48-
"Фамилия": user_program_profile.user.last_name if user_program_profile else '',
49-
"Имя": user_program_profile.user.first_name if user_program_profile else '',
50-
"Отчество": user_program_profile.user.patronymic if user_program_profile else '',
50+
"Фамилия": (
51+
user_program_profile.user.last_name if user_program_profile else ""
52+
),
53+
"Имя": (
54+
user_program_profile.user.first_name if user_program_profile else ""
55+
),
56+
"Отчество": (
57+
user_program_profile.user.patronymic if user_program_profile else ""
58+
),
5159
"Email": (
52-
user_program_profile_json.get('email') if user_program_profile_json.get('email')
60+
user_program_profile_json.get("email")
61+
if user_program_profile_json.get("email")
5362
else user_program_profile.user.email
5463
),
55-
"Регион_РФ": user_program_profile_json.get('region', ''),
56-
"Учебное_заведение": user_program_profile_json.get('education_type', ''),
57-
"Название_учебного_заведения": user_program_profile_json.get('institution_name', ''),
58-
"Класс_курс": user_program_profile_json.get('class_course', ''),
64+
"Регион_РФ": user_program_profile_json.get("region", ""),
65+
"Учебное_заведение": user_program_profile_json.get("education_type", ""),
66+
"Название_учебного_заведения": user_program_profile_json.get(
67+
"institution_name", ""
68+
),
69+
"Класс_курс": user_program_profile_json.get("class_course", ""),
5970
}
6071
return user_info
6172
except Exception as e:
62-
logger.error(f"Prepare export rates data about user error: {str(e)}", exc_info=True)
73+
logger.error(
74+
f"Prepare export rates data about user error: {str(e)}", exc_info=True
75+
)
6376
return self.USER_ERROR_FIELDS
6477

6578
def get_project_expert_info(self) -> dict[str, str]:
6679
try:
6780
project_scores: list[ProjectScore] = self._scores.get(self._project_id, [])
6881
first_score = project_scores[0] if project_scores else None
69-
expert_last_name: dict[str, str] = {"Фамилия эксперта": first_score.user.last_name if first_score else ''}
82+
expert_last_name: dict[str, str] = {
83+
"Фамилия эксперта": first_score.user.last_name if first_score else ""
84+
}
7085
return expert_last_name
7186
except Exception as e:
72-
logger.error(f"Prepare export rates data about expert error: {str(e)}", exc_info=True)
87+
logger.error(
88+
f"Prepare export rates data about expert error: {str(e)}", exc_info=True
89+
)
7390
return self.EXPERT_ERROR_FIELDS
7491

7592
def get_project_scores_info(self) -> dict[str, str]:
@@ -78,16 +95,114 @@ def get_project_scores_info(self) -> dict[str, str]:
7895
project_scores: list[ProjectScore] = self._scores.get(self._project_id, [])
7996
score_info_with_out_comment: dict[str, str] = {
8097
score.criteria.name: score.value
81-
for score in project_scores if score.criteria.name != "Комментарий"
98+
for score in project_scores
99+
if score.criteria.name != "Комментарий"
82100
}
83101
project_scores_dict.update(score_info_with_out_comment)
84-
comment = next((score for score in project_scores if score.criteria.name == "Комментарий"), None)
102+
comment = next(
103+
(
104+
score
105+
for score in project_scores
106+
if score.criteria.name == "Комментарий"
107+
),
108+
None,
109+
)
85110
if comment is not None:
86111
project_scores_dict["Комментарий"] = comment.value
87112
return project_scores_dict
88113
except Exception as e:
89-
logger.error(f"Prepare export rates data about project_scores error: {str(e)}", exc_info=True)
114+
logger.error(
115+
f"Prepare export rates data about project_scores error: {str(e)}",
116+
exc_info=True,
117+
)
90118
return {
91119
criteria.name: "ОШИБКА"
92-
for criteria in Criteria.objects.filter(partner_program__id=self._program_id)
120+
for criteria in Criteria.objects.filter(
121+
partner_program__id=self._program_id
122+
)
93123
}
124+
125+
126+
BASE_COLUMNS = [
127+
("row_number", "№ п/п"),
128+
("project_name", "Название проекта"),
129+
("project_description", "Описание проекта"),
130+
("project_region", "Регион проекта"),
131+
("project_presentation", "Ссылка на презентацию"),
132+
("team_size", "Количество человек в команде"),
133+
("leader_full_name", "Имя фамилия лидера"),
134+
]
135+
136+
137+
def _leader_full_name(user):
138+
if not user:
139+
return ""
140+
if hasattr(user, "get_full_name") and callable(user.get_full_name):
141+
full = user.get_full_name()
142+
if full:
143+
return full
144+
first = getattr(user, "first_name", "") or ""
145+
last = getattr(user, "last_name", "") or ""
146+
return (first + " " + last).strip() or getattr(user, "username", "") or str(user.pk)
147+
148+
149+
def _calc_team_size(project):
150+
try:
151+
if hasattr(project, "get_collaborators_user_list"):
152+
return 1 + len(project.get_collaborators_user_list())
153+
if hasattr(project, "collaborator_set"):
154+
return 1 + project.collaborator_set.count()
155+
except Exception:
156+
pass
157+
return 1
158+
159+
160+
def build_program_field_columns(program) -> list[tuple[str, str]]:
161+
program_fields = program.fields.all().order_by("pk")
162+
return [
163+
(f"name:{program_field.name}", program_field.label)
164+
for program_field in program_fields
165+
]
166+
167+
168+
def row_dict_for_link(
169+
program_project_link,
170+
extra_field_keys_order: list[str],
171+
row_number: int,
172+
) -> OrderedDict:
173+
"""
174+
program_project_link: PartnerProgramProject
175+
extra_field_keys_order: список псевдоключей "name:<field.name>" в нужном порядке
176+
row_number: порядковый номер строки в Excel (начиная с 1)
177+
"""
178+
project = program_project_link.project
179+
row = OrderedDict()
180+
181+
row["row_number"] = row_number
182+
183+
row["project_name"] = project.name or ""
184+
row["project_description"] = project.description or ""
185+
row["project_region"] = project.region or ""
186+
row["project_presentation"] = project.presentation_address or ""
187+
row["team_size"] = _calc_team_size(project)
188+
row["leader_full_name"] = _leader_full_name(getattr(project, "leader", None))
189+
190+
values_map: dict[str, str] = {}
191+
prefetched_values = getattr(program_project_link, "_prefetched_field_values", None)
192+
field_values_iterable = (
193+
prefetched_values
194+
if prefetched_values is not None
195+
else program_project_link.field_values.all()
196+
)
197+
198+
for field_value in field_values_iterable:
199+
if (
200+
field_value.field.partner_program_id
201+
== program_project_link.partner_program_id
202+
):
203+
values_map[f"name:{field_value.field.name}"] = field_value.get_value()
204+
205+
for field_key in extra_field_keys_order:
206+
row[field_key] = values_map.get(field_key, "")
207+
208+
return row

partner_programs/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
PartnerProgramCreateUserAndRegister,
66
PartnerProgramDataSchema,
77
PartnerProgramDetail,
8+
PartnerProgramExportProjectsAPIView,
89
PartnerProgramList,
910
PartnerProgramProjectsAPIView,
1011
PartnerProgramProjectSubmitView,
@@ -50,4 +51,9 @@
5051
PartnerProgramProjectsAPIView.as_view(),
5152
name="partner-program-projects",
5253
),
54+
path(
55+
"<int:pk>/export-projects/",
56+
PartnerProgramExportProjectsAPIView.as_view(),
57+
name="partner-program-export-projects",
58+
),
5359
]

partner_programs/views.py

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
import io
2+
import unicodedata
3+
from datetime import date
4+
15
from django.contrib.auth import get_user_model
26
from django.db import IntegrityError, transaction
7+
from django.db.models import Prefetch
8+
from django.http import FileResponse
39
from django.shortcuts import get_object_or_404
410
from django.utils import timezone
511
from django.utils.timezone import now
612
from drf_yasg import openapi
713
from drf_yasg.utils import swagger_auto_schema
14+
from openpyxl import Workbook
815
from rest_framework import generics, permissions, status
916
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
1017
from rest_framework.generics import GenericAPIView
@@ -34,16 +41,18 @@
3441
PartnerProgramUserSerializer,
3542
ProgramProjectFilterRequestSerializer,
3643
)
44+
from partner_programs.services import (
45+
BASE_COLUMNS,
46+
build_program_field_columns,
47+
row_dict_for_link,
48+
)
3749
from partner_programs.utils import filter_program_projects_by_field_name
3850
from projects.models import Project
3951
from projects.serializers import (
4052
PartnerProgramFieldValueUpdateSerializer,
4153
ProjectListSerializer,
4254
)
43-
from vacancy.mapping import (
44-
MessageTypeEnum,
45-
UserProgramRegisterParams,
46-
)
55+
from vacancy.mapping import MessageTypeEnum, UserProgramRegisterParams
4756
from vacancy.tasks import send_email
4857

4958
User = get_user_model()
@@ -255,9 +264,7 @@ def get_project(self, project_id):
255264
except Project.DoesNotExist:
256265
raise NotFound("Проект не найден")
257266

258-
@swagger_auto_schema(
259-
request_body=PartnerProgramFieldValueUpdateSerializer(many=True)
260-
)
267+
@swagger_auto_schema(request_body=PartnerProgramFieldValueUpdateSerializer(many=True))
261268
def put(self, request, project_id, *args, **kwargs):
262269
project = self.get_project(project_id)
263270

@@ -439,3 +446,92 @@ class PartnerProgramProjectsAPIView(generics.ListAPIView):
439446
def get_queryset(self):
440447
program = get_object_or_404(PartnerProgram, pk=self.kwargs["pk"])
441448
return Project.objects.filter(program_links__partner_program=program).distinct()
449+
450+
451+
def _slugify_filename(filename: str) -> str:
452+
"""
453+
Преобразует произвольную строку в безопасное имя файла:
454+
- нормализует Unicode;
455+
- оставляет только буквы, цифры, дефисы, подчёркивания и пробелы;
456+
- заменяет группы пробелов на один дефис.
457+
"""
458+
normalized_name = unicodedata.normalize("NFKD", filename)
459+
safe_chars = [
460+
char for char in normalized_name if char.isalnum() or char in ("-", "_", " ")
461+
]
462+
cleaned_name = "".join(safe_chars)
463+
return "-".join(cleaned_name.split())
464+
465+
466+
class PartnerProgramExportProjectsAPIView(APIView):
467+
"""Возвращает Excel-файл со всеми проектами программы."""
468+
469+
permission_classes = [permissions.IsAdminUser]
470+
471+
def get(self, request, pk: int):
472+
try:
473+
program = PartnerProgram.objects.get(pk=pk)
474+
except PartnerProgram.DoesNotExist:
475+
return Response(
476+
{"detail": "Программа не найдена."}, status=status.HTTP_404_NOT_FOUND
477+
)
478+
479+
user = request.user
480+
if not (
481+
getattr(user, "is_staff", False)
482+
or getattr(user, "is_superuser", False)
483+
or program.is_manager(user)
484+
):
485+
return Response(
486+
{"detail": "Недостаточно прав."}, status=status.HTTP_403_FORBIDDEN
487+
)
488+
489+
only_submitted = request.query_params.get("only_submitted") in (
490+
"1",
491+
"true",
492+
"True",
493+
)
494+
495+
extra_cols = build_program_field_columns(program)
496+
header_pairs = BASE_COLUMNS + extra_cols
497+
498+
fv_qs = PartnerProgramFieldValue.objects.select_related("field").filter(
499+
field__partner_program_id=program.id
500+
)
501+
links_qs = program.program_projects.select_related(
502+
"project", "project__leader"
503+
).prefetch_related(
504+
Prefetch("field_values", queryset=fv_qs, to_attr="_prefetched_field_values")
505+
)
506+
if only_submitted:
507+
links_qs = links_qs.filter(submitted=True)
508+
509+
wb = Workbook(write_only=True)
510+
ws = wb.create_sheet(title="Проекты")
511+
ws.append([title for _, title in header_pairs])
512+
513+
extra_keys_order = [key for key, _ in extra_cols]
514+
515+
for row_number, program_project_link in enumerate(links_qs, start=1):
516+
row_dict = row_dict_for_link(
517+
program_project_link=program_project_link,
518+
extra_field_keys_order=extra_keys_order,
519+
row_number=row_number,
520+
)
521+
ws.append([row_dict.get(key, "") for key, _ in header_pairs])
522+
523+
bio = io.BytesIO()
524+
wb.save(bio)
525+
bio.seek(0)
526+
527+
fname_base = _slugify_filename(
528+
f"{program.name or 'program'}-{program.pk}-projects-{date.today():%Y-%m-%d}"
529+
)
530+
filename = f"{fname_base}.xlsx"
531+
532+
return FileResponse(
533+
bio,
534+
as_attachment=True,
535+
filename=filename,
536+
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
537+
)

0 commit comments

Comments
 (0)