Skip to content

Commit 9a94aeb

Browse files
committed
Добавлена ручка для выгрузки данных проектов в программах
(cherry picked from commit 2b83e93)
1 parent 36059a0 commit 9a94aeb

4 files changed

Lines changed: 196 additions & 10 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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from collections import OrderedDict
23

34
from partner_programs.models import PartnerProgramUserProfile
45
from project_rates.models import Criteria, ProjectScore
@@ -120,3 +121,88 @@ def get_project_scores_info(self) -> dict[str, str]:
120121
partner_program__id=self._program_id
121122
)
122123
}
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)