From 70e252a2d9dc6911544abf73e725b97e3cf1802f Mon Sep 17 00:00:00 2001 From: Toksi86 Date: Mon, 1 Sep 2025 11:14:50 +0500 Subject: [PATCH 01/13] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/constants.py | 9 ----- projects/models.py | 86 ++++++++++++++++++++++--------------------- projects/views.py | 11 ------ 3 files changed, 45 insertions(+), 61 deletions(-) delete mode 100644 projects/constants.py diff --git a/projects/constants.py b/projects/constants.py deleted file mode 100644 index 0dd9684e..00000000 --- a/projects/constants.py +++ /dev/null @@ -1,9 +0,0 @@ -VERBOSE_STEPS = ( - (0, "Идея"), - (1, "Прототип"), - (2, "MVP(Минимально жизнеспособный продукт)"), - (3, "Первые продажи"), - (4, "Масштабирование"), -) - -RECOMMENDATIONS_COUNT = 5 diff --git a/projects/models.py b/projects/models.py index 117b65c7..9bc99bfc 100644 --- a/projects/models.py +++ b/projects/models.py @@ -2,14 +2,17 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericRelation -from django.core.validators import MaxLengthValidator +from django.core.validators import ( + MaxLengthValidator, + MaxValueValidator, + MinValueValidator, +) from django.db import models from django.db.models import UniqueConstraint from core.models import Like, View from files.models import UserFile from industries.models import Industry -from projects.constants import VERBOSE_STEPS from projects.managers import AchievementManager, CollaboratorManager, ProjectManager from users.models import CustomUser @@ -60,48 +63,36 @@ class Meta: class Project(models.Model): """ - Project model - - Attributes: - name: A CharField name of the project. - description: A TextField description of the project. - region: A CharField region of the project. - step: A PositiveSmallIntegerField which indicates status of the project - according to VERBOSE_STEPS. - industry: A ForeignKey referring to the Industry model. - presentation_address: A URLField presentation URL address. - image_address: A URLField image URL address. - leader: A ForeignKey referring to the User model. - draft: A boolean indicating if Project is a draft. - is_company: A boolean indicating if Project is a company. - cover_image_address: A URLField cover image URL address. - cover: A ForeignKey referring to the UserFile model, which is the image cover of the project. - datetime_created: A DateTimeField indicating date of creation. - datetime_updated: A DateTimeField indicating date of update. + Модель проекта. + + Атрибуты: + name (CharField): Название проекта. + description (TextField): Подробное описание проекта. + region (CharField): Регион, в котором реализуется проект. + hidden_score (PositiveSmallIntegerField): Скрытый рейтинг проекта, + используется для внутренней сортировки. + actuality (TextField): Актуальность проекта (почему он важен). + target_audience (CharField): Описание целевой аудитории проекта. + implementation_deadline (DateField): Общий срок реализации проекта (дата завершения). + problem (TextField): Проблема, которую решает проект. + trl (PositiveSmallIntegerField): Уровень технологической готовности (Technology Readiness Level) от 1 до 9. + industry (ForeignKey): Ссылка на отрасль (модель Industry). + presentation_address (URLField): Ссылка на презентацию проекта. + image_address (URLField): Ссылка на изображение (аватар проекта). + leader (ForeignKey): Руководитель проекта (пользователь). + draft (BooleanField): Флаг, указывающий, является ли проект черновиком. + is_company (BooleanField): Признак того, что проект представляет компанию. + cover_image_address (URLField): Ссылка на обложку проекта. + cover (ForeignKey): Файл-обложка проекта (устаревшее поле). + subscribers (ManyToManyField): Подписчики проекта. + datetime_created (DateTimeField): Дата создания проекта. + datetime_updated (DateTimeField): Дата последнего изменения проекта. """ name = models.CharField(max_length=256, null=True, blank=True) description = models.TextField(null=True, blank=True) region = models.CharField(max_length=256, null=True, blank=True) - step = models.PositiveSmallIntegerField( - choices=VERBOSE_STEPS, null=True, blank=True - ) hidden_score = models.PositiveSmallIntegerField(default=100) - - track = models.CharField( - max_length=256, - blank=True, - null=True, - verbose_name="Трек", - help_text="Направление/курс, в рамках которого реализуется проект", - ) - direction = models.CharField( - max_length=256, - blank=True, - null=True, - verbose_name="Направление", - help_text="Более общее направление деятельности проекта", - ) actuality = models.TextField( blank=True, null=True, @@ -109,12 +100,25 @@ class Project(models.Model): verbose_name="Актуальность", help_text="Почему проект важен (до 1000 симв.)", ) - goal = models.CharField( + target_audience = models.CharField( max_length=500, blank=True, null=True, - verbose_name="Цель", - help_text="Главная цель проекта (до 500 симв.)", + verbose_name="Целевая аудитория", + help_text="Описание целевой аудитории проекта (до 500 симв.)", + ) + trl = models.PositiveSmallIntegerField( + verbose_name="TRL", + help_text="Technology Readiness Level (от 1 до 9)", + validators=[MinValueValidator(1), MaxValueValidator(9)], + null=True, + blank=True, + ) + implementation_deadline = models.DateField( + verbose_name="Общий срок реализации проекта", + help_text="Дата, до которой планируется реализовать проект", + null=True, + blank=True, ) problem = models.TextField( blank=True, diff --git a/projects/views.py b/projects/views.py index bc330f9e..7f26d543 100644 --- a/projects/views.py +++ b/projects/views.py @@ -23,7 +23,6 @@ PartnerProgramProject, PartnerProgramUserProfile, ) -from projects.constants import VERBOSE_STEPS from projects.exceptions import CollaboratorDoesNotExist from projects.filters import ProjectFilter from projects.helpers import ( @@ -322,16 +321,6 @@ def _collabs_queryset( ) -class ProjectSteps(APIView): - permission_classes = [IsStaffOrReadOnly] - - def get(self, request, format=None): - """ - Return a tuple of project steps. - """ - return Response(VERBOSE_STEPS) - - class AchievementList(generics.ListCreateAPIView): queryset = Achievement.objects.get_achievements_for_list_view() serializer_class = AchievementListSerializer From ef7eeaa974c418198e3a5f37590d9970810effa9 Mon Sep 17 00:00:00 2001 From: Toksi86 Date: Wed, 3 Sep 2025 13:01:14 +0500 Subject: [PATCH 02/13] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20=D0=9F=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- invites/tests.py | 1 - projects/admin.py | 13 ++-- projects/filters.py | 8 +-- projects/helpers.py | 3 +- ..._direction_remove_project_goal_and_more.py | 65 +++++++++++++++++++ projects/serializers.py | 15 +++-- projects/tests.py | 2 - projects/urls.py | 2 - projects/views.py | 8 +-- ...ustomuser_avatar_alter_customuser_email.py | 34 ++++++++++ vacancy/tests.py | 1 - 11 files changed, 117 insertions(+), 35 deletions(-) create mode 100644 projects/migrations/0028_remove_project_direction_remove_project_goal_and_more.py create mode 100644 users/migrations/0055_alter_customuser_avatar_alter_customuser_email.py diff --git a/invites/tests.py b/invites/tests.py index dcabc897..613fd603 100644 --- a/invites/tests.py +++ b/invites/tests.py @@ -36,7 +36,6 @@ def setUp(self) -> None: "name": "Test", "description": "Test", "industry": Industry.objects.create(name="Test").id, - "step": 1, "draft": False, } diff --git a/projects/admin.py b/projects/admin.py index d7cbce7e..99435e5d 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -18,8 +18,9 @@ class ProjectAdmin(admin.ModelAdmin): "name", "draft", "is_company", - "track", - "direction", + "trl", + "target_audience", + "implementation_deadline", ) list_display_links = ( "id", @@ -27,13 +28,11 @@ class ProjectAdmin(admin.ModelAdmin): ) search_fields = ( "name", - "track", ) list_filter = ( "draft", "is_company", - "track", - "direction", + "trl", ) fieldsets = ( @@ -46,7 +45,6 @@ class ProjectAdmin(admin.ModelAdmin): "leader", "industry", "region", - "step", "draft", "is_company", ) @@ -56,10 +54,7 @@ class ProjectAdmin(admin.ModelAdmin): "Для проектов ПД МосПолитеха", { "fields": ( - "track", - "direction", "actuality", - "goal", "problem", ) }, diff --git a/projects/filters.py b/projects/filters.py index 3d1f36c6..9ece8dc4 100644 --- a/projects/filters.py +++ b/projects/filters.py @@ -13,9 +13,9 @@ class ProjectFilter(filters.FilterSet): Adds filtering to DRF list retrieve views Parameters to filter by: - industry (int), step (int), region (str), name__contains (str), + industry (int), region (str), name__contains (str), description__contains (str), collaborator__user__in (List[int]), - datetime_created__gt (datetime.datetime), step (int), any_vacancies (bool), + datetime_created__gt (datetime.datetime), any_vacancies (bool), member_count__gt (int), member_count__lt (int), leader (int), partner_program (int), is_company (bool). @@ -25,7 +25,6 @@ class ProjectFilter(filters.FilterSet): ?datetime_created__gt=25.10.2022 equals to .filter(datetime_created__gt=datetime.datetime(...)) ?collaborator__user__in=1,2 equals to .filter(collaborator__user__in=[1, 2]) - ?step=1 equals to .filter(step=1) ?any_vacancies=true equals to .filter(any_vacancies=True) ?collaborator__count__gt=1 equals to .filter(collaborator__count__gt=1) ?is_company=0/?is_company=false equals .filter(is_company=False) @@ -113,7 +112,6 @@ def filter_by_have_expert_rates(self, queryset, name, value): collaborator__count__lte = filters.NumberFilter( field_name="collaborator", method="filter_collaborator_count_lte" ) - step = filters.NumberFilter(field_name="step") partner_program = filters.NumberFilter( field_name="partner_program", method="filter_by_partner_program" ) @@ -128,10 +126,8 @@ class Meta: model = Project fields = ( "industry", - "step", "region", "leader", - "step", "partner_program", "is_company", ) diff --git a/projects/helpers.py b/projects/helpers.py index 36e7a50b..ad3c02bc 100644 --- a/projects/helpers.py +++ b/projects/helpers.py @@ -7,7 +7,6 @@ from rest_framework.exceptions import ValidationError from partner_programs.models import PartnerProgram, PartnerProgramUserProfile -from projects.constants import RECOMMENDATIONS_COUNT from projects.models import Project, ProjectLink, Achievement from users.models import CustomUser @@ -18,7 +17,7 @@ def get_recommended_users(project: Project) -> list[User]: """ Searches for users by matching their skills and vacancies required_skills """ - + RECOMMENDATIONS_COUNT = 5 all_needed_skills = set() for vacancy in project.vacancies.all(): all_needed_skills.update(set(vacancy.get_required_skills())) diff --git a/projects/migrations/0028_remove_project_direction_remove_project_goal_and_more.py b/projects/migrations/0028_remove_project_direction_remove_project_goal_and_more.py new file mode 100644 index 00000000..84e7897f --- /dev/null +++ b/projects/migrations/0028_remove_project_direction_remove_project_goal_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.11 on 2025-09-02 07:59 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0027_alter_defaultprojectcover_datetime_created_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="project", + name="direction", + ), + migrations.RemoveField( + model_name="project", + name="goal", + ), + migrations.RemoveField( + model_name="project", + name="step", + ), + migrations.RemoveField( + model_name="project", + name="track", + ), + migrations.AddField( + model_name="project", + name="implementation_deadline", + field=models.DateField( + blank=True, + help_text="Дата, до которой планируется реализовать проект", + null=True, + verbose_name="Общий срок реализации проекта", + ), + ), + migrations.AddField( + model_name="project", + name="target_audience", + field=models.CharField( + blank=True, + help_text="Описание целевой аудитории проекта (до 500 симв.)", + max_length=500, + null=True, + verbose_name="Целевая аудитория", + ), + ), + migrations.AddField( + model_name="project", + name="trl", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Technology Readiness Level (от 1 до 9)", + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(9), + ], + verbose_name="TRL", + ), + ), + ] diff --git a/projects/serializers.py b/projects/serializers.py index e2d300be..b6e409bf 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -122,11 +122,13 @@ class ProjectDetailSerializer(serializers.ModelSerializer): links = serializers.SerializerMethodField() partner_program = serializers.SerializerMethodField() partner_program_tags = serializers.SerializerMethodField() - track = serializers.CharField(required=False, allow_null=True, allow_blank=True) - direction = serializers.CharField(required=False, allow_null=True, allow_blank=True) actuality = serializers.CharField(required=False, allow_null=True, allow_blank=True) - goal = serializers.CharField(required=False, allow_null=True, allow_blank=True) problem = serializers.CharField(required=False, allow_null=True, allow_blank=True) + target_audience = serializers.CharField( + required=False, allow_blank=True, allow_null=True + ) + implementation_deadline = serializers.DateField(required=False, allow_null=True) + trl = serializers.IntegerField(required=False, allow_null=True) def get_partner_program(self, project): try: @@ -172,7 +174,6 @@ class Meta: "achievements", "links", "region", - "step", "industry", "industry_id", "presentation_address", @@ -187,11 +188,11 @@ class Meta: "views_count", "cover", "cover_image_address", - "track", - "direction", "actuality", - "goal", "problem", + "target_audience", + "implementation_deadline", + "trl", "partner_program_tags", "partner_program", ] diff --git a/projects/tests.py b/projects/tests.py index ff30af9d..c046d699 100644 --- a/projects/tests.py +++ b/projects/tests.py @@ -19,7 +19,6 @@ def setUp(self): "name": "Test", "description": "Test", "industry": Industry.objects.create(name="Test").id, - "step": 1, } def test_project_creation(self): @@ -42,7 +41,6 @@ def test_project_creation_with_wrong_data(self): "name": "T" * 257, "description": "Test", "industry": Industry.objects.create(name="Test").id, - "step": 1, }, ) force_authenticate(request, user=user) diff --git a/projects/urls.py b/projects/urls.py index 636f8ca7..046b13d2 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -12,7 +12,6 @@ ProjectDetail, ProjectList, ProjectRecommendedUsers, - ProjectSteps, ProjectSubscribe, ProjectSubscribers, ProjectUnsubscribe, @@ -50,7 +49,6 @@ name="update_program_fields", ), path("count/", ProjectCountView.as_view()), - path("steps/", ProjectSteps.as_view()), path("achievements/", AchievementList.as_view()), path("achievements//", AchievementDetail.as_view()), path("/responses/", ProjectVacancyResponses.as_view()), diff --git a/projects/views.py b/projects/views.py index 7f26d543..7fbdce6b 100644 --- a/projects/views.py +++ b/projects/views.py @@ -120,7 +120,6 @@ def post(self, request, *args, **kwargs): [name] - название проекта [description] - описание проекта [industry] - id отрасли - [step] - этап проекта [image_address] - адрес изображения [presentation_address] - адрес презентации [short_description] - краткое описание проекта @@ -684,12 +683,11 @@ def post(self, request): name=original_project.name, description=original_project.description, region=original_project.region, - step=original_project.step, hidden_score=original_project.hidden_score, - track=original_project.track, - direction=original_project.direction, actuality=original_project.actuality, - goal=original_project.goal, + target_audience=original_project.target_audience, + trl=original_project.trl, + implementation_deadline=original_project.implementation_deadline, problem=original_project.problem, industry=original_project.industry, image_address=original_project.image_address, diff --git a/users/migrations/0055_alter_customuser_avatar_alter_customuser_email.py b/users/migrations/0055_alter_customuser_avatar_alter_customuser_email.py new file mode 100644 index 00000000..2bbb6595 --- /dev/null +++ b/users/migrations/0055_alter_customuser_avatar_alter_customuser_email.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.11 on 2025-09-02 07:59 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0054_alter_customuser_first_name_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="customuser", + name="avatar", + field=models.URLField( + blank=True, + null=True, + validators=[ + django.core.validators.URLValidator(message="Введите корректный URL") + ], + ), + ), + migrations.AlterField( + model_name="customuser", + name="email", + field=models.EmailField( + error_messages={"unique": "Пользователь с таким email уже существует"}, + max_length=254, + unique=True, + ), + ), + ] diff --git a/vacancy/tests.py b/vacancy/tests.py index 356c5212..fb6d3596 100644 --- a/vacancy/tests.py +++ b/vacancy/tests.py @@ -33,7 +33,6 @@ def setUp(self): name="Test", description="Test", industry=Industry.objects.create(name="Test"), - step=1, leader=self.user_project_owner, ) self.vacancy_create_data = { From 2399010b68ff14c5064d44a65e47877be964feea Mon Sep 17 00:00:00 2001 From: Toksi86 Date: Thu, 4 Sep 2025 12:26:02 +0500 Subject: [PATCH 03/13] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=D0=B0=20=D1=81=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0=20=D0=9F=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=B0=D0=B4=D0=BC.=20=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/admin.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/projects/admin.py b/projects/admin.py index 99435e5d..cc30ab34 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -22,18 +22,9 @@ class ProjectAdmin(admin.ModelAdmin): "target_audience", "implementation_deadline", ) - list_display_links = ( - "id", - "name", - ) - search_fields = ( - "name", - ) - list_filter = ( - "draft", - "is_company", - "trl", - ) + list_display_links = ("id", "name") + search_fields = ("name",) + list_filter = ("draft", "is_company", "trl") fieldsets = ( ( @@ -51,11 +42,14 @@ class ProjectAdmin(admin.ModelAdmin): }, ), ( - "Для проектов ПД МосПолитеха", + "Характеристики проекта", { "fields": ( "actuality", "problem", + "target_audience", + "trl", + "implementation_deadline", ) }, ), From 0b5055a9f1b43e065bce0527404d3fbaa98e6009 Mon Sep 17 00:00:00 2001 From: Toksi86 Date: Fri, 5 Sep 2025 15:54:50 +0500 Subject: [PATCH 04/13] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=BB=D0=B2?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BE=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B8=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B5=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=B8=D0=B5=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- invites/serializers.py | 44 +++++++++++++++++++++++++++++++++ projects/models.py | 31 +++++++++++++++++++++++ projects/permissions.py | 54 ++++++++++++++++++++++++++++++++--------- projects/serializers.py | 2 +- projects/views.py | 3 ++- 5 files changed, 121 insertions(+), 13 deletions(-) diff --git a/invites/serializers.py b/invites/serializers.py index 1ea64c59..1152ed73 100644 --- a/invites/serializers.py +++ b/invites/serializers.py @@ -1,6 +1,8 @@ +from django.apps import apps from rest_framework import serializers from invites.models import Invite +from projects.models import Collaborator from projects.serializers import ProjectListSerializer from users.serializers import UserDetailSerializer @@ -18,6 +20,48 @@ class Meta: "is_accepted", ] + def validate(self, attrs): + project = attrs["project"] + user = attrs["user"] + + if project.leader_id == user.id: + raise serializers.ValidationError( + {"user": "Пользователь уже является лидером проекта."} + ) + + if Collaborator.objects.filter(project=project, user=user).exists(): + raise serializers.ValidationError( + {"user": "Пользователь уже состоит в проекте."} + ) + + if Invite.objects.filter( + project=project, user=user, is_accepted__isnull=True + ).exists(): + raise serializers.ValidationError( + {"user": "У пользователя уже есть активное приглашение в этот проект."} + ) + + link = project.program_links.select_related("partner_program").first() + if link: + PartnerProgramUserProfile = apps.get_model( + "partner_programs", "PartnerProgramUserProfile" + ) + is_participant = PartnerProgramUserProfile.objects.filter( + user_id=user.id, + partner_program_id=link.partner_program_id, + ).exists() + if not is_participant: + raise serializers.ValidationError( + { + "user": ( + "Нельзя пригласить пользователя: проект относится к программе, " + "а пользователь не является её участником." + ) + } + ) + + return attrs + class InviteDetailSerializer(serializers.ModelSerializer[Invite]): user = UserDetailSerializer(many=False, read_only=True) diff --git a/projects/models.py b/projects/models.py index 9bc99bfc..92b4c8d2 100644 --- a/projects/models.py +++ b/projects/models.py @@ -1,7 +1,9 @@ from typing import Optional +from django.apps import apps from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError from django.core.validators import ( MaxLengthValidator, MaxValueValidator, @@ -310,6 +312,35 @@ class Meta: ) ] + def clean(self): + """ + Если проект привязан к программе, добавлять коллаборатора можно + только если пользователь — участник этой программы. + (Проект привязан максимум к одной программе.) + """ + link = self.project.program_links.select_related("partner_program").first() + if not link: + return + + PartnerProgramUserProfile = apps.get_model( + "partner_programs", + "PartnerProgramUserProfile", + ) + + is_participant = PartnerProgramUserProfile.objects.filter( + user_id=self.user_id, + partner_program_id=link.partner_program_id, + ).exists() + + if not is_participant: + raise ValidationError( + "Пользователь не является участником программы, к которой относится проект." + ) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + class ProjectNews(models.Model): """ diff --git a/projects/permissions.py b/projects/permissions.py index 4a18a68a..18b28556 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -1,12 +1,11 @@ -from datetime import timedelta, datetime +from datetime import datetime, timedelta from django.utils import timezone +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.permissions import SAFE_METHODS, BasePermission -from rest_framework.permissions import BasePermission, SAFE_METHODS -from rest_framework.exceptions import PermissionDenied - +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from projects.models import Project -from partner_programs.models import PartnerProgramUserProfile class IsProjectLeaderOrReadOnlyForNonDrafts(BasePermission): @@ -79,6 +78,7 @@ class TimingAfterEndsProgramPermission(BasePermission): for `_SECONDS_AFTER_CANT_EDIT` seconds -> days from the end of the program. If the project is not in program or the request in `SAFE_METHODS` -> allowed. """ + _SECONDS_AFTER_CANT_EDIT: int = 60 * 60 * 24 * 30 # Now 30 days. def has_object_permission(self, request, view, obj) -> bool: @@ -86,22 +86,29 @@ def has_object_permission(self, request, view, obj) -> bool: return True program_profile = ( - PartnerProgramUserProfile.objects - .filter(user=request.user, project=obj) + PartnerProgramUserProfile.objects.filter(user=request.user, project=obj) .select_related("partner_program") .first() ) moscow_time: datetime = timezone.localtime(timezone.now()) if program_profile: - date_from_end_program: timedelta = (moscow_time - program_profile.partner_program.datetime_finished) + date_from_end_program: timedelta = ( + moscow_time - program_profile.partner_program.datetime_finished + ) days_from_end_program: int = date_from_end_program.days seconds_from_end_program: int = date_from_end_program.total_seconds() if 0 <= seconds_from_end_program <= self._SECONDS_AFTER_CANT_EDIT: - raise PermissionDenied(detail=self._prepare_exception_detail(days_from_end_program, program_profile)) + raise PermissionDenied( + detail=self._prepare_exception_detail( + days_from_end_program, program_profile + ) + ) return True - def _prepare_exception_detail(self, days_from_end_program: int, program_profile: PartnerProgramUserProfile): + def _prepare_exception_detail( + self, days_from_end_program: int, program_profile: PartnerProgramUserProfile + ): """ Prepare response body when `PermissionDenied` exception raised: program_name: str -> Program title @@ -112,7 +119,11 @@ def _prepare_exception_detail(self, days_from_end_program: int, program_profile: when_can_edit: datetime = timezone.localtime( datetime_finished + timedelta(seconds=self._SECONDS_AFTER_CANT_EDIT) ) - days_until_resolution: int = int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24) - days_from_end_program - 1 + days_until_resolution: int = ( + int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24) + - days_from_end_program + - 1 + ) return { "program_name": program_profile.partner_program.name, "when_can_edit": when_can_edit, @@ -140,3 +151,24 @@ def has_object_permission(self, request, view, obj): ) or obj.project.leader == request.user: return True return False + + +class CanBindProjectToProgram(BasePermission): + message = "Привязать проект к программе может только её участник (или менеджер)." + + def has_permission(self, request, view): + program_id = (request.data or {}).get("partner_program_id") + if not program_id: + return True + + try: + program = PartnerProgram.objects.get(pk=program_id) + except PartnerProgram.DoesNotExist: + raise ValidationError({"partner_program_id": "Программа не найдена."}) + + if program.is_manager(request.user): + return True + + return PartnerProgramUserProfile.objects.filter( + user=request.user, partner_program=program + ).exists() diff --git a/projects/serializers.py b/projects/serializers.py index b6e409bf..abb9bcae 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -387,7 +387,7 @@ def validate(self, data): if project.leader != request.user: raise serializers.ValidationError( - "Только лидер проекта может дублировать его в программу." + {"error": "Только лидер проекта может дублировать его в программу."} ) try: diff --git a/projects/views.py b/projects/views.py index 7fbdce6b..c843b61f 100644 --- a/projects/views.py +++ b/projects/views.py @@ -33,6 +33,7 @@ from projects.models import Achievement, Collaborator, Project, ProjectNews from projects.pagination import ProjectNewsPagination, ProjectsPagination from projects.permissions import ( + CanBindProjectToProgram, HasInvolvementInProjectOrReadOnly, IsNewsAuthorIsProjectLeaderOrReadOnly, IsProjectLeader, @@ -659,7 +660,7 @@ def patch(self, request, project_pk: int, user_to_leader_pk: int) -> Response: class DuplicateProjectView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, CanBindProjectToProgram] @swagger_auto_schema( request_body=ProjectDuplicateRequestSerializer, From 9499c6d682c3cae557fc580f5644f844f3e5fcf7 Mon Sep 17 00:00:00 2001 From: Toksi86 Date: Mon, 8 Sep 2025 13:26:39 +0500 Subject: [PATCH 05/13] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20=D1=86=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/admin.py | 31 +++++++++++ projects/migrations/0029_projectgoal.py | 60 +++++++++++++++++++++ projects/models.py | 43 +++++++++++++++ projects/permissions.py | 69 +++++++++++++++++++++---- projects/serializers.py | 27 +++++++++- projects/urls.py | 17 +++++- projects/views.py | 27 +++++++++- 7 files changed, 260 insertions(+), 14 deletions(-) create mode 100644 projects/migrations/0029_projectgoal.py diff --git a/projects/admin.py b/projects/admin.py index cc30ab34..d3a293b7 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -6,11 +6,20 @@ DefaultProjectAvatar, DefaultProjectCover, Project, + ProjectGoal, ProjectLink, ProjectNews, ) +class ProjectGoalInline(admin.TabularInline): + model = ProjectGoal + extra = 0 + fields = ("title", "completion_date", "responsible", "is_done") + show_change_link = True + autocomplete_fields = ("responsible",) + + @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): list_display = ( @@ -76,6 +85,28 @@ class ProjectAdmin(admin.ModelAdmin): ), ) readonly_fields = ("datetime_created", "datetime_updated") + inlines = [ProjectGoalInline] + + +@admin.register(ProjectGoal) +class ProjectGoalAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "project", + "completion_date", + "responsible", + "is_done", + ) + list_filter = ("is_done", "completion_date", "project") + search_fields = ( + "title", + "project__name", + "responsible__username", + "responsible__email", + ) + list_select_related = ("project", "responsible") + autocomplete_fields = ("project", "responsible") @admin.register(ProjectNews) diff --git a/projects/migrations/0029_projectgoal.py b/projects/migrations/0029_projectgoal.py new file mode 100644 index 00000000..884dd200 --- /dev/null +++ b/projects/migrations/0029_projectgoal.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.11 on 2025-09-08 06:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projects", "0028_remove_project_direction_remove_project_goal_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectGoal", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255, verbose_name="Название цели")), + ( + "completion_date", + models.DateField( + blank=True, null=True, verbose_name="Срок реализации цели" + ), + ), + ("is_done", models.BooleanField(default=False, verbose_name="Выполнено")), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="goals", + to="projects.project", + verbose_name="Проект", + ), + ), + ( + "responsible", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="responsible_goals", + to=settings.AUTH_USER_MODEL, + verbose_name="Ответственный", + ), + ), + ], + options={ + "verbose_name": "Цель", + "verbose_name_plural": "Цели", + }, + ), + ] diff --git a/projects/models.py b/projects/models.py index 9bc99bfc..bd9dffb0 100644 --- a/projects/models.py +++ b/projects/models.py @@ -352,3 +352,46 @@ class Meta: verbose_name = "Новость проекта" verbose_name_plural = "Новости проекта" ordering = ["-datetime_created"] + + +class ProjectGoal(models.Model): + """ + Цель проекта (минимальная версия). + """ + + project = models.ForeignKey( + "Project", + on_delete=models.CASCADE, + related_name="goals", + verbose_name="Проект", + ) + + title = models.CharField( + max_length=255, + verbose_name="Название цели", + ) + + completion_date = models.DateField( + null=True, + blank=True, + verbose_name="Срок реализации цели", + ) + + responsible = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="responsible_goals", + verbose_name="Ответственный", + ) + + is_done = models.BooleanField( + default=False, + verbose_name="Выполнено", + ) + + def __str__(self) -> str: + return f"Проект [{self.project_id}] - {self.title}" + + class Meta: + verbose_name = "Цель" + verbose_name_plural = "Цели" diff --git a/projects/permissions.py b/projects/permissions.py index 4a18a68a..fe17ea9e 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -1,12 +1,11 @@ -from datetime import timedelta, datetime +from datetime import datetime, timedelta from django.utils import timezone - -from rest_framework.permissions import BasePermission, SAFE_METHODS from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import SAFE_METHODS, BasePermission -from projects.models import Project from partner_programs.models import PartnerProgramUserProfile +from projects.models import Project class IsProjectLeaderOrReadOnlyForNonDrafts(BasePermission): @@ -79,6 +78,7 @@ class TimingAfterEndsProgramPermission(BasePermission): for `_SECONDS_AFTER_CANT_EDIT` seconds -> days from the end of the program. If the project is not in program or the request in `SAFE_METHODS` -> allowed. """ + _SECONDS_AFTER_CANT_EDIT: int = 60 * 60 * 24 * 30 # Now 30 days. def has_object_permission(self, request, view, obj) -> bool: @@ -86,22 +86,29 @@ def has_object_permission(self, request, view, obj) -> bool: return True program_profile = ( - PartnerProgramUserProfile.objects - .filter(user=request.user, project=obj) + PartnerProgramUserProfile.objects.filter(user=request.user, project=obj) .select_related("partner_program") .first() ) moscow_time: datetime = timezone.localtime(timezone.now()) if program_profile: - date_from_end_program: timedelta = (moscow_time - program_profile.partner_program.datetime_finished) + date_from_end_program: timedelta = ( + moscow_time - program_profile.partner_program.datetime_finished + ) days_from_end_program: int = date_from_end_program.days seconds_from_end_program: int = date_from_end_program.total_seconds() if 0 <= seconds_from_end_program <= self._SECONDS_AFTER_CANT_EDIT: - raise PermissionDenied(detail=self._prepare_exception_detail(days_from_end_program, program_profile)) + raise PermissionDenied( + detail=self._prepare_exception_detail( + days_from_end_program, program_profile + ) + ) return True - def _prepare_exception_detail(self, days_from_end_program: int, program_profile: PartnerProgramUserProfile): + def _prepare_exception_detail( + self, days_from_end_program: int, program_profile: PartnerProgramUserProfile + ): """ Prepare response body when `PermissionDenied` exception raised: program_name: str -> Program title @@ -112,7 +119,11 @@ def _prepare_exception_detail(self, days_from_end_program: int, program_profile: when_can_edit: datetime = timezone.localtime( datetime_finished + timedelta(seconds=self._SECONDS_AFTER_CANT_EDIT) ) - days_until_resolution: int = int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24) - days_from_end_program - 1 + days_until_resolution: int = ( + int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24) + - days_from_end_program + - 1 + ) return { "program_name": program_profile.partner_program.name, "when_can_edit": when_can_edit, @@ -140,3 +151,41 @@ def has_object_permission(self, request, view, obj): ) or obj.project.leader == request.user: return True return False + + +class IsProjectLeaderOrReadOnly(BasePermission): + """ + Читать могут все (в т.ч. анонимы). + Создавать/изменять/удалять может только лидер проекта. + """ + + message = "Только лидер проекта может создавать, изменять или удалять цели." + + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + + if not request.user or not request.user.is_authenticated: + return False + + project_pk = view.kwargs.get("project_pk") + project_id = project_pk or request.data.get("project") + if not project_id: + return False + + try: + project = Project.objects.only("id", "leader_id").get(pk=project_id) + except Project.DoesNotExist: + return False + + return project.leader_id == request.user.id + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + + return ( + request.user + and request.user.is_authenticated + and obj.project.leader_id == request.user.id + ) diff --git a/projects/serializers.py b/projects/serializers.py index b6e409bf..94cf0fc8 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -19,7 +19,7 @@ PartnerProgramFieldSerializer, PartnerProgramFieldValueSerializer, ) -from projects.models import Achievement, Collaborator, Project, ProjectNews +from projects.models import Achievement, Collaborator, Project, ProjectGoal, ProjectNews from projects.validators import validate_project from vacancy.serializers import ProjectVacancyListSerializer @@ -109,6 +109,31 @@ def get_program_field_values(self, obj): return PartnerProgramFieldValueSerializer(values_qs, many=True).data +class ResponsibleMiniSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("id", "first_name", "last_name", "avatar") + + +class ProjectGoalSerializer(serializers.ModelSerializer): + project = serializers.PrimaryKeyRelatedField(read_only=True) + responsible = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) + responsible_info = ResponsibleMiniSerializer(source="responsible", read_only=True) + + class Meta: + model = ProjectGoal + fields = [ + "id", + "project", + "title", + "completion_date", + "responsible", + "responsible_info", + "is_done", + ] + read_only_fields = ["id", "project", "responsible_info"] + + class ProjectDetailSerializer(serializers.ModelSerializer): achievements = AchievementListSerializer(many=True, read_only=True) cover = UserFileSerializer(required=False) diff --git a/projects/urls.py b/projects/urls.py index 046b13d2..f06af3ec 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -6,6 +6,7 @@ AchievementDetail, AchievementList, DuplicateProjectView, + GoalViewSet, LeaveProject, ProjectCollaborators, ProjectCountView, @@ -21,7 +22,15 @@ ) app_name = "projects" - +project_goal_list = GoalViewSet.as_view({"get": "list", "post": "create"}) +project_goal_detail = GoalViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } +) urlpatterns = [ path("", ProjectList.as_view()), path("/like/", SetLikeOnProject.as_view()), @@ -29,6 +38,12 @@ path("/subscribe/", ProjectSubscribe.as_view()), path("/unsubscribe/", ProjectUnsubscribe.as_view()), path("/subscribers/", ProjectSubscribers.as_view()), + path("/goals/", project_goal_list, name="project-goals"), + path( + "/goals//", + project_goal_detail, + name="project-goal-detail", + ), path("/news//", NewsDetail.as_view()), path("/news//set_viewed/", NewsDetailSetViewed.as_view()), path("/news//set_liked/", NewsDetailSetLiked.as_view()), diff --git a/projects/views.py b/projects/views.py index 7fbdce6b..2978f0b9 100644 --- a/projects/views.py +++ b/projects/views.py @@ -9,7 +9,7 @@ from django_filters import rest_framework as filters from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from rest_framework import generics, permissions, status +from rest_framework import generics, permissions, status, viewsets from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -30,12 +30,13 @@ get_recommended_users, update_partner_program, ) -from projects.models import Achievement, Collaborator, Project, ProjectNews +from projects.models import Achievement, Collaborator, Project, ProjectGoal, ProjectNews from projects.pagination import ProjectNewsPagination, ProjectsPagination from projects.permissions import ( HasInvolvementInProjectOrReadOnly, IsNewsAuthorIsProjectLeaderOrReadOnly, IsProjectLeader, + IsProjectLeaderOrReadOnly, IsProjectLeaderOrReadOnlyForNonDrafts, TimingAfterEndsProgramPermission, ) @@ -45,6 +46,7 @@ ProjectCollaboratorSerializer, ProjectDetailSerializer, ProjectDuplicateRequestSerializer, + ProjectGoalSerializer, ProjectListSerializer, ProjectNewsDetailSerializer, ProjectNewsListSerializer, @@ -710,3 +712,24 @@ def post(self, request): }, status=status.HTTP_201_CREATED, ) + + +class GoalViewSet(viewsets.ModelViewSet): + queryset = ProjectGoal.objects.select_related("project", "responsible") + serializer_class = ProjectGoalSerializer + permission_classes = [IsProjectLeaderOrReadOnly] + + def get_queryset(self): + qs = super().get_queryset() + project_pk = self.kwargs.get("project_pk") + return qs.filter(project_id=project_pk) if project_pk is not None else qs + + def perform_create(self, serializer): + project_pk = self.kwargs.get("project_pk") + if project_pk is None: + serializer.save() + else: + serializer.save(project_id=project_pk) + + def perform_update(self, serializer): + serializer.save(project=self.get_object().project) From 7e61a1f54a0e9ac5df1d412c90bbae14ea602bee Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 1 Oct 2025 15:26:33 +0500 Subject: [PATCH 06/13] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8=D0=BD=D1=82?= =?UTF-8?q?,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20=D0=BC=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D0=B2=20=D1=86=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B2=20?= =?UTF-8?q?POST=20/projects/{project=5Fpk}/goals/=20=D0=B8=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D1=91=D1=82=20=D0=B8=D1=85=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20bulk=5Fcreate.=20=D0=9E=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D1=8B=D0=B5=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20(GET/PUT/PATCH/DELETE)=20=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=B8=D1=81=D1=8C=20=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=D0=BD=D1=8B=20=D0=BF=D0=BE=20pk.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/serializers.py | 21 +++++++++++-- projects/urls.py | 12 +++++--- projects/views.py | 68 ++++++++++++++++++++++++++--------------- 3 files changed, 70 insertions(+), 31 deletions(-) diff --git a/projects/serializers.py b/projects/serializers.py index a8795793..23894af3 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -26,6 +26,10 @@ User = get_user_model() +class EmptySerializer(serializers.Serializer): + pass + + class AchievementListSerializer(serializers.ModelSerializer): class Meta: model = Achievement @@ -115,8 +119,18 @@ class Meta: fields = ("id", "first_name", "last_name", "avatar") +class ProjectGoalBulkListSerializer(serializers.ListSerializer): + """Bulk_create при POST запросе со списком объектов на /projects/{project_pk}/goals/.""" + + def create(self, validated_data): + project = self.context["project"] + objs = [ProjectGoal(project=project, **item) for item in validated_data] + created = ProjectGoal.objects.bulk_create(objs) + return created + + class ProjectGoalSerializer(serializers.ModelSerializer): - project = serializers.PrimaryKeyRelatedField(read_only=True) + project_id = serializers.IntegerField(read_only=True) responsible = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) responsible_info = ResponsibleMiniSerializer(source="responsible", read_only=True) @@ -124,14 +138,15 @@ class Meta: model = ProjectGoal fields = [ "id", - "project", + "project_id", "title", "completion_date", "responsible", "responsible_info", "is_done", ] - read_only_fields = ["id", "project", "responsible_info"] + read_only_fields = ["id", "project_id", "responsible_info"] + list_serializer_class = ProjectGoalBulkListSerializer class ProjectDetailSerializer(serializers.ModelSerializer): diff --git a/projects/urls.py b/projects/urls.py index f06af3ec..d0ddf8cd 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -22,7 +22,13 @@ ) app_name = "projects" -project_goal_list = GoalViewSet.as_view({"get": "list", "post": "create"}) +project_goal_list = GoalViewSet.as_view( + { + "get": "list", + "post": "create", + } +) + project_goal_detail = GoalViewSet.as_view( { "get": "retrieve", @@ -55,9 +61,7 @@ ), path("/", ProjectDetail.as_view()), path("/recommended_users", ProjectRecommendedUsers.as_view()), - path( - "assign-to-program/", DuplicateProjectView.as_view(), name="duplicate-project" - ), + path("assign-to-program/", DuplicateProjectView.as_view(), name="duplicate-project"), path( "/program-fields/", PartnerProgramFieldValueBulkUpdateView.as_view(), diff --git a/projects/views.py b/projects/views.py index fd8d54ee..4f5c6475 100644 --- a/projects/views.py +++ b/projects/views.py @@ -44,6 +44,7 @@ from projects.serializers import ( AchievementDetailSerializer, AchievementListSerializer, + EmptySerializer, ProjectCollaboratorSerializer, ProjectDetailSerializer, ProjectDuplicateRequestSerializer, @@ -90,9 +91,7 @@ def create(self, request, *args, **kwargs): try: partner_program_id = request.data.get("partner_program_id") - update_partner_program( - partner_program_id, request.user, serializer.instance - ) + update_partner_program(partner_program_id, request.user, serializer.instance) except PartnerProgram.DoesNotExist: return Response( {"detail": "Partner program with this id does not exist"}, @@ -105,9 +104,7 @@ def create(self, request, *args, **kwargs): ) headers = self.get_success_headers(serializer.data) - return Response( - serializer.data, status=status.HTTP_201_CREATED, headers=headers - ) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def post(self, request, *args, **kwargs): """ @@ -241,9 +238,7 @@ def get(self, request): { "all": self.get_queryset().filter(draft=False).count(), "my": self.get_queryset() - .filter( - Q(leader_id=request.user.id) | Q(collaborator__user=request.user) - ) + .filter(Q(leader_id=request.user.id) | Q(collaborator__user=request.user)) .distinct() .count(), }, @@ -313,9 +308,7 @@ def _project_data( return project.id, project.leader.id @staticmethod - def _collabs_queryset( - project_id: int, requested_id: int, leader_id: int - ) -> QuerySet: + def _collabs_queryset(project_id: int, requested_id: int, leader_id: int) -> QuerySet: return Collaborator.objects.exclude( user__id=leader_id ).get( # чтоб случайно лидер сам себя не удалил @@ -586,9 +579,7 @@ def _project_data( return project.id, project.leader.id @staticmethod - def _collabs_queryset( - project_id: int, requested_id: int, leader_id: int - ) -> QuerySet: + def _collabs_queryset(project_id: int, requested_id: int, leader_id: int) -> QuerySet: return Collaborator.objects.exclude( user__id=leader_id ).get( # чтоб случайно лидер сам себя не удалил @@ -626,6 +617,7 @@ def delete(self, request, project_pk: int) -> Response: class SwitchLeaderRole(generics.GenericAPIView): permission_classes = [IsProjectLeader] queryset = Project.objects.all().select_related("leader") + serializer_class = EmptySerializer @staticmethod def _get_new_leader(user_id: int, project: Project) -> Collaborator: @@ -677,9 +669,7 @@ def post(self, request): data = serializer.validated_data original_project = get_object_or_404(Project, id=data["project_id"]) - partner_program = get_object_or_404( - PartnerProgram, id=data["partner_program_id"] - ) + partner_program = get_object_or_404(PartnerProgram, id=data["partner_program_id"]) with transaction.atomic(): new_project = Project.objects.create( @@ -721,16 +711,46 @@ class GoalViewSet(viewsets.ModelViewSet): permission_classes = [IsProjectLeaderOrReadOnly] def get_queryset(self): - qs = super().get_queryset() project_pk = self.kwargs.get("project_pk") + qs = super().get_queryset() return qs.filter(project_id=project_pk) if project_pk is not None else qs - def perform_create(self, serializer): + def get_serializer_context(self): + ctx = super().get_serializer_context() project_pk = self.kwargs.get("project_pk") - if project_pk is None: - serializer.save() - else: - serializer.save(project_id=project_pk) + if project_pk and "project" not in ctx: + ctx["project"] = get_object_or_404(Project, pk=project_pk) + return ctx + + @swagger_auto_schema( + request_body=ProjectGoalSerializer(many=True), + responses={201: ProjectGoalSerializer(many=True)}, + ) + def create(self, request, *args, **kwargs): + if not isinstance(request.data, list): + return Response( + {"detail": "В теле запроса должен быть массив целей."}, status=400 + ) + serializer = self.get_serializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + created = serializer.save() + out = self.get_serializer(created, many=True) + return Response(out.data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + if isinstance(request.data, list): + return Response( + {"detail": "Обновление выполняется для одной цели по её ID."}, status=400 + ) + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + if isinstance(request.data, list): + return Response( + {"detail": "Частичное обновление выполняется для одной цели по её ID."}, + status=400, + ) + return super().partial_update(request, *args, **kwargs) def perform_update(self, serializer): serializer.save(project=self.get_object().project) From 888d4fe17a3a51c59029816f9d0816b37b6f02f6 Mon Sep 17 00:00:00 2001 From: Toksi Date: Thu, 9 Oct 2025 13:11:27 +0500 Subject: [PATCH 07/13] =?UTF-8?q?=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20"=D0=9A=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8",=20?= =?UTF-8?q?"=D0=A0=D0=B5=D1=81=D1=83=D1=80=D1=81=D1=8B",=20"=D0=9A=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8=D0=9F=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/admin.py | 51 +++- ...ojectcompany_project_companies_and_more.py | 161 ++++++++++++ projects/models.py | 118 +++++++++ projects/serializers.py | 139 +++++++++- projects/urls.py | 44 ++++ projects/validators.py | 7 + projects/views.py | 242 +++++++++++++++++- 7 files changed, 756 insertions(+), 6 deletions(-) create mode 100644 projects/migrations/0030_company_resource_projectcompany_project_companies_and_more.py diff --git a/projects/admin.py b/projects/admin.py index d3a293b7..6f5e8e75 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -3,12 +3,15 @@ from projects.models import ( Achievement, Collaborator, + Company, DefaultProjectAvatar, DefaultProjectCover, Project, + ProjectCompany, ProjectGoal, ProjectLink, ProjectNews, + Resource, ) @@ -20,6 +23,31 @@ class ProjectGoalInline(admin.TabularInline): autocomplete_fields = ("responsible",) +class ProjectCompanyInline(admin.TabularInline): + model = ProjectCompany + extra = 1 + autocomplete_fields = ("company", "decision_maker") + fields = ("company", "contribution", "decision_maker") + verbose_name = "Партнёр проекта" + verbose_name_plural = "Партнёры проекта" + + +class ResourceInline(admin.StackedInline): + model = Resource + extra = 0 + fields = ("type", "description", "partner_company") + show_change_link = True + verbose_name = "Ресурс" + verbose_name_plural = "Ресурсы" + + def get_formset(self, request, obj=None, **kwargs): + formset = super().get_formset(request, obj, **kwargs) + if obj is not None: + qs = obj.companies.all() + formset.form.base_fields["partner_company"].queryset = qs + return formset + + @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): list_display = ( @@ -85,7 +113,28 @@ class ProjectAdmin(admin.ModelAdmin): ), ) readonly_fields = ("datetime_created", "datetime_updated") - inlines = [ProjectGoalInline] + inlines = [ProjectGoalInline, ProjectCompanyInline, ResourceInline] + + +@admin.register(Company) +class CompanyAdmin(admin.ModelAdmin): + list_display = ("id", "name", "inn") + list_display_links = ("id", "name") + search_fields = ("name", "inn") + list_filter = () + ordering = ("name",) + readonly_fields = () + fieldsets = ( + ( + "Компания", + { + "fields": ( + "name", + "inn", + ) + }, + ), + ) @admin.register(ProjectGoal) diff --git a/projects/migrations/0030_company_resource_projectcompany_project_companies_and_more.py b/projects/migrations/0030_company_resource_projectcompany_project_companies_and_more.py new file mode 100644 index 00000000..94a0210e --- /dev/null +++ b/projects/migrations/0030_company_resource_projectcompany_project_companies_and_more.py @@ -0,0 +1,161 @@ +# Generated by Django 4.2.24 on 2025-10-06 09:13 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projects", "0029_projectgoal"), + ] + + operations = [ + migrations.CreateModel( + name="Company", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "inn", + models.CharField( + max_length=12, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="ИНН должен содержать 10 или 12 цифр.", + regex="^\\d{10}(\\d{2})?$", + ) + ], + ), + ), + ], + options={ + "verbose_name": "Компания", + "verbose_name_plural": "Компании", + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="Resource", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("infrastructure", "Инфраструктурный"), + ("staff", "Кадровый"), + ("financial", "Финансовый"), + ("information", "Информационный"), + ], + max_length=32, + ), + ), + ("description", models.TextField()), + ( + "partner_company", + models.ForeignKey( + blank=True, + help_text="Если не указано — ресурс в поиске партнёра.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="resources", + to="projects.company", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="resources", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "Ресурс", + "verbose_name_plural": "Ресурсы", + "ordering": ["project", "type", "id"], + }, + ), + migrations.CreateModel( + name="ProjectCompany", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("contribution", models.TextField(blank=True)), + ( + "company", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_links", + to="projects.company", + ), + ), + ( + "decision_maker", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="partner_decisions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_companies", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "Связь проекта и компании", + "verbose_name_plural": "Связи проекта и компании", + }, + ), + migrations.AddField( + model_name="project", + name="companies", + field=models.ManyToManyField( + related_name="projects", + through="projects.ProjectCompany", + to="projects.company", + ), + ), + migrations.AddConstraint( + model_name="projectcompany", + constraint=models.UniqueConstraint( + fields=("project", "company"), name="uq_project_company_unique_pair" + ), + ), + ] diff --git a/projects/models.py b/projects/models.py index bd1f0df2..b36cd38e 100644 --- a/projects/models.py +++ b/projects/models.py @@ -16,6 +16,7 @@ from files.models import UserFile from industries.models import Industry from projects.managers import AchievementManager, CollaboratorManager, ProjectManager +from projects.validators import inn_validator from users.models import CustomUser User = get_user_model() @@ -170,6 +171,12 @@ class Project(models.Model): User, verbose_name="Подписчики", related_name="subscribed_projects" ) + companies = models.ManyToManyField( + "Company", + through="ProjectCompany", + related_name="projects", + ) + datetime_created = models.DateTimeField( verbose_name="Дата создания", null=False, auto_now_add=True ) @@ -426,3 +433,114 @@ def __str__(self) -> str: class Meta: verbose_name = "Цель" verbose_name_plural = "Цели" + + +class Company(models.Model): + name = models.CharField(max_length=255) + inn = models.CharField(max_length=12, unique=True, validators=[inn_validator]) + + class Meta: + verbose_name = "Компания" + verbose_name_plural = "Компании" + ordering = ["name"] + + def __str__(self): + return f"{self.name} ({self.inn})" + + +class ProjectCompany(models.Model): + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="project_companies", + ) + company = models.ForeignKey( + Company, + on_delete=models.CASCADE, + related_name="project_links", + ) + contribution = models.TextField(blank=True) + decision_maker = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="partner_decisions", + ) + + class Meta: + verbose_name = "Связь проекта и компании" + verbose_name_plural = "Связи проекта и компании" + constraints = [ + models.UniqueConstraint( + fields=["project", "company"], + name="uq_project_company_unique_pair", + ), + ] + + def __str__(self): + return f"{self.project} - {self.company}" + + +class Resource(models.Model): + class ResourceType(models.TextChoices): + INFRASTRUCTURE = "infrastructure", "Инфраструктурный" + STAFF = "staff", "Кадровый" + FINANCIAL = "financial", "Финансовый" + INFORMATION = "information", "Информационный" + + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="resources", + ) + type = models.CharField( + max_length=32, + choices=ResourceType.choices, + ) + description = models.TextField() + + partner_company = models.ForeignKey( + Company, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="resources", + help_text="Если не указано — ресурс в поиске партнёра.", + ) + + class Meta: + verbose_name = "Ресурс" + verbose_name_plural = "Ресурсы" + ordering = ["project", "type", "id"] + + def __str__(self): + base = f"{self.get_type_display()} ресурс для {self.project}" + return f"{base} — {self.partner_display}" + + @property + def partner_display(self): + return ( + self.partner_company.name + if self.partner_company + else "в поиске партнёра для данного ресурса" + ) + + def clean(self): + """ + Проверяет, что выбранная partner_company действительно является партнёром проекта. + """ + super().clean() + if self.partner_company: + exists = ProjectCompany.objects.filter( + project=self.project, company=self.partner_company + ).exists() + if not exists: + raise ValidationError( + { + "partner_company": ( + "Эта компания не является партнёром данного проекта. " + "Сначала добавьте её в партнёры проекта." + ) + } + ) diff --git a/projects/serializers.py b/projects/serializers.py index 23894af3..a80c192c 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.core.cache import cache +from django.db import transaction from rest_framework import serializers from core.serializers import SkillToObjectSerializer @@ -19,7 +20,16 @@ PartnerProgramFieldSerializer, PartnerProgramFieldValueSerializer, ) -from projects.models import Achievement, Collaborator, Project, ProjectGoal, ProjectNews +from projects.models import ( + Achievement, + Collaborator, + Company, + Project, + ProjectCompany, + ProjectGoal, + ProjectNews, + Resource, +) from projects.validators import validate_project from vacancy.serializers import ProjectVacancyListSerializer @@ -494,9 +504,7 @@ def _validate_text(self, field, value, attrs): ) else: if value is not None and not isinstance(value, str): - raise serializers.ValidationError( - "Ожидается строка для текстового поля." - ) + raise serializers.ValidationError("Ожидается строка для текстового поля.") def _validate_checkbox(self, field, value, attrs): if field.is_required and value in (None, ""): @@ -575,3 +583,126 @@ def _is_valid_url(self, url: str) -> bool: return parsed.scheme in ("http", "https") and bool(parsed.netloc) except Exception: return False + + +class CompanySerializer(serializers.ModelSerializer): + class Meta: + model = Company + fields = ("id", "name", "inn") + read_only_fields = ("id",) + + +class ProjectCompanySerializer(serializers.ModelSerializer): + company = CompanySerializer() + project = serializers.PrimaryKeyRelatedField(read_only=True) + decision_maker = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta: + model = ProjectCompany + fields = ("id", "project", "company", "contribution", "decision_maker") + + +class ResourceSerializer(serializers.ModelSerializer): + project_id = serializers.PrimaryKeyRelatedField( + source="project", queryset=Project.objects.all(), write_only=True + ) + + class Meta: + model = Resource + fields = ( + "id", + "project_id", + "project", + "type", + "description", + "partner_company", + ) + read_only_fields = ("id", "project") + + def validate(self, attrs): + project = attrs.get("project", getattr(self.instance, "project", None)) + partner_company = attrs.get( + "partner_company", getattr(self.instance, "partner_company", None) + ) + if project and partner_company: + exists = ProjectCompany.objects.filter( + project=project, company=partner_company + ).exists() + if not exists: + raise serializers.ValidationError( + { + "partner_company": "Эта компания не является партнёром данного проекта." + } + ) + return attrs + + def create(self, validated_data): + obj = Resource(**validated_data) + obj.full_clean() + obj.save() + return obj + + def update(self, instance, validated_data): + for key, value in validated_data.items(): + setattr(instance, key, value) + instance.full_clean() + instance.save() + return instance + + +class ProjectCompanyUpsertSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255) + inn = serializers.RegexField(regex=r"^\d{10}(\d{2})?$") + + contribution = serializers.CharField(allow_blank=True, required=False, default="") + decision_maker = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), allow_null=True, required=False, default=None + ) + + def validate(self, attrs): + return attrs + + @transaction.atomic + def create(self, validated_data): + project = self.context["project"] + name = validated_data["name"].strip() + inn = validated_data["inn"] + contribution = validated_data.get("contribution", "") + decision_maker = validated_data.get("decision_maker", None) + + company, _ = Company.objects.get_or_create( + inn=inn, + defaults={"name": name}, + ) + + link, created = ProjectCompany.objects.get_or_create( + project=project, + company=company, + defaults={"contribution": contribution, "decision_maker": decision_maker}, + ) + if not created: + updated = False + if "contribution" in self.initial_data: + link.contribution = contribution + updated = True + if "decision_maker" in self.initial_data: + link.decision_maker = decision_maker + updated = True + if updated: + link.save() + + return link + + def to_representation(self, instance: ProjectCompany): + return ProjectCompanySerializer(instance).data + + +class ProjectCompanyUpdateSerializer(serializers.ModelSerializer): + contribution = serializers.CharField(allow_blank=True, required=False) + decision_maker = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), allow_null=True, required=False + ) + + class Meta: + model = ProjectCompany + fields = ("contribution", "decision_maker") diff --git a/projects/urls.py b/projects/urls.py index d0ddf8cd..b9810e61 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -9,6 +9,9 @@ GoalViewSet, LeaveProject, ProjectCollaborators, + ProjectCompaniesListView, + ProjectCompanyDetailView, + ProjectCompanyUpsertView, ProjectCountView, ProjectDetail, ProjectList, @@ -17,6 +20,7 @@ ProjectSubscribers, ProjectUnsubscribe, ProjectVacancyResponses, + ResourceViewSet, SetLikeOnProject, SwitchLeaderRole, ) @@ -37,6 +41,21 @@ "delete": "destroy", } ) +project_resource_list = ResourceViewSet.as_view( + { + "get": "list", + "post": "create", + } +) + +project_resource_detail = ResourceViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } +) urlpatterns = [ path("", ProjectList.as_view()), path("/like/", SetLikeOnProject.as_view()), @@ -45,6 +64,31 @@ path("/unsubscribe/", ProjectUnsubscribe.as_view()), path("/subscribers/", ProjectSubscribers.as_view()), path("/goals/", project_goal_list, name="project-goals"), + path( + "/resources/", + project_resource_list, + name="project-resources", + ), + path( + "/resources//", + project_resource_detail, + name="project-resource-detail", + ), + path( + "/companies/", + ProjectCompanyUpsertView.as_view(), + name="project-company-upsert", + ), + path( + "/companies//", + ProjectCompanyDetailView.as_view(), + name="project-company-detail", + ), + path( + "/companies/list/", + ProjectCompaniesListView.as_view(), + name="project-companies-list", + ), path( "/goals//", project_goal_detail, diff --git a/projects/validators.py b/projects/validators.py index f14e813c..98dd75e6 100644 --- a/projects/validators.py +++ b/projects/validators.py @@ -1,3 +1,4 @@ +from django.core.validators import RegexValidator from rest_framework.serializers import ValidationError @@ -11,3 +12,9 @@ def validate_project(data): if error: raise ValidationError(error) return data + + +inn_validator = RegexValidator( + regex=r"^\d{10}(\d{2})?$", + message="ИНН должен содержать 10 или 12 цифр.", +) diff --git a/projects/views.py b/projects/views.py index 4f5c6475..78d45861 100644 --- a/projects/views.py +++ b/projects/views.py @@ -11,6 +11,7 @@ from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, permissions, status, viewsets from rest_framework.exceptions import NotFound +from rest_framework.generics import ListAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -30,7 +31,16 @@ get_recommended_users, update_partner_program, ) -from projects.models import Achievement, Collaborator, Project, ProjectGoal, ProjectNews +from projects.models import ( + Achievement, + Collaborator, + Company, + Project, + ProjectCompany, + ProjectGoal, + ProjectNews, + Resource, +) from projects.pagination import ProjectNewsPagination, ProjectsPagination from projects.permissions import ( CanBindProjectToProgram, @@ -44,8 +54,12 @@ from projects.serializers import ( AchievementDetailSerializer, AchievementListSerializer, + CompanySerializer, EmptySerializer, ProjectCollaboratorSerializer, + ProjectCompanySerializer, + ProjectCompanyUpdateSerializer, + ProjectCompanyUpsertSerializer, ProjectDetailSerializer, ProjectDuplicateRequestSerializer, ProjectGoalSerializer, @@ -53,6 +67,7 @@ ProjectNewsDetailSerializer, ProjectNewsListSerializer, ProjectSubscribersListSerializer, + ResourceSerializer, ) from users.models import LikesOnProject from users.serializers import UserListSerializer @@ -754,3 +769,228 @@ def partial_update(self, request, *args, **kwargs): def perform_update(self, serializer): serializer.save(project=self.get_object().project) + + +class CompanyViewSet(viewsets.ModelViewSet): + queryset = Company.objects.all().order_by("name") + serializer_class = CompanySerializer + permission_classes = (IsProjectLeaderOrReadOnly,) + filterset_fields = ("inn",) + search_fields = ("name", "inn") + + +class ResourceViewSet(viewsets.ModelViewSet): + queryset = Resource.objects.select_related("project", "partner_company").all() + serializer_class = ResourceSerializer + permission_classes = (IsProjectLeaderOrReadOnly,) + filterset_fields = ("type", "project", "partner_company") + search_fields = ("description", "project__name", "partner_company__name") + + def get_queryset(self): + project_pk = self.kwargs.get("project_pk") + queryset = super().get_queryset() + if project_pk is not None: + queryset = queryset.filter(project_id=project_pk) + return queryset + + def perform_create(self, serializer): + project_pk = self.kwargs.get("project_pk") + serializer.save(project_id=project_pk) + + +class ProjectCompanyUpsertView(APIView): + """ + POST /projects//companies/ + Тело: { name, inn, contribution?, decision_maker? } + + Логика: + - если компания с таким inn существует — связываем с проектом (create/get); + - если нет — создаём компанию и тут же связываем. + """ + + permission_classes = (IsProjectLeaderOrReadOnly,) + + @swagger_auto_schema( + request_body=ProjectCompanyUpsertSerializer, + responses={201: ProjectCompanySerializer}, + operation_summary="Создать или привязать компанию к проекту (upsert)", + operation_description="Если компания с таким ИНН уже есть — создаёт/обновляет связь. Если нет — создаёт.", + tags=["Projects • Companies"], + ) + def post(self, request, project_id: int): + try: + project = Project.objects.get(pk=project_id) + except Project.DoesNotExist: + return Response( + {"detail": "Проект не найден."}, status=status.HTTP_404_NOT_FOUND + ) + + serializer = ProjectCompanyUpsertSerializer( + data=request.data, + context={"project": project, "request": request}, + ) + serializer.is_valid(raise_exception=True) + link = serializer.save() + return Response( + serializer.to_representation(link), status=status.HTTP_201_CREATED + ) + + +class ProjectCompaniesListView(ListAPIView): + """ + GET /projects//companies/ + Возвращает список связей (партнёров) проекта с данными компании. + """ + + serializer_class = ProjectCompanySerializer + permission_classes = (IsProjectLeaderOrReadOnly,) + + @swagger_auto_schema( + operation_summary="Список партнёров проекта", + operation_description="Возвращает связи ProjectCompany с вложенными данными компании для указанного проекта.", + responses={200: ProjectCompanySerializer(many=True)}, + tags=["Projects • Companies"], + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + project_id = self.kwargs["project_id"] + return ( + ProjectCompany.objects.select_related("company", "project") + .filter(project_id=project_id) + .order_by("company__name") + ) + + +class ProjectCompanyDetailView(APIView): + """ + PATCH - частично обновляет вклад/ответственного в связи ProjectCompany + DELETE - удаляет только связь; Company остаётся в БД + """ + + permission_classes = (IsProjectLeaderOrReadOnly,) + project_id_param = openapi.Parameter( + "project_id", + openapi.IN_PATH, + description="ID проекта", + type=openapi.TYPE_INTEGER, + required=True, + ) + company_id_param = openapi.Parameter( + "company_id", + openapi.IN_PATH, + description="ID компании", + type=openapi.TYPE_INTEGER, + required=True, + ) + + def _get_link_or_404(self, project_id: int, company_id: int): + try: + project = Project.objects.get(pk=project_id) + except Project.DoesNotExist: + return ( + None, + None, + Response( + {"detail": "Проект не найден."}, status=status.HTTP_404_NOT_FOUND + ), + ) + + try: + company = Company.objects.get(pk=company_id) + except Company.DoesNotExist: + return ( + project, + None, + Response( + {"detail": "Компания не найдена."}, status=status.HTTP_404_NOT_FOUND + ), + ) + + try: + link = ProjectCompany.objects.get(project=project, company=company) + except ProjectCompany.DoesNotExist: + return ( + project, + company, + Response( + {"detail": "Связь проект↔компания не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ), + ) + + return project, company, link + + @swagger_auto_schema( + operation_summary="Обновить вклад и/или ответственного компании в проекте", + operation_description=( + "Позволяет изменить поля связи `ProjectCompany`, такие как `contribution` " + "и `decision_maker`. Компания остаётся без изменений." + ), + manual_parameters=[project_id_param, company_id_param], + request_body=ProjectCompanyUpdateSerializer, + responses={ + 200: ProjectCompanySerializer, + 403: "Недостаточно прав", + 404: "Проект, компания или связь не найдены", + }, + tags=["Projects • Companies"], + ) + def patch(self, request, project_id: int, company_id: int): + project, company, link_or_resp = self._get_link_or_404(project_id, company_id) + if isinstance(link_or_resp, Response): + return link_or_resp + link = link_or_resp + + perm_resp = self._check_permissions(request, project) + if perm_resp: + return perm_resp + + serializer = ProjectCompanyUpdateSerializer( + link, data=request.data, partial=True, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response( + { + "id": link.id, + "project": link.project_id, + "company": { + "id": link.company_id, + "name": link.company.name, + "inn": link.company.inn, + }, + "contribution": link.contribution, + "decision_maker": link.decision_maker_id, + }, + status=status.HTTP_200_OK, + ) + + @swagger_auto_schema( + operation_summary="Удалить связь проекта с компанией", + operation_description=( + "Удаляет запись `ProjectCompany`, связывающую проект и компанию. " + "Сама компания при этом остаётся в базе данных." + ), + manual_parameters=[project_id_param, company_id_param], + responses={ + 204: "Связь успешно удалена", + 403: "Недостаточно прав", + 404: "Проект, компания или связь не найдены", + }, + tags=["Projects • Companies"], + ) + def delete(self, request, project_id: int, company_id: int): + project, company, link_or_resp = self._get_link_or_404(project_id, company_id) + if isinstance(link_or_resp, Response): + return link_or_resp + link = link_or_resp + + perm_resp = self._check_permissions(request, project) + if perm_resp: + return perm_resp + + link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) From 0bac2da027a161a9cd933bf928a33fe38a60d56b Mon Sep 17 00:00:00 2001 From: Toksi Date: Thu, 9 Oct 2025 13:43:12 +0500 Subject: [PATCH 08/13] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8E?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/permissions.py | 4 ++-- projects/views.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/projects/permissions.py b/projects/permissions.py index 5af6b320..886a6d72 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -159,7 +159,7 @@ class IsProjectLeaderOrReadOnly(BasePermission): Создавать/изменять/удалять может только лидер проекта. """ - message = "Только лидер проекта может создавать, изменять или удалять цели." + message = "Только лидер проекта может создавать, изменять или удалять параметры." def has_permission(self, request, view): if request.method in SAFE_METHODS: @@ -169,7 +169,7 @@ def has_permission(self, request, view): return False project_pk = view.kwargs.get("project_pk") - project_id = project_pk or request.data.get("project") + project_id = project_pk or view.kwargs.get("project_id") or request.data.get("project") if not project_id: return False diff --git a/projects/views.py b/projects/views.py index 78d45861..4cff1107 100644 --- a/projects/views.py +++ b/projects/views.py @@ -943,9 +943,7 @@ def patch(self, request, project_id: int, company_id: int): return link_or_resp link = link_or_resp - perm_resp = self._check_permissions(request, project) - if perm_resp: - return perm_resp + self.check_object_permissions(request, link) serializer = ProjectCompanyUpdateSerializer( link, data=request.data, partial=True, context={"request": request} @@ -988,9 +986,8 @@ def delete(self, request, project_id: int, company_id: int): return link_or_resp link = link_or_resp - perm_resp = self._check_permissions(request, project) - if perm_resp: - return perm_resp + # объектная проверка прав + self.check_object_permissions(request, link) link.delete() return Response(status=status.HTTP_204_NO_CONTENT) From d2239bec22ac04cc9ec6aaf5bb7ebb564d2693be Mon Sep 17 00:00:00 2001 From: Toksi Date: Thu, 9 Oct 2025 13:46:16 +0500 Subject: [PATCH 09/13] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D1=91=D0=BD=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/views.py b/projects/views.py index 4cff1107..a3625c4e 100644 --- a/projects/views.py +++ b/projects/views.py @@ -986,7 +986,6 @@ def delete(self, request, project_id: int, company_id: int): return link_or_resp link = link_or_resp - # объектная проверка прав self.check_object_permissions(request, link) link.delete() From 202b119b8676f0bee26f8a1b203f607a2ebeb3f5 Mon Sep 17 00:00:00 2001 From: Toksi Date: Thu, 9 Oct 2025 14:48:30 +0500 Subject: [PATCH 10/13] =?UTF-8?q?=D0=9F=D1=80=D0=B8=20GET=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=B5=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89?= =?UTF-8?q?=D0=B0=D0=B5=D0=BC=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=20project=20=D0=BD=D0=B0=20project=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/serializers.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/projects/serializers.py b/projects/serializers.py index a80c192c..44999bad 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -594,17 +594,20 @@ class Meta: class ProjectCompanySerializer(serializers.ModelSerializer): company = CompanySerializer() - project = serializers.PrimaryKeyRelatedField(read_only=True) + project_id = serializers.IntegerField(read_only=True) decision_maker = serializers.PrimaryKeyRelatedField(read_only=True) class Meta: model = ProjectCompany - fields = ("id", "project", "company", "contribution", "decision_maker") + fields = ("id", "project_id", "company", "contribution", "decision_maker") class ResourceSerializer(serializers.ModelSerializer): - project_id = serializers.PrimaryKeyRelatedField( - source="project", queryset=Project.objects.all(), write_only=True + project_id = serializers.IntegerField(read_only=True) + project = serializers.PrimaryKeyRelatedField( + queryset=Project.objects.all(), + write_only=True, + required=False, ) class Meta: From 8362d09301db3fa811e3ae2371423d57dc5edbfb Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 15 Oct 2025 11:18:19 +0500 Subject: [PATCH 11/13] =?UTF-8?q?Nginx=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B9=D0=BD=D0=B5=D1=80=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B8=D0=B7=20docker-compose;=20=D0=9F=D1=80=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=BE=D1=88=D0=B5=D0=BD=20=D0=BF=D0=BE=D1=80=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.dev-ci.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docker-compose.dev-ci.yml b/docker-compose.dev-ci.yml index 09f7188a..77eb1d6f 100644 --- a/docker-compose.dev-ci.yml +++ b/docker-compose.dev-ci.yml @@ -13,8 +13,8 @@ services: - .env environment: HOST: 0.0.0.0 - expose: - - 8000 + ports: + - 8000:8000 grafana: image: grafana/grafana:latest @@ -37,13 +37,13 @@ services: - prom-data:/prometheus - ./prometheus:/etc/prometheus - nginx: - restart: unless-stopped - build: ./nginx - depends_on: - - web - ports: - - 8000:80 + #nginx: + # restart: unless-stopped + # build: ./nginx + # depends_on: + # - web + # ports: + # - 8000:80 loki: image: grafana/loki:2.9.0 From d2cc7407246df4f15ee497ac73ffbe3704a9cdd6 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 15 Oct 2025 12:01:47 +0500 Subject: [PATCH 12/13] =?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 --- 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 2b83e93599d61d953718648d58643f54275d5bb7 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 15 Oct 2025 13:58:38 +0500 Subject: [PATCH 13/13] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=80=D1=83=D1=87=D0=BA=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D0=B2=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=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 --- 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", + )