From 70e252a2d9dc6911544abf73e725b97e3cf1802f Mon Sep 17 00:00:00 2001 From: Toksi86 Date: Mon, 1 Sep 2025 11:14:50 +0500 Subject: [PATCH 01/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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/21] =?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 7289f8f4c8e8d875fe58e51f0e2fdb0fe2c76e61 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 17 Oct 2025 15:52:18 +0500 Subject: [PATCH 12/21] =?UTF-8?q?=D0=92=20docker=20compose=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=B5=20?= =?UTF-8?q?web=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=20=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.dev-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.dev-ci.yml b/docker-compose.dev-ci.yml index 77eb1d6f..d83371f2 100644 --- a/docker-compose.dev-ci.yml +++ b/docker-compose.dev-ci.yml @@ -14,7 +14,7 @@ services: environment: HOST: 0.0.0.0 ports: - - 8000:8000 + - "127.0.0.1:8000:8000" grafana: image: grafana/grafana:latest From 0d58831669f598f0e23b58633e605c82193c0703 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 22 Oct 2025 13:48:13 +0500 Subject: [PATCH 13/21] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20drf-yasg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/serializers.py | 4 ++++ feed/views.py | 19 ++++++++++--------- partner_programs/serializers.py | 4 +--- partner_programs/views.py | 9 +++++++-- procollab/celery.py | 7 +------ projects/views.py | 2 +- 6 files changed, 24 insertions(+), 21 deletions(-) diff --git a/core/serializers.py b/core/serializers.py index 6fff7685..4a0f8bfe 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -3,6 +3,10 @@ from .models import SkillToObject, SkillCategory, Skill +class EmptySerializer(serializers.Serializer): + pass + + class SetLikedSerializer(serializers.Serializer): is_liked = serializers.BooleanField() diff --git a/feed/views.py b/feed/views.py index 01c4450e..fab314df 100644 --- a/feed/views.py +++ b/feed/views.py @@ -1,18 +1,19 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import QuerySet, Q +from django.db.models import Q, QuerySet from rest_framework.generics import CreateAPIView from rest_framework.response import Response from rest_framework.views import APIView +from core.serializers import EmptySerializer from feed.pagination import FeedPagination from feed.services import get_liked_news - from news.models import News -from .serializers import NewsFeedListSerializer -from projects.models import Project from partner_programs.models import PartnerProgramUserProfile +from projects.models import Project from vacancy.models import Vacancy +from .serializers import NewsFeedListSerializer + class NewSimpleFeed(APIView): serializator_class = NewsFeedListSerializer @@ -29,11 +30,9 @@ def _get_filter_data(self) -> list[str]: def _get_excluded_projects_ids(self) -> list[int]: """IDs for exclude projects which in Partner Program.""" - excluded_projects = ( - PartnerProgramUserProfile.objects - .values_list("project_id", flat=True) - .exclude(project_id__isnull=True) - ) + excluded_projects = PartnerProgramUserProfile.objects.values_list( + "project_id", flat=True + ).exclude(project_id__isnull=True) return excluded_projects def get_queryset(self) -> QuerySet[News]: @@ -80,6 +79,8 @@ def get(self, *args, **kwargs): class DevScript(CreateAPIView): + serializer_class = EmptySerializer + def create(self, request): content_type_project = ContentType.objects.filter(model="project").first() for project in Project.objects.filter(draft=False): 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/views.py b/partner_programs/views.py index 2783bb14..3f99cd77 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -19,7 +19,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from core.serializers import SetLikedSerializer, SetViewedSerializer +from core.serializers import EmptySerializer, SetLikedSerializer, SetViewedSerializer from core.services import add_view, set_like from partner_programs.helpers import date_to_iso from partner_programs.models import ( @@ -69,6 +69,7 @@ class PartnerProgramList(generics.ListCreateAPIView): class PartnerProgramDetail(generics.RetrieveAPIView): queryset = PartnerProgram.objects.prefetch_related("materials", "managers").all() permission_classes = [permissions.IsAuthenticatedOrReadOnly] + serializer_class = PartnerProgramForUnregisteredUserSerializer def get(self, request, *args, **kwargs): program = self.get_object() @@ -320,7 +321,7 @@ def put(self, request, project_id, *args, **kwargs): class PartnerProgramProjectSubmitView(GenericAPIView): permission_classes = [IsAuthenticated, IsProjectLeader] - serializer_class = None + serializer_class = EmptySerializer queryset = PartnerProgramProject.objects.all() @swagger_auto_schema( @@ -375,6 +376,7 @@ class ProgramProjectFilterAPIView(GenericAPIView): serializer_class = ProgramProjectFilterRequestSerializer permission_classes = [permissions.IsAuthenticated] pagination_class = PartnerProgramPagination + queryset = PartnerProgram.objects.none() def post(self, request, pk): serializer = self.get_serializer(data=request.data) @@ -445,6 +447,9 @@ class PartnerProgramProjectsAPIView(generics.ListAPIView): pagination_class = PartnerProgramPagination def get_queryset(self): + if "pk" not in self.kwargs: + return Project.objects.none() + program = get_object_or_404(PartnerProgram, pk=self.kwargs["pk"]) return Project.objects.filter(program_links__partner_program=program).distinct() diff --git a/procollab/celery.py b/procollab/celery.py index f56806c8..882377a2 100644 --- a/procollab/celery.py +++ b/procollab/celery.py @@ -1,22 +1,17 @@ import os from celery import Celery -import django -# from celery.schedules import crontab from celery.schedules import crontab os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings") -django.setup() -app = Celery("procollab") +app = Celery("procollab") app.config_from_object("django.conf:settings", namespace="CELERY") - app.autodiscover_tasks() app.conf.task_serializer = "json" - app.conf.beat_schedule = { "outdate_vacancy": { "task": "vacancy.tasks.email_notificate_vacancy_outdated", diff --git a/projects/views.py b/projects/views.py index a3625c4e..5820f58a 100644 --- a/projects/views.py +++ b/projects/views.py @@ -51,11 +51,11 @@ IsProjectLeaderOrReadOnlyForNonDrafts, TimingAfterEndsProgramPermission, ) +from core.serializers import EmptySerializer from projects.serializers import ( AchievementDetailSerializer, AchievementListSerializer, CompanySerializer, - EmptySerializer, ProjectCollaboratorSerializer, ProjectCompanySerializer, ProjectCompanyUpdateSerializer, From ded251ec6e37a0635b90045f92822011ebfec993 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 22 Oct 2025 13:48:57 +0500 Subject: [PATCH 14/21] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20"?= =?UTF-8?q?=D0=94=D0=BE=D1=81=D1=82=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- users/admin.py | 25 +++- ...ementfile_userachievement_year_and_more.py | 80 ++++++++++ users/models.py | 78 +++++++++- users/serializers.py | 140 +++++++++++++++--- users/views.py | 75 ++++++---- 5 files changed, 342 insertions(+), 56 deletions(-) create mode 100644 users/migrations/0056_userachievementfile_userachievement_year_and_more.py diff --git a/users/admin.py b/users/admin.py index c10356bd..9c199008 100644 --- a/users/admin.py +++ b/users/admin.py @@ -8,11 +8,13 @@ from django.http import HttpResponse from django.shortcuts import redirect from django.urls import path +from django.utils.html import format_html from django.utils.timezone import now from core.admin import SkillToObjectInline from core.utils import XlsxFileToExport from mailing.views import MailingTemplateRender +from users.models import UserAchievementFile from users.services.users_activity import UserActivityDataPreparer from .helpers import force_verify_user, send_verification_completed_email @@ -377,9 +379,30 @@ def get_export_users_emails(self, users): return response +class UserAchievementFileInline(admin.TabularInline): + model = UserAchievementFile + extra = 0 + autocomplete_fields = ("file",) + + @admin.register(UserAchievement) class UserAchievementAdmin(admin.ModelAdmin): - list_display = ("id", "title", "status", "user") + list_display = ("id", "title", "status", "user", "year", "file_link") + list_filter = ("year",) + search_fields = ("title", "status", "user__email", "user__username") + inlines = [UserAchievementFileInline] + + def file_link(self, obj): + uf = obj.files.select_related("file").first() + if uf and uf.file and uf.file.link: + return format_html( + "открыть ({} файл(ов))", + uf.file.link, + obj.files.count(), + ) + return "—" + + file_link.short_description = "Файл(ы)" @admin.register(UserLink) diff --git a/users/migrations/0056_userachievementfile_userachievement_year_and_more.py b/users/migrations/0056_userachievementfile_userachievement_year_and_more.py new file mode 100644 index 00000000..f7b6f5f0 --- /dev/null +++ b/users/migrations/0056_userachievementfile_userachievement_year_and_more.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.24 on 2025-10-20 08:06 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0007_auto_20230929_1727"), + ("users", "0055_alter_customuser_avatar_alter_customuser_email"), + ] + + operations = [ + migrations.CreateModel( + name="UserAchievementFile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + options={ + "verbose_name": "Файл достижения", + "verbose_name_plural": "Файлы достижения", + }, + ), + migrations.AddField( + model_name="userachievement", + name="year", + field=models.PositiveSmallIntegerField( + blank=True, + db_index=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1900), + django.core.validators.MaxValueValidator(2025), + ], + verbose_name="Год достижения", + ), + ), + migrations.AddConstraint( + model_name="userachievement", + constraint=models.UniqueConstraint( + fields=("user", "title", "year"), name="uniq_user_achievement_title_year" + ), + ), + migrations.AddField( + model_name="userachievementfile", + name="achievement", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="files", + to="users.userachievement", + verbose_name="Достижение", + ), + ), + migrations.AddField( + model_name="userachievementfile", + name="file", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="achievement_links", + to="files.userfile", + verbose_name="Файл", + ), + ), + migrations.AddConstraint( + model_name="userachievementfile", + constraint=models.UniqueConstraint( + fields=("achievement", "file"), name="uniq_achievement_file_link" + ), + ), + ] diff --git a/users/models.py b/users/models.py index d3e3a91f..3796a7ff 100644 --- a/users/models.py +++ b/users/models.py @@ -1,9 +1,10 @@ +import datetime from functools import partial from django.contrib.auth.models import AbstractUser from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError -from django.core.validators import URLValidator +from django.core.validators import MaxValueValidator, MinValueValidator, URLValidator from django.db import models from django.db.models import QuerySet from django.utils import timezone @@ -211,9 +212,7 @@ def calculate_ordering_score(self) -> int: def get_project_chats(self) -> QuerySet: from chats.models import ProjectChat - user_project_ids = self.collaborations.all().values_list( - "project_id", flat=True - ) + user_project_ids = self.collaborations.all().values_list("project_id", flat=True) return ProjectChat.objects.filter(project__in=user_project_ids) def get_full_name(self) -> str: @@ -258,7 +257,16 @@ class UserAchievement(models.Model): title = models.CharField(max_length=256) status = models.CharField(max_length=256) - + year = models.PositiveSmallIntegerField( + "Год достижения", + null=True, + blank=True, + db_index=True, + validators=[ + MinValueValidator(1900), + MaxValueValidator(datetime.date.today().year), + ], + ) user = models.ForeignKey( CustomUser, on_delete=models.CASCADE, @@ -274,6 +282,54 @@ class Meta(TypedModelMeta): verbose_name = "Достижение" verbose_name_plural = "Достижения" + constraints = [ + models.UniqueConstraint( + fields=["user", "title", "year"], + name="uniq_user_achievement_title_year", + ), + ] + + +class UserAchievementFile(models.Model): + ALLOWED_EXTENSIONS = {"pdf", "doc", "docx", "jpg", "jpeg", "png"} + MAX_UPLOAD_SIZE = 50 * 1024 * 1024 + achievement = models.ForeignKey( + UserAchievement, + on_delete=models.CASCADE, + related_name="files", + verbose_name="Достижение", + ) + file = models.ForeignKey( + "files.UserFile", + on_delete=models.CASCADE, + related_name="achievement_links", + verbose_name="Файл", + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["achievement", "file"], name="uniq_achievement_file_link" + ) + ] + verbose_name = "Файл достижения" + verbose_name_plural = "Файлы достижения" + + def clean(self): + super().clean() + if not self.file or not self.achievement: + return + + if self.file.user_id is None or self.file.user_id != self.achievement.user_id: + raise ValidationError("Файл должен принадлежать тому же пользователю.") + + if self.file.size and self.file.size > self.MAX_UPLOAD_SIZE: + raise ValidationError("Размер файла превышает 50 МБ.") + + ext = (self.file.extension or "").lower() + if ext and ext not in self.ALLOWED_EXTENSIONS: + raise ValidationError("Недопустимое расширение файла.") + class AbstractUserWithRole(models.Model): """ @@ -556,6 +612,12 @@ class UserEducation(AbstractUserExperience): null=True, verbose_name="Статус по обучению", ) + description = models.TextField( + max_length=1000, + null=True, + blank=True, + verbose_name="Направление обучения", + ) class Meta: verbose_name = "Образование пользователя" @@ -583,6 +645,12 @@ class UserWorkExperience(AbstractUserExperience): related_name="work_experience", verbose_name="Пользователь", ) + description = models.TextField( + max_length=1000, + null=True, + blank=True, + verbose_name="Краткое описание деятельности", + ) job_position = models.CharField( max_length=256, null=True, diff --git a/users/serializers.py b/users/serializers.py index c43bc1d9..d92391a6 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -14,6 +14,7 @@ from core.serializers import SkillToObjectSerializer from core.services import get_views_count from core.utils import get_user_online_cache_key +from files.models import UserFile from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from projects.models import Collaborator, Project from projects.validators import validate_project @@ -25,6 +26,7 @@ Member, Mentor, UserAchievement, + UserAchievementFile, UserEducation, UserLanguages, UserSkillConfirmation, @@ -34,11 +36,62 @@ from users.validators import specialization_exists_validator -class AchievementListSerializer(serializers.ModelSerializer[UserAchievement]): +class AchievementListSerializer(serializers.ModelSerializer): + year = serializers.IntegerField(required=False, allow_null=True) + files = serializers.SerializerMethodField() + class Meta: model = UserAchievement - fields = ["id", "title", "status"] - ref_name = "Users" + fields = ["id", "title", "status", "year", "files"] + + def get_files(self, obj): + uafs = obj.files.all() + return [UserFileReadSerializer(uf.file).data for uf in uafs if uf.file] + + +class UserFileReadSerializer(serializers.ModelSerializer): + class Meta: + model = UserFile + fields = ("link", "name", "extension", "mime_type", "size") + + +class AchievementFileReadSerializer(serializers.ModelSerializer): + file = UserFileReadSerializer() + + class Meta: + model = UserAchievementFile + fields = ("file",) + + +class FilesWriteField(serializers.ListField): + """ + Принимает список ссылок (UserFile.link — первичный ключ). + Проверяет существование и владельца (user не NULL и совпадает с request.user). + """ + + child = serializers.URLField() + + def to_internal_value(self, data): + values = super().to_internal_value(data) + request = self.context.get("request") + qs = UserFile.objects.filter(link__in=values) + files_by_link = {uf.link: uf for uf in qs} + + missing = [v for v in values if v not in files_by_link] + if missing: + raise serializers.ValidationError(f"Файлы не найдены: {missing}") + + if request and request.user.is_authenticated: + wrong_owner = [] + for uf in files_by_link.values(): + if uf.user_id is None or uf.user_id != request.user.id: + wrong_owner.append(uf.link) + if wrong_owner: + raise serializers.ValidationError( + f"Нельзя привязать файлы: нет владельца или владелец другой ({wrong_owner})" + ) + + return list(files_by_link.values()) class CustomListField(serializers.ListField): @@ -47,9 +100,7 @@ def to_representation(self, data): if isinstance(data, list): return data return [ - i.replace("'", "") - for i in data.strip("][").split(",") - if i.replace("'", "") + i.replace("'", "") for i in data.strip("][").split(",") if i.replace("'", "") ] @@ -151,9 +202,7 @@ def to_representation(self, instance): """Returns correct data about user in `confirmed_by`.""" data = super().to_representation(instance) data.pop("skill_to_object", None) - data["confirmed_by"] = UserDataConfirmationSerializer( - instance.confirmed_by - ).data + data["confirmed_by"] = UserDataConfirmationSerializer(instance.confirmed_by).data return data @@ -528,10 +577,7 @@ def update(self, instance, validated_data): if attr in IMMUTABLE_FIELDS + USER_TYPE_FIELDS + RELATED_FIELDS: continue if attr == "user_type": - if ( - value == instance.user_type - or value not in user_types_to_attr.keys() - ): + if value == instance.user_type or value not in user_types_to_attr.keys(): continue # we can't change user type to Member if value == CustomUser.MEMBER: @@ -827,16 +873,68 @@ class Meta: ] -class AchievementDetailSerializer(serializers.ModelSerializer[UserAchievement]): +class AchievementDetailSerializer(serializers.ModelSerializer): + files = AchievementFileReadSerializer(many=True, read_only=True) + files_links = FilesWriteField(write_only=True, required=False) + user = serializers.PrimaryKeyRelatedField(read_only=True) + class Meta: model = UserAchievement - fields = [ - "id", - "title", - "status", - "user", - ] - ref_name = "Users" + fields = ["id", "title", "status", "year", "user", "files", "files_links"] + + def validate_year(self, value): + import datetime + + if value is None: + return value + cur = datetime.date.today().year + if value < 1900 or value > cur: + raise serializers.ValidationError("Год вне допустимого диапазона.") + return value + + @transaction.atomic + def _sync_files(self, achievement: UserAchievement, files: list[UserFile]): + """ + Синхронизируем связи через link (PK у UserFile). + clean() на UserAchievementFile проверит size/extension и владельца ещё раз. + """ + current_links = set( + UserAchievementFile.objects.filter(achievement=achievement).values_list( + "file_id", flat=True + ) + ) + target_links = set(f.link for f in files) + + to_add = target_links - current_links + to_remove = current_links - target_links + + if to_remove: + UserAchievementFile.objects.filter( + achievement=achievement, file_id__in=to_remove + ).delete() + + for link in to_add: + rel = UserAchievementFile(achievement=achievement, file_id=link) + rel.clean() + rel.save() + + @transaction.atomic + def create(self, validated_data): + files = validated_data.pop("files_links", []) + achievement = UserAchievement.objects.create(**validated_data) + if files: + self._sync_files(achievement, files) + return achievement + + @transaction.atomic + def update(self, instance, validated_data): + files = validated_data.pop("files_links", None) + for attr, val in validated_data.items(): + setattr(instance, attr, val) + instance.save() + if files is not None: + self._sync_files(instance, files) + return instance class EmailSerializer(serializers.Serializer): diff --git a/users/views.py b/users/views.py index 534c80ef..1922c2c8 100644 --- a/users/views.py +++ b/users/views.py @@ -48,11 +48,7 @@ VERIFY_EMAIL_REDIRECT_URL, OnboardingStage, ) -from users.helpers import ( - check_related_fields_update, - force_verify_user, - verify_email, -) +from users.helpers import check_related_fields_update, force_verify_user, verify_email from users.models import LikesOnProject, UserAchievement, UserSkillConfirmation from users.permissions import IsAchievementOwnerOrReadOnly from users.serializers import ( @@ -111,9 +107,7 @@ def post(self, request, *args, **kwargs): verify_email(user, request) - return Response( - serializer.data, status=status.HTTP_201_CREATED, headers=headers - ) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) class LikedProjectList(ListAPIView): @@ -314,9 +308,7 @@ def get(self, request): token = request.GET.get("token") try: - payload = jwt.decode( - jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"] - ) + payload = jwt.decode(jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"]) user = User.objects.get(id=payload["user_id"]) access_token = RefreshToken.for_user(user).access_token refresh_token = RefreshToken.for_user(user) @@ -346,34 +338,56 @@ def get(self, request): class AchievementList(ListCreateAPIView): + """ + GET /api/users/achievements/ + POST /api/users/achievements/ + """ + queryset = UserAchievement.objects.get_achievements_for_list_view() - serializer_class = AchievementListSerializer - permission_classes = [IsAchievementOwnerOrReadOnly] + + def get_permissions(self): + if self.request.method == "POST": + return [permissions.IsAuthenticated()] + return [permissions.AllowAny()] + + def get_serializer_class(self): + return ( + AchievementListSerializer + if self.request.method == "GET" + else AchievementDetailSerializer + ) def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) + if not request.user.is_authenticated: + return Response( + {"detail": "Authentication required."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + serializer = AchievementDetailSerializer( + data=request.data, context={"request": request} + ) serializer.is_valid(raise_exception=True) - serializer.validated_data["user"] = request.user - # warning for someone who tries to set user variable (the user will always be yourself anyway) - if ( - request.data.get("user") is not None - and request.data.get("user") != request.user.id - ): + + if request.data.get("user") and request.data.get("user") != request.user.id: return Response( - { - "error": "you can't edit USER field for this view since " - "you can't create achievements for other people" - }, + {"error": "Нельзя создавать достижения для другого пользователя."}, status=status.HTTP_403_FORBIDDEN, ) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response( - serializer.data, status=status.HTTP_201_CREATED, headers=headers - ) + + achievement = serializer.save(user=request.user) + out = AchievementDetailSerializer(achievement, context={"request": request}) + headers = self.get_success_headers(out.data) + return Response(out.data, status=status.HTTP_201_CREATED, headers=headers) class AchievementDetail(RetrieveUpdateDestroyAPIView): + """ + GET /api/users/achievements/{id}/ + PATCH/PUT /api/users/achievements/{id}/ + DELETE /api/users/achievements/{id}/ + """ + queryset = UserAchievement.objects.get_achievements_for_detail_view() serializer_class = AchievementDetailSerializer permission_classes = [IsAchievementOwnerOrReadOnly] @@ -506,6 +520,9 @@ class UserSubscribedProjectsList(ListAPIView): pagination_class = Pagination def get_queryset(self): + if "pk" not in self.kwargs: + return Project.objects.none() + try: user = User.objects.get(pk=self.kwargs["pk"]) return user.subscribed_projects.all() From 1e9b6a3de54fb5b8d930f875df8168adf39597d0 Mon Sep 17 00:00:00 2001 From: Toksi Date: Thu, 23 Oct 2025 15:54:29 +0500 Subject: [PATCH 15/21] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=91=D0=BD=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B8=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B4=D0=BE=D1=81=D1=82=D0=B8=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D0=B4=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=B8=D0=BD=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BE=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...lter_usereducation_description_and_more.py | 33 ++++++++++ users/models.py | 2 +- users/serializers.py | 66 +++++++++---------- 3 files changed, 65 insertions(+), 36 deletions(-) create mode 100644 users/migrations/0057_alter_usereducation_description_and_more.py diff --git a/users/migrations/0057_alter_usereducation_description_and_more.py b/users/migrations/0057_alter_usereducation_description_and_more.py new file mode 100644 index 00000000..d46cfe4f --- /dev/null +++ b/users/migrations/0057_alter_usereducation_description_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.24 on 2025-10-23 10:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0056_userachievementfile_userachievement_year_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="usereducation", + name="description", + field=models.TextField( + blank=True, + max_length=1000, + null=True, + verbose_name="Направление обучения", + ), + ), + migrations.AlterField( + model_name="userworkexperience", + name="description", + field=models.TextField( + blank=True, + max_length=1000, + null=True, + verbose_name="Краткое описание деятельности", + ), + ), + ] diff --git a/users/models.py b/users/models.py index 3796a7ff..64bc049a 100644 --- a/users/models.py +++ b/users/models.py @@ -291,7 +291,7 @@ class Meta(TypedModelMeta): class UserAchievementFile(models.Model): - ALLOWED_EXTENSIONS = {"pdf", "doc", "docx", "jpg", "jpeg", "png"} + ALLOWED_EXTENSIONS = {"pdf", "doc", "docx", "jpg", "jpeg", "png", "webp"} MAX_UPLOAD_SIZE = 50 * 1024 * 1024 achievement = models.ForeignKey( UserAchievement, diff --git a/users/serializers.py b/users/serializers.py index d92391a6..83c1e5ec 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -38,15 +38,15 @@ class AchievementListSerializer(serializers.ModelSerializer): year = serializers.IntegerField(required=False, allow_null=True) - files = serializers.SerializerMethodField() + file_link = serializers.SerializerMethodField() class Meta: model = UserAchievement - fields = ["id", "title", "status", "year", "files"] + fields = ["id", "title", "status", "year", "file_link"] - def get_files(self, obj): - uafs = obj.files.all() - return [UserFileReadSerializer(uf.file).data for uf in uafs if uf.file] + def get_file_link(self, obj): + uaf = obj.files.first() + return uaf.file.link if (uaf and uaf.file) else None class UserFileReadSerializer(serializers.ModelSerializer): @@ -874,13 +874,19 @@ class Meta: class AchievementDetailSerializer(serializers.ModelSerializer): - files = AchievementFileReadSerializer(many=True, read_only=True) - files_links = FilesWriteField(write_only=True, required=False) + file_link = serializers.URLField(required=False, allow_null=True) user = serializers.PrimaryKeyRelatedField(read_only=True) class Meta: model = UserAchievement - fields = ["id", "title", "status", "year", "user", "files", "files_links"] + fields = ["id", "title", "status", "year", "user", "file_link"] + read_only_fields = ["id", "user"] + + def to_representation(self, instance): + data = super().to_representation(instance) + rel = instance.files.first() # UserAchievementFile + data["file_link"] = rel.file.link if (rel and rel.file) else None + return data def validate_year(self, value): import datetime @@ -893,47 +899,37 @@ def validate_year(self, value): return value @transaction.atomic - def _sync_files(self, achievement: UserAchievement, files: list[UserFile]): - """ - Синхронизируем связи через link (PK у UserFile). - clean() на UserAchievementFile проверит size/extension и владельца ещё раз. - """ - current_links = set( - UserAchievementFile.objects.filter(achievement=achievement).values_list( - "file_id", flat=True - ) - ) - target_links = set(f.link for f in files) + def _set_single_file(self, achievement: UserAchievement, link: str | None): + UserAchievementFile.objects.filter(achievement=achievement).delete() - to_add = target_links - current_links - to_remove = current_links - target_links + if not link: + return - if to_remove: - UserAchievementFile.objects.filter( - achievement=achievement, file_id__in=to_remove - ).delete() + uf, _ = UserFile.objects.get_or_create(link=link) - for link in to_add: - rel = UserAchievementFile(achievement=achievement, file_id=link) - rel.clean() - rel.save() + rel = UserAchievementFile(achievement=achievement, file=uf) + rel.clean() + rel.save() @transaction.atomic def create(self, validated_data): - files = validated_data.pop("files_links", []) + link = validated_data.pop("file_link", None) achievement = UserAchievement.objects.create(**validated_data) - if files: - self._sync_files(achievement, files) + self._set_single_file(achievement, link) return achievement @transaction.atomic def update(self, instance, validated_data): - files = validated_data.pop("files_links", None) + sentinel = object() + link = validated_data.pop("file_link", sentinel) + for attr, val in validated_data.items(): setattr(instance, attr, val) instance.save() - if files is not None: - self._sync_files(instance, files) + + if link is not sentinel: + self._set_single_file(instance, link) + return instance From 046c5fa175eb6f9a56255148ed5877f8b2a6a44a Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 24 Oct 2025 12:24:05 +0500 Subject: [PATCH 16/21] =?UTF-8?q?=D0=98=D0=BD=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=B0=D1=85=20=D0=B2=20=D0=B4=D0=BE=D1=81=D1=82=D0=B8=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=D1=85=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D1=80=D0=BE=D0=B1=D0=BD=D0=BE=D0=BC=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B4=D0=B5=20=D0=BF=D1=80=D0=B8=20GET=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B5,=20=D0=BD=D0=BE=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=BE=D0=BA=D1=80=D0=B0=D1=89=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=BF=D1=80=D0=B8=20POST=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- users/admin.py | 10 +- users/managers.py | 34 +++-- .../0058_userachievement_files_and_more.py | 48 +++++++ users/models.py | 13 +- users/serializers.py | 123 ++++++++---------- users/views.py | 12 +- 6 files changed, 157 insertions(+), 83 deletions(-) create mode 100644 users/migrations/0058_userachievement_files_and_more.py diff --git a/users/admin.py b/users/admin.py index 9c199008..d393b47b 100644 --- a/users/admin.py +++ b/users/admin.py @@ -393,12 +393,14 @@ class UserAchievementAdmin(admin.ModelAdmin): inlines = [UserAchievementFileInline] def file_link(self, obj): - uf = obj.files.select_related("file").first() - if uf and uf.file and uf.file.link: + first_file = obj.files.first() + count = obj.files.count() + + if first_file and getattr(first_file, "link", None): return format_html( "открыть ({} файл(ов))", - uf.file.link, - obj.files.count(), + first_file.link, + count, ) return "—" diff --git a/users/managers.py b/users/managers.py index d2f2eb63..a09b2e6a 100644 --- a/users/managers.py +++ b/users/managers.py @@ -1,6 +1,8 @@ +from django.apps import apps from django.contrib.auth.hashers import make_password from django.contrib.auth.models import UserManager -from django.db.models import Manager +from django.db import models +from django.db.models import Manager, Prefetch from users.constants import MEMBER @@ -53,19 +55,35 @@ def _create_user(self, email, password, **extra_fields): return user -class UserAchievementManager(Manager): - def get_achievements_for_list_view(self): +FILE_FIELDS = ( + "id", + "name", + "extension", + "mime_type", + "size", + "link", + "user_id", + "datetime_uploaded", +) + + +class UserAchievementManager(models.Manager): + def _with_user_and_files(self): + UserFile = apps.get_model("files", "UserFile") return ( self.get_queryset() .select_related("user") - .only("id", "title", "status", "user__id") + .prefetch_related(Prefetch("files", queryset=UserFile.objects.all())) + ) + + def get_achievements_for_list_view(self): + return self._with_user_and_files().only( + "id", "title", "status", "year", "user_id" ) def get_achievements_for_detail_view(self): - return ( - self.get_queryset() - .select_related("user") - .only("id", "title", "status", "user") + return self._with_user_and_files().only( + "id", "title", "status", "year", "user_id" ) diff --git a/users/migrations/0058_userachievement_files_and_more.py b/users/migrations/0058_userachievement_files_and_more.py new file mode 100644 index 00000000..0530c787 --- /dev/null +++ b/users/migrations/0058_userachievement_files_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.24 on 2025-10-24 06:41 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0007_auto_20230929_1727"), + ("users", "0057_alter_usereducation_description_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="userachievement", + name="files", + field=models.ManyToManyField( + blank=True, + related_name="achievements", + related_query_name="achievement", + through="users.UserAchievementFile", + to="files.userfile", + ), + ), + migrations.AlterField( + model_name="userachievementfile", + name="achievement", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="file_links", + related_query_name="file_link", + to="users.userachievement", + verbose_name="Достижение", + ), + ), + migrations.AlterField( + model_name="userachievementfile", + name="file", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="achievement_links", + related_query_name="achievement_link", + to="files.userfile", + verbose_name="Файл", + ), + ), + ] diff --git a/users/models.py b/users/models.py index 64bc049a..815ee521 100644 --- a/users/models.py +++ b/users/models.py @@ -272,6 +272,14 @@ class UserAchievement(models.Model): on_delete=models.CASCADE, related_name="achievements", ) + files = models.ManyToManyField( + "files.UserFile", + through="UserAchievementFile", + through_fields=("achievement", "file"), + related_name="achievements", + related_query_name="achievement", + blank=True, + ) objects = UserAchievementManager() @@ -293,16 +301,19 @@ class Meta(TypedModelMeta): class UserAchievementFile(models.Model): ALLOWED_EXTENSIONS = {"pdf", "doc", "docx", "jpg", "jpeg", "png", "webp"} MAX_UPLOAD_SIZE = 50 * 1024 * 1024 + achievement = models.ForeignKey( UserAchievement, on_delete=models.CASCADE, - related_name="files", + related_name="file_links", + related_query_name="file_link", verbose_name="Достижение", ) file = models.ForeignKey( "files.UserFile", on_delete=models.CASCADE, related_name="achievement_links", + related_query_name="achievement_link", verbose_name="Файл", ) diff --git a/users/serializers.py b/users/serializers.py index 83c1e5ec..ac04468a 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -15,6 +15,7 @@ from core.services import get_views_count from core.utils import get_user_online_cache_key from files.models import UserFile +from files.serializers import UserFileSerializer from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from projects.models import Collaborator, Project from projects.validators import validate_project @@ -36,23 +37,69 @@ from users.validators import specialization_exists_validator +class UserFileReadSerializer(serializers.ModelSerializer): + class Meta: + model = UserFile + fields = ("link", "name", "extension", "mime_type", "size") + + +class FileLinkField(serializers.SlugRelatedField): + """ + write-only: принимает link, маппит на UserFile текущего пользователя. + """ + + def get_queryset(self): + request = self.context.get("request") + qs = UserFile.objects.all() + if request and request.user.is_authenticated: + return qs.filter(user=request.user) + return qs.none() + + class AchievementListSerializer(serializers.ModelSerializer): year = serializers.IntegerField(required=False, allow_null=True) - file_link = serializers.SerializerMethodField() + files = UserFileSerializer(many=True, read_only=True) class Meta: model = UserAchievement - fields = ["id", "title", "status", "year", "file_link"] + fields = ["id", "title", "status", "year", "files"] - def get_file_link(self, obj): - uaf = obj.files.first() - return uaf.file.link if (uaf and uaf.file) else None +class AchievementDetailSerializer(serializers.ModelSerializer): + files = UserFileSerializer(many=True, read_only=True) + file_links = FileLinkField( + slug_field="link", + many=True, + write_only=True, + required=False, + ) -class UserFileReadSerializer(serializers.ModelSerializer): class Meta: - model = UserFile - fields = ("link", "name", "extension", "mime_type", "size") + model = UserAchievement + fields = [ + "id", + "title", + "status", + "year", + "files", + "file_links", + ] + + @transaction.atomic + def create(self, validated_data): + file_objs = validated_data.pop("file_links", []) + achievement = super().create(validated_data) + if file_objs: + achievement.files.set(file_objs) + return achievement + + @transaction.atomic + def update(self, instance, validated_data): + file_objs = validated_data.pop("file_links", None) + achievement = super().update(instance, validated_data) + if file_objs is not None: + achievement.files.set(file_objs) + return achievement class AchievementFileReadSerializer(serializers.ModelSerializer): @@ -873,66 +920,6 @@ class Meta: ] -class AchievementDetailSerializer(serializers.ModelSerializer): - file_link = serializers.URLField(required=False, allow_null=True) - user = serializers.PrimaryKeyRelatedField(read_only=True) - - class Meta: - model = UserAchievement - fields = ["id", "title", "status", "year", "user", "file_link"] - read_only_fields = ["id", "user"] - - def to_representation(self, instance): - data = super().to_representation(instance) - rel = instance.files.first() # UserAchievementFile - data["file_link"] = rel.file.link if (rel and rel.file) else None - return data - - def validate_year(self, value): - import datetime - - if value is None: - return value - cur = datetime.date.today().year - if value < 1900 or value > cur: - raise serializers.ValidationError("Год вне допустимого диапазона.") - return value - - @transaction.atomic - def _set_single_file(self, achievement: UserAchievement, link: str | None): - UserAchievementFile.objects.filter(achievement=achievement).delete() - - if not link: - return - - uf, _ = UserFile.objects.get_or_create(link=link) - - rel = UserAchievementFile(achievement=achievement, file=uf) - rel.clean() - rel.save() - - @transaction.atomic - def create(self, validated_data): - link = validated_data.pop("file_link", None) - achievement = UserAchievement.objects.create(**validated_data) - self._set_single_file(achievement, link) - return achievement - - @transaction.atomic - def update(self, instance, validated_data): - sentinel = object() - link = validated_data.pop("file_link", sentinel) - - for attr, val in validated_data.items(): - setattr(instance, attr, val) - instance.save() - - if link is not sentinel: - self._set_single_file(instance, link) - - return instance - - class EmailSerializer(serializers.Serializer): email = serializers.EmailField() diff --git a/users/views.py b/users/views.py index 1922c2c8..0d10eeec 100644 --- a/users/views.py +++ b/users/views.py @@ -343,7 +343,11 @@ class AchievementList(ListCreateAPIView): POST /api/users/achievements/ """ - queryset = UserAchievement.objects.get_achievements_for_list_view() + queryset = ( + UserAchievement.objects.get_achievements_for_list_view() + .select_related("user") + .prefetch_related("files") + ) def get_permissions(self): if self.request.method == "POST": @@ -388,7 +392,11 @@ class AchievementDetail(RetrieveUpdateDestroyAPIView): DELETE /api/users/achievements/{id}/ """ - queryset = UserAchievement.objects.get_achievements_for_detail_view() + queryset = ( + UserAchievement.objects.get_achievements_for_detail_view() + .select_related("user") + .prefetch_related("files") + ) serializer_class = AchievementDetailSerializer permission_classes = [IsAchievementOwnerOrReadOnly] From 486acbc5f0942fecc18c868b213a028c6043827d Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 27 Oct 2025 13:09:38 +0500 Subject: [PATCH 17/21] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D1=83=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=BB=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D0=B8;=20=D0=9F?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BF=D0=B8=D1=81=D0=B0=D0=BB=20=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9,=20?= =?UTF-8?q?=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20year=20=D0=B8=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=B2=D1=8F=D0=B7=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D1=8B=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- users/helpers.py | 141 +++++++++++++++++++++++++++++++++++++---- vacancy/serializers.py | 18 ++++-- vacancy/views.py | 14 +++- 3 files changed, 155 insertions(+), 18 deletions(-) diff --git a/users/helpers.py b/users/helpers.py index 77fa9af3..dd8dc7a9 100644 --- a/users/helpers.py +++ b/users/helpers.py @@ -2,11 +2,14 @@ from django.contrib.auth import get_user_model from django.contrib.sites.shortcuts import get_current_site from django.core.cache import cache +from django.db import transaction from django.urls import reverse from django.utils import timezone from django.utils.timezone import now +from rest_framework.exceptions import ValidationError from rest_framework_simplejwt.tokens import RefreshToken +from files.models import UserFile from mailing.utils import send_mail from users.constants import PROTOCOL from users.models import UserAchievement, UserLink @@ -54,24 +57,140 @@ def check_related_fields_update(data, pk): update_links(data.get("links"), pk) +def _extract_file_links(raw_files) -> list[str]: + """ + Normalize file input payload into a list of links. + Accepts either a list of strings or a list of dicts with a `link` key. + """ + + if not raw_files: + return [] + + if isinstance(raw_files, str): + raw_files = [raw_files] + + if not isinstance(raw_files, (list, tuple)): + return [] + + links: list[str] = [] + for item in raw_files: + if isinstance(item, str): + links.append(item) + elif isinstance(item, dict): + link = item.get("link") + if isinstance(link, str): + links.append(link) + # keep original order but remove empties/duplicates + seen = set() + deduped = [] + for link in links: + if link and link not in seen: + seen.add(link) + deduped.append(link) + return deduped + + +def _resolve_user_files(file_links: list[str], user_id: int) -> list[UserFile]: + """ + Resolve file links to UserFile objects, validating ownership. + """ + + if not file_links: + return [] + + files = UserFile.objects.filter(link__in=file_links) + files_by_link = {f.link: f for f in files} + + missing = [link for link in file_links if link not in files_by_link] + if missing: + raise ValidationError({"achievements": [f"Файлы не найдены: {missing}"]}) + + wrong_owner = [ + link + for link, file in files_by_link.items() + if file.user_id is None or file.user_id != user_id + ] + if wrong_owner: + raise ValidationError( + { + "achievements": [ + "Нельзя привязать файлы: нет владельца или владелец другой " + f"({wrong_owner})" + ] + } + ) + + # Preserve original ordering + return [files_by_link[link] for link in file_links] + + +@transaction.atomic def update_achievements(achievements, pk): """ Bootleg version of updating achievements via user """ - # delete all old achievements - UserAchievement.objects.filter(user_id=pk).delete() - # create new achievements - UserAchievement.objects.bulk_create( - [ - UserAchievement( + if achievements is None: + return + + if not isinstance(achievements, list): + raise ValidationError({"achievements": ["Должен быть списком объектов."]}) + + existing_achievements = { + achievement.id: achievement + for achievement in UserAchievement.objects.filter(user_id=pk) + } + seen_ids: set[int] = set() + + for achievement_payload in achievements: + if not isinstance(achievement_payload, dict): + raise ValidationError({"achievements": ["Каждое достижение должно быть объектом."]}) + + achievement_id = achievement_payload.get("id") + has_year_key = "year" in achievement_payload + raw_files = None + files_key_present = False + + if "file_links" in achievement_payload: + raw_files = achievement_payload.get("file_links") + files_key_present = True + elif "files" in achievement_payload: + raw_files = achievement_payload.get("files") + files_key_present = True + + file_links = ( + _extract_file_links(raw_files) if files_key_present else None + ) + + if achievement_id and achievement_id in existing_achievements: + achievement_instance = existing_achievements[achievement_id] + title = achievement_payload.get("title") + status = achievement_payload.get("status") + + if title is not None: + achievement_instance.title = title + if status is not None: + achievement_instance.status = status + if has_year_key: + achievement_instance.year = achievement_payload.get("year") + achievement_instance.save() + else: + achievement_instance = UserAchievement.objects.create( user_id=pk, - title=achievement.get("title"), - status=achievement.get("status"), + title=achievement_payload.get("title"), + status=achievement_payload.get("status"), + year=achievement_payload.get("year"), ) - for achievement in achievements - ] - ) + + seen_ids.add(achievement_instance.id) + + if file_links is not None: + user_files = _resolve_user_files(file_links, pk) + achievement_instance.files.set(user_files) + + stale_ids = set(existing_achievements.keys()) - seen_ids + if stale_ids: + UserAchievement.objects.filter(id__in=stale_ids).delete() def update_links(links, pk): diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 3b4cf59d..a3f6e733 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -307,12 +307,6 @@ def create(self, validated_data): return vacancy_response -class VacancyResponseFullFileInfoListSerializer(VacancyResponseListSerializer): - """Returns full file info.""" - - accompanying_file = UserFileSerializer(read_only=True) - - class VacancyResponseDetailSerializer(serializers.ModelSerializer[VacancyResponse]): user = UserDetailSerializer(many=False, read_only=True) vacancy = VacancyListSerializer(many=False, read_only=True) @@ -340,3 +334,15 @@ class Meta: class VacancyResponseAcceptSerializer(VacancyResponseDetailSerializer[VacancyResponse]): is_approved = serializers.BooleanField(required=True, read_only=False) + + +class VacancyResponseFullFileInfoListSerializer(VacancyResponseListSerializer): + """Returns full file info.""" + + accompanying_file = UserFileSerializer(read_only=True) + + +class VacancyResponseDetailReadSerializer(VacancyResponseDetailSerializer): + """Returns full file info for detail view without breaking writes.""" + + accompanying_file = UserFileSerializer(read_only=True) diff --git a/vacancy/views.py b/vacancy/views.py index 78994bf9..8fd1edd0 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -22,6 +22,8 @@ VacancyDetailSerializer, VacancyResponseAcceptSerializer, VacancyResponseDetailSerializer, + VacancyResponseDetailReadSerializer, + VacancyResponseFullFileInfoListSerializer, VacancyResponseListSerializer, ProjectVacancyCreateListSerializer, ) @@ -76,6 +78,11 @@ class VacancyResponseList( permission_classes = [permissions.IsAuthenticatedOrReadOnly] serializer_class = VacancyResponseListSerializer + def get_serializer_class(self): + if self.request.method == "GET": + return VacancyResponseFullFileInfoListSerializer + return super().get_serializer_class() + def get(self, request, *args, **kwargs): """retrieve all responses for certain vacancy""" # note: doesn't raise an error if the vacancy_id passed is non-existent @@ -127,6 +134,11 @@ class VacancyResponseDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = VacancyResponseDetailSerializer permission_classes = [IsVacancyResponseOwnerOrReadOnly] + def get_serializer_class(self): + if self.request.method == "GET": + return VacancyResponseDetailReadSerializer + return super().get_serializer_class() + class VacancyResponseAccept(generics.GenericAPIView): queryset = VacancyResponse.objects.get_vacancy_response_for_detail_view() @@ -210,7 +222,7 @@ def post(self, request, pk): class UserVacancyResponses(ListAPIView): - serializer_class = VacancyResponseListSerializer + serializer_class = VacancyResponseFullFileInfoListSerializer permission_classes = [IsVacancyResponseOwnerOrReadOnly] pagination_class = VacancyPagination From 70f00a4bb8cbcef99c8488bee18430dc0880b82e Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 29 Oct 2025 12:34:07 +0500 Subject: [PATCH 18/21] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D1=8F=D0=B5=D1=82=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D0=B9;=20=D1=83?= =?UTF-8?q?=D0=B6=D0=B5=D1=81=D1=82=D0=BE=D1=87=D0=B0=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit добавлены партнёры, ресурсы и цели в ответ детального проекта выдано поле date_create_time и ссылки/отрасль в ответах вакансий удаляются отсутствующие достижения и блокируются дубликаты по названию и году --- projects/managers.py | 4 ++ projects/serializers.py | 144 +++++++++++++++++++++------------------- users/helpers.py | 83 ++++++++++++++++++----- vacancy/managers.py | 10 ++- vacancy/serializers.py | 40 +++++++++++ 5 files changed, 196 insertions(+), 85 deletions(-) diff --git a/projects/managers.py b/projects/managers.py index a0fa0626..5144f399 100644 --- a/projects/managers.py +++ b/projects/managers.py @@ -29,6 +29,10 @@ def get_projects_for_detail_view(self): "collaborator_set", "vacancies", "links", + "goals__responsible", + "project_companies__company", + "project_companies__decision_maker", + "resources__partner_company", ) .all() ) diff --git a/projects/serializers.py b/projects/serializers.py index 44999bad..6afca590 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -159,6 +159,74 @@ class Meta: list_serializer_class = ProjectGoalBulkListSerializer +class CompanySerializer(serializers.ModelSerializer): + class Meta: + model = Company + fields = ("id", "name", "inn") + read_only_fields = ("id",) + + +class ProjectCompanySerializer(serializers.ModelSerializer): + company = CompanySerializer() + project_id = serializers.IntegerField(read_only=True) + decision_maker = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta: + model = ProjectCompany + fields = ("id", "project_id", "company", "contribution", "decision_maker") + + +class ResourceSerializer(serializers.ModelSerializer): + project_id = serializers.IntegerField(read_only=True) + project = serializers.PrimaryKeyRelatedField( + queryset=Project.objects.all(), + write_only=True, + required=False, + ) + + 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 ProjectDetailSerializer(serializers.ModelSerializer): achievements = AchievementListSerializer(many=True, read_only=True) cover = UserFileSerializer(required=False) @@ -166,6 +234,11 @@ class ProjectDetailSerializer(serializers.ModelSerializer): source="collaborator_set", many=True, read_only=True ) vacancies = ProjectVacancyListSerializer(many=True, read_only=True) + goals = ProjectGoalSerializer(many=True, read_only=True) + partners = ProjectCompanySerializer( + source="project_companies", many=True, read_only=True + ) + resources = ResourceSerializer(many=True, read_only=True) short_description = serializers.SerializerMethodField() industry_id = serializers.IntegerField(required=False) views_count = serializers.SerializerMethodField(method_name="count_views") @@ -222,6 +295,7 @@ class Meta: "description", "short_description", "achievements", + "goals", "links", "region", "industry", @@ -229,10 +303,12 @@ class Meta: "presentation_address", "image_address", "collaborators", + "partners", "leader", "draft", "is_company", "vacancies", + "resources", "datetime_created", "datetime_updated", "views_count", @@ -585,74 +661,6 @@ def _is_valid_url(self, url: str) -> bool: 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_id = serializers.IntegerField(read_only=True) - decision_maker = serializers.PrimaryKeyRelatedField(read_only=True) - - class Meta: - model = ProjectCompany - fields = ("id", "project_id", "company", "contribution", "decision_maker") - - -class ResourceSerializer(serializers.ModelSerializer): - project_id = serializers.IntegerField(read_only=True) - project = serializers.PrimaryKeyRelatedField( - queryset=Project.objects.all(), - write_only=True, - required=False, - ) - - 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})?$") diff --git a/users/helpers.py b/users/helpers.py index dd8dc7a9..b513be63 100644 --- a/users/helpers.py +++ b/users/helpers.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from django.contrib.sites.shortcuts import get_current_site from django.core.cache import cache -from django.db import transaction +from django.db import IntegrityError, transaction from django.urls import reverse from django.utils import timezone from django.utils.timezone import now @@ -140,7 +140,21 @@ def update_achievements(achievements, pk): achievement.id: achievement for achievement in UserAchievement.objects.filter(user_id=pk) } - seen_ids: set[int] = set() + existing_ids = set(existing_achievements.keys()) + payload_existing_ids = { + achievement.get("id") + for achievement in achievements + if isinstance(achievement, dict) + and achievement.get("id") in existing_achievements + } + stale_ids = existing_ids - payload_existing_ids + + if stale_ids: + UserAchievement.objects.filter(id__in=stale_ids).delete() + for stale_id in stale_ids: + existing_achievements.pop(stale_id, None) + + seen_pairs: set[tuple[str, int | None]] = set() for achievement_payload in achievements: if not isinstance(achievement_payload, dict): @@ -164,34 +178,71 @@ def update_achievements(achievements, pk): if achievement_id and achievement_id in existing_achievements: achievement_instance = existing_achievements[achievement_id] - title = achievement_payload.get("title") - status = achievement_payload.get("status") + title = achievement_payload.get("title", achievement_instance.title) + status = achievement_payload.get("status", achievement_instance.status) + year = ( + achievement_payload.get("year") + if has_year_key + else achievement_instance.year + ) - if title is not None: - achievement_instance.title = title - if status is not None: - achievement_instance.status = status + achievement_instance.title = title + achievement_instance.status = status if has_year_key: - achievement_instance.year = achievement_payload.get("year") - achievement_instance.save() + achievement_instance.year = year else: - achievement_instance = UserAchievement.objects.create( + achievement_instance = UserAchievement( user_id=pk, title=achievement_payload.get("title"), status=achievement_payload.get("status"), year=achievement_payload.get("year"), ) + title = achievement_instance.title + year = achievement_instance.year + + duplicate_key = (title, year) + if duplicate_key in seen_pairs: + raise ValidationError( + { + "achievements": [ + f"Достижение с названием '{title}' и годом '{year}' указано несколько раз." + ] + } + ) - seen_ids.add(achievement_instance.id) + existing_conflict = ( + UserAchievement.objects.filter( + user_id=pk, title=title, year=year + ) + .exclude(id=achievement_instance.id) + .exists() + ) + if existing_conflict: + raise ValidationError( + { + "achievements": [ + f"Достижение с названием '{title}' за {year} уже существует." + ] + } + ) + + try: + achievement_instance.save() + except IntegrityError: + raise ValidationError( + { + "achievements": [ + f"Достижение с названием '{title}' за {year} уже существует." + ] + } + ) from None + + seen_pairs.add(duplicate_key) if file_links is not None: user_files = _resolve_user_files(file_links, pk) achievement_instance.files.set(user_files) - stale_ids = set(existing_achievements.keys()) - seen_ids - if stale_ids: - UserAchievement.objects.filter(id__in=stale_ids).delete() - def update_links(links, pk): """ diff --git a/vacancy/managers.py b/vacancy/managers.py index f086a39d..fe7e3fd9 100644 --- a/vacancy/managers.py +++ b/vacancy/managers.py @@ -16,18 +16,26 @@ def get_vacancy_for_list_view(self): "description", "project__id", "is_active", + "datetime_created", ) ) def get_vacancy_for_detail_view(self): return ( self.get_queryset() - .select_related("project") + .select_related("project", "project__industry") + .prefetch_related("project__links") .only( "role", "required_skills", "description", "project", + "project__name", + "project__description", + "project__image_address", + "project__is_company", + "project__industry", + "project__links__link", "is_active", "datetime_created", "datetime_updated", diff --git a/vacancy/serializers.py b/vacancy/serializers.py index a3f6e733..5dda57c7 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.utils import timezone from rest_framework import serializers from core.models import Skill, SkillToObject @@ -72,11 +73,33 @@ def get_response_count(self, obj): return obj.vacancy_requests.filter(is_approved=None).count() +def _format_datetime_with_seconds(value): + if value is None: + return None + if timezone.is_aware(value): + value = timezone.localtime(value) + try: + return value.isoformat(timespec="seconds") + except TypeError: + # Fallback for Python versions without timespec support + return value.replace(microsecond=0).isoformat() + + +class VacancyCreationDateSerializerMixin(serializers.Serializer): + date_create_time = serializers.SerializerMethodField() + + @staticmethod + def get_date_create_time(obj: Vacancy): + return _format_datetime_with_seconds(getattr(obj, "datetime_created", None)) + + class ProjectVacancyListSerializer( + VacancyCreationDateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, RequiredSkillsSerializerMixin[Vacancy], ): + class Meta: model = Vacancy fields = [ @@ -89,10 +112,17 @@ class Meta: "is_active", "datetime_closed", "response_count", + "date_create_time", ] class ProjectForVacancySerializer(serializers.ModelSerializer[Project]): + links = serializers.SerializerMethodField() + + @staticmethod + def get_links(project: Project) -> list[str]: + return [link.link for link in project.links.all()] + class Meta: model = Project fields = [ @@ -101,10 +131,13 @@ class Meta: "description", "image_address", "is_company", + "industry", + "links", ] class VacancyDetailSerializer( + VacancyCreationDateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, AbstractVacancyEnumFields, @@ -127,6 +160,7 @@ class Meta: "datetime_updated", "datetime_closed", "response_count", + "date_create_time", "required_experience", "work_schedule", "work_format", @@ -136,10 +170,12 @@ class Meta: class VacancyListSerializer( + VacancyCreationDateSerializerMixin, serializers.ModelSerializer, RequiredSkillsSerializerMixin[Vacancy], AbstractVacancyReadOnlyFields, ): + class Meta: model = Vacancy fields = [ @@ -151,6 +187,7 @@ class Meta: "is_active", "datetime_closed", "response_count", + "date_create_time", ] read_only_fields = [ "project", @@ -194,11 +231,13 @@ def validate(self, data): class ProjectVacancyCreateListSerializer( + VacancyCreationDateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, AbstractVacancyEnumFields, RequiredSkillsWriteSerializerMixin[Vacancy], ): + def create(self, validated_data): project = validated_data["project"] if project.leader != self.context["request"].user: @@ -247,6 +286,7 @@ class Meta: "is_active", "datetime_closed", "response_count", + "date_create_time", "required_experience", "work_schedule", "work_format", From 2d8d9867285c3af545ef1a5590ab6a35776a7b40 Mon Sep 17 00:00:00 2001 From: Toksi Date: Thu, 30 Oct 2025 14:37:35 +0500 Subject: [PATCH 19/21] =?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=BE=20=D0=B8=D0=BC=D1=8F=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=20=D0=BD=D0=BE=D0=B2=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8;=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BB=D0=BE=D0=B3=D0=BE=D1=82=D0=B8=D0=BF=D1=8B=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC;=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=20=D0=B4=D0=B8=D0=B0?= =?UTF-8?q?=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD=20=D0=B3=D0=BE=D0=B4=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- news/mapping.py | 2 +- users/serializers.py | 3 ++- users/validators.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/news/mapping.py b/news/mapping.py index cf736be4..9e1f90e0 100644 --- a/news/mapping.py +++ b/news/mapping.py @@ -14,7 +14,7 @@ class NewsMapping: @classmethod def get_name(cls, content_object) -> str: if isinstance(content_object, User): - f"{content_object.first_name} {content_object.last_name}" + return f"{content_object.first_name} {content_object.last_name}" if isinstance(content_object, Project) or isinstance( content_object, PartnerProgram ): diff --git a/users/serializers.py b/users/serializers.py index ac04468a..a210adc9 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -446,10 +446,11 @@ class Meta: class UserProgramsSerializer(serializers.ModelSerializer): year = serializers.SerializerMethodField() + logo = serializers.CharField(source="image_address", read_only=True) class Meta: model = PartnerProgram - fields = ["id", "tag", "name", "year"] + fields = ["id", "tag", "name", "year", "logo"] def get_year(self, program: PartnerProgram) -> int | None: user_program_profile = PartnerProgramUserProfile.objects.filter( diff --git a/users/validators.py b/users/validators.py index 61e9e527..7d6e506c 100644 --- a/users/validators.py +++ b/users/validators.py @@ -45,11 +45,11 @@ def specialization_exists_validator(pk: int): def user_experience_years_range_validator(value: int): """ Check range for choice entry/completion year. - (2000 - `now.year`) + (1971 - `now.year`) """ - if value not in range(2000, timezone.now().year + 1): + if value not in range(1971, timezone.now().year + 1): raise DjangoValidationError( - f"Год должен быть в диапазоне 2000 - {timezone.now().year}" + f"Год должен быть в диапазоне 1971 - {timezone.now().year}" ) From 8cc7c5775e5a75a7b3e4b9b1fd878e33945ddecb Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 31 Oct 2025 15:31:32 +0500 Subject: [PATCH 20/21] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D1=8B,=20?= =?UTF-8?q?=D0=B2=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D1=83=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B0=D1=85=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=B4=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0012_partnerprogram_registration_link.py | 18 ++++++++++++++++++ partner_programs/models.py | 6 ++++++ partner_programs/serializers.py | 3 +++ projects/managers.py | 2 +- projects/serializers.py | 12 +++++++++++- 5 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 partner_programs/migrations/0012_partnerprogram_registration_link.py diff --git a/partner_programs/migrations/0012_partnerprogram_registration_link.py b/partner_programs/migrations/0012_partnerprogram_registration_link.py new file mode 100644 index 00000000..d229008c --- /dev/null +++ b/partner_programs/migrations/0012_partnerprogram_registration_link.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.24 on 2025-10-31 10:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partner_programs', '0011_partnerprogram_is_competitive_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='partnerprogram', + name='registration_link', + field=models.URLField(blank=True, help_text='Адрес страницы регистрации (например, на Tilda)', null=True, verbose_name='Ссылка на регистрацию'), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 34eb2e3a..096dbca2 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -73,6 +73,12 @@ class PartnerProgram(models.Model): blank=True, verbose_name="Ссылка на презентацию", ) + registration_link = models.URLField( + null=True, + blank=True, + verbose_name="Ссылка на регистрацию", + help_text="Адрес страницы регистрации (например, на Tilda)", + ) data_schema = models.JSONField( verbose_name="Схема данных в формате JSON", help_text="Ключи - имена полей, значения - тип поля ввода", diff --git a/partner_programs/serializers.py b/partner_programs/serializers.py index 3bd6a09a..21083e1f 100644 --- a/partner_programs/serializers.py +++ b/partner_programs/serializers.py @@ -45,6 +45,7 @@ class Meta: "name", "image_address", "short_description", + "registration_link", "datetime_registration_ends", "datetime_started", "datetime_finished", @@ -110,6 +111,7 @@ class Meta: "image_address", "cover_image_address", "presentation_address", + "registration_link", "views_count", "datetime_registration_ends", "is_user_manager", @@ -131,6 +133,7 @@ class Meta: "cover_image_address", "advertisement_image_address", "presentation_address", + "registration_link", "datetime_registration_ends", "is_user_manager", ) diff --git a/projects/managers.py b/projects/managers.py index 5144f399..bf701459 100644 --- a/projects/managers.py +++ b/projects/managers.py @@ -16,7 +16,7 @@ def get_draft_projects_for_user(self): class ProjectManager(Manager): def get_projects_for_list_view(self): - return self.get_queryset().filter(draft=False) + return self.get_queryset().filter(draft=False).prefetch_related("program_links") def get_user_projects_for_list_view(self): return self.get_queryset().distinct() diff --git a/projects/serializers.py b/projects/serializers.py index 6afca590..00d10dc9 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -334,6 +334,7 @@ class Meta: class ProjectListSerializer(serializers.ModelSerializer): views_count = serializers.SerializerMethodField(method_name="count_views") short_description = serializers.SerializerMethodField() + partner_program_id = serializers.SerializerMethodField() @classmethod def count_views(cls, project): @@ -343,6 +344,14 @@ def count_views(cls, project): def get_short_description(cls, project): return project.get_short_description() + @staticmethod + def get_partner_program_id(project): + links_cache = getattr(project, "_prefetched_objects_cache", {}).get( + "program_links" + ) + link = links_cache[0] if links_cache else project.program_links.first() + return link.partner_program_id if link else None + class Meta: model = Project fields = [ @@ -354,9 +363,10 @@ class Meta: "industry", "views_count", "is_company", + "partner_program_id", ] - read_only_fields = ["leader", "views_count", "is_company"] + read_only_fields = ["leader", "views_count", "is_company", "partner_program_id"] def is_valid(self, *, raise_exception=False): return super().is_valid(raise_exception=raise_exception) From f58867738acc0a86dadf3750089ee0fad281d1de Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 31 Oct 2025 16:03:23 +0500 Subject: [PATCH 21/21] =?UTF-8?q?=D0=92=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=20=D0=9F=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D1=8B?= =?UTF-8?q?=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=BB=D0=B5=20=D0=A1=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- partner_programs/admin.py | 46 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/partner_programs/admin.py b/partner_programs/admin.py index a9612592..1e89666a 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -2,6 +2,7 @@ import urllib.parse import tablib +from django import forms from django.contrib import admin from django.db.models import QuerySet from django.http import HttpRequest, HttpResponse @@ -37,7 +38,19 @@ class PartnerProgramFieldInline(admin.TabularInline): @admin.register(PartnerProgram) class PartnerProgramAdmin(admin.ModelAdmin): + class PartnerProgramAdminForm(forms.ModelForm): + class Meta: + model = PartnerProgram + fields = "__all__" + widgets = { + "name": forms.TextInput(attrs={"size": 80}), + "tag": forms.TextInput(attrs={"size": 80}), + "description": forms.Textarea(attrs={"rows": 4, "cols": 82}), + "city": forms.TextInput(attrs={"size": 80}), + } + inlines = [PartnerProgramMaterialInline, PartnerProgramFieldInline] + form = PartnerProgramAdminForm list_display = ("id", "name", "tag", "city", "datetime_created") list_display_links = ( "id", @@ -53,8 +66,39 @@ class PartnerProgramAdmin(admin.ModelAdmin): ) list_filter = ("city",) - filter_horizontal = ("users", "managers") + filter_horizontal = ("managers",) date_hierarchy = "datetime_started" + readonly_fields = ("datetime_created", "datetime_updated") + fieldsets = ( + ( + None, + { + "fields": ( + "name", + "tag", + "description", + "city", + "is_competitive", + "projects_availability", + "draft", + ( + "datetime_started", + "datetime_registration_ends", + "datetime_finished", + ), + ( + "image_address", + "cover_image_address", + "advertisement_image_address", + ), + ("presentation_address", "registration_link"), + "data_schema", + ) + }, + ), + ("Менеджеры программы", {"fields": ("managers",)}), + ("Служебная информация", {"fields": ("datetime_created", "datetime_updated")}), + ) def get_queryset(self, request: HttpRequest) -> QuerySet[PartnerProgram]: qs = super().get_queryset(request)