From 6a92214648d007782769821caadc306dd2d5d457 Mon Sep 17 00:00:00 2001 From: Marsel Narbekov Date: Tue, 16 Jul 2024 12:49:07 +0300 Subject: [PATCH 01/16] PRO-343: Change logic on vacancy response 1. Now when you respond to a vacancy, the response and the vacancy are not deleted. Now the status of the response and the vacancy itself simply changes. 2. Added read-only field datetime_closed. --- vacancy/admin.py | 1 + .../0006_vacancy_datetime_closed.py | 20 ++++++++++++++ vacancy/models.py | 15 +++++++++++ vacancy/serializers.py | 26 ++++++++++++++++--- vacancy/views.py | 6 ++--- 5 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 vacancy/migrations/0006_vacancy_datetime_closed.py diff --git a/vacancy/admin.py b/vacancy/admin.py index 21673c16..de42048d 100644 --- a/vacancy/admin.py +++ b/vacancy/admin.py @@ -24,6 +24,7 @@ class VacancyAdmin(admin.ModelAdmin): inlines = [ VacancySkillToObjectInline, ] + readonly_fields = ('datetime_closed',) list_display_links = ["role"] change_list_template = "vacancies/vacancies_change_list.html" diff --git a/vacancy/migrations/0006_vacancy_datetime_closed.py b/vacancy/migrations/0006_vacancy_datetime_closed.py new file mode 100644 index 00000000..e4e1c421 --- /dev/null +++ b/vacancy/migrations/0006_vacancy_datetime_closed.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2024-07-15 21:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vacancy", "0005_remove_vacancy_required_skills_old"), + ] + + operations = [ + migrations.AddField( + model_name="vacancy", + name="datetime_closed", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Дата закрытия" + ), + ), + ] diff --git a/vacancy/models.py b/vacancy/models.py index e05a9383..b3a34486 100644 --- a/vacancy/models.py +++ b/vacancy/models.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models +from django.utils import timezone from files.models import UserFile from projects.models import Project @@ -42,6 +43,9 @@ class Vacancy(models.Model): datetime_updated = models.DateTimeField( verbose_name="Дата обновления", null=False, auto_now=True ) + datetime_closed = models.DateTimeField( + verbose_name="Дата закрытия", null=True, blank=True + ) objects = VacancyManager() @@ -51,6 +55,17 @@ def get_required_skills(self): required_skills.append(sto.skill) return required_skills + def update_datetime_closed(self): + """Update datetime_closed based on the is_active status.""" + if not self.is_active and self.datetime_closed is None: + self.datetime_closed = timezone.now() + elif self.is_active: + self.datetime_closed = None + + def save(self, *args, **kwargs): + self.update_datetime_closed() + super().save(*args, **kwargs) + def __str__(self) -> str: return f"Vacancy<{self.id}> - {self.role}" diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 89f66169..e6eb37a0 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -22,6 +22,11 @@ class RequiredSkillsWriteSerializerMixin(RequiredSkillsSerializerMixin): ) +class AbstractVacancyReadOnlyFields(serializers.Serializer): + """Abstract read-only fields for Vacancy.""" + datetime_closed = serializers.DateTimeField(read_only=True) + + class ProjectForVacancySerializer(serializers.ModelSerializer[Project]): class Meta: model = Project @@ -34,7 +39,9 @@ class Meta: class VacancyDetailSerializer( - serializers.ModelSerializer, RequiredSkillsWriteSerializerMixin[Vacancy] + serializers.ModelSerializer, + AbstractVacancyReadOnlyFields, + RequiredSkillsWriteSerializerMixin[Vacancy], ): project = ProjectForVacancySerializer(many=False, read_only=True) @@ -50,11 +57,16 @@ class Meta: "is_active", "datetime_created", "datetime_updated", + "datetime_closed", ] read_only_fields = ["project"] -class VacancyListSerializer(serializers.ModelSerializer, RequiredSkillsSerializerMixin[Vacancy]): +class VacancyListSerializer( + serializers.ModelSerializer, + RequiredSkillsSerializerMixin[Vacancy], + AbstractVacancyReadOnlyFields, +): class Meta: model = Vacancy fields = [ @@ -70,7 +82,9 @@ class Meta: class ProjectVacancyListSerializer( - serializers.ModelSerializer, RequiredSkillsSerializerMixin[Vacancy] + serializers.ModelSerializer, + AbstractVacancyReadOnlyFields, + RequiredSkillsSerializerMixin[Vacancy], ): class Meta: model = Vacancy @@ -81,11 +95,14 @@ class Meta: "description", "project", "is_active", + "datetime_closed", ] class ProjectVacancyCreateListSerializer( - serializers.ModelSerializer, RequiredSkillsWriteSerializerMixin[Vacancy] + serializers.ModelSerializer, + AbstractVacancyReadOnlyFields, + RequiredSkillsWriteSerializerMixin[Vacancy], ): def create(self, validated_data): project = validated_data["project"] @@ -127,6 +144,7 @@ class Meta: "description", "project", "is_active", + "datetime_closed", ] diff --git a/vacancy/views.py b/vacancy/views.py index 1791399b..fc14eccd 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -159,12 +159,12 @@ def post(self, request, pk): schema_id=2, ) ) - + # After acceptance, closes the vacancy. + vacancy.is_active = False + vacancy.save() new_collaborator.save() - # vacancy.project.collaborator_set.add(vacancy_request.user) - vacancy.project.save() vacancy_request.save() - vacancy.delete() return Response(status=status.HTTP_200_OK) From 5b5f4ceefaf763a0adfc8b424e924b74e2fdf5df Mon Sep 17 00:00:00 2001 From: Marsel Narbekov Date: Tue, 16 Jul 2024 13:18:19 +0300 Subject: [PATCH 02/16] Hotfix migration The migration is irrelevant and breaks other migrations, providing access to the database before formation. The edits described in this migration have already been done manually. --- ...0003_migrate_old_required_skills_to_new.py | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/vacancy/migrations/0003_migrate_old_required_skills_to_new.py b/vacancy/migrations/0003_migrate_old_required_skills_to_new.py index 613bda04..fe12158a 100644 --- a/vacancy/migrations/0003_migrate_old_required_skills_to_new.py +++ b/vacancy/migrations/0003_migrate_old_required_skills_to_new.py @@ -1,24 +1,31 @@ # Generated by Django 4.2.3 on 2024-03-02 22:32 -from django.contrib.contenttypes.models import ContentType + +""" +The migration is irrelevant and breaks other migrations, +providing access to the database before formation. +The edits described in this migration have already been done manually. +""" + +# from django.contrib.contenttypes.models import ContentType from django.db import migrations -from core.models import Skill, SkillToObject -from vacancy.models import Vacancy +# from core.models import Skill, SkillToObject +# from vacancy.models import Vacancy -def migrate_required_skills(apps, schema_editor): - for vacancy in Vacancy.objects.all(): - if vacancy.required_skills_old: - for skill_name in vacancy.required_skills_old.lower().split(','): - skill_name = skill_name.strip() - skill = Skill.objects.filter(name__iexact=skill_name).first() - if skill: - SkillToObject.objects.get_or_create( - skill=skill, content_type=ContentType.objects.get_for_model(Vacancy), object_id=vacancy.id - ) +# def migrate_required_skills(apps, schema_editor): +# for vacancy in Vacancy.objects.all(): +# if vacancy.required_skills_old: +# for skill_name in vacancy.required_skills_old.lower().split(','): +# skill_name = skill_name.strip() +# skill = Skill.objects.filter(name__iexact=skill_name).first() +# if skill: +# SkillToObject.objects.get_or_create( +# skill=skill, content_type=ContentType.objects.get_for_model(Vacancy), object_id=vacancy.id +# ) -def reverse(apps, schema_editor): - SkillToObject.objects.filter(content_type=ContentType.objects.get_for_model(Vacancy)).delete() +# def reverse(apps, schema_editor): +# SkillToObject.objects.filter(content_type=ContentType.objects.get_for_model(Vacancy)).delete() class Migration(migrations.Migration): @@ -29,5 +36,5 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(migrate_required_skills, reverse_code=reverse), + # migrations.RunPython(migrate_required_skills, reverse_code=reverse), ] From 47af22e47e615e6d85c2cd2b71282f988dcd26df Mon Sep 17 00:00:00 2001 From: Marsel Narbekov Date: Tue, 16 Jul 2024 14:05:00 +0300 Subject: [PATCH 03/16] Change: User cannot apply for a closed vacancy --- vacancy/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vacancy/views.py b/vacancy/views.py index fc14eccd..33269bac 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -83,6 +83,10 @@ def get_queryset(self): ) def post(self, request, vacancy_id): + vacancy = Vacancy.objects.get(pk=vacancy_id) + if not vacancy.is_active: + return Response("You cannot apply for a closed vacancy", status.HTTP_400_BAD_REQUEST) + try: request.data["user_id"] = self.request.user.id request.data["vacancy"] = vacancy_id From 330aebbf3fc52eed81068c6f5eac3aac68ae96c4 Mon Sep 17 00:00:00 2001 From: Marsel Narbekov Date: Tue, 16 Jul 2024 14:21:57 +0300 Subject: [PATCH 04/16] fix 500 if vacancy is not-exist --- vacancy/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vacancy/views.py b/vacancy/views.py index 33269bac..7248f70e 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -1,4 +1,5 @@ from django_filters import rest_framework as filters +from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, mixins, permissions, status @@ -83,7 +84,7 @@ def get_queryset(self): ) def post(self, request, vacancy_id): - vacancy = Vacancy.objects.get(pk=vacancy_id) + vacancy = get_object_or_404(Vacancy, pk=vacancy_id) if not vacancy.is_active: return Response("You cannot apply for a closed vacancy", status.HTTP_400_BAD_REQUEST) From e5c4a1d938837269b6c86831f374fea353853d58 Mon Sep 17 00:00:00 2001 From: Marsel Narbekov Date: Tue, 16 Jul 2024 16:00:29 +0300 Subject: [PATCH 05/16] PRO-347: Bug with dataset_migration_applied For a new user and a user who has updated their profile, the dataset_migration_applied flag now changes its meaning. --- users/models.py | 2 ++ users/signals.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/users/models.py b/users/models.py index 74b31155..6f22a421 100644 --- a/users/models.py +++ b/users/models.py @@ -51,6 +51,8 @@ class CustomUser(AbstractUser): speciality: CharField instance the user's specialty. datetime_updated: A DateTimeField indicating date of update. datetime_created: A DateTimeField indicating date of creation. + dataset_migration_applied: A BooleanField indicating based on + the `v2_speciality` and `skills`. """ ADMIN = ADMIN diff --git a/users/signals.py b/users/signals.py index ac7e6471..1937ff41 100644 --- a/users/signals.py +++ b/users/signals.py @@ -1,4 +1,5 @@ from django.core.mail import EmailMultiAlternatives +from django.db import transaction from django.db.models.signals import post_save from django.dispatch import receiver from django.template.loader import render_to_string @@ -25,6 +26,21 @@ def create_or_update_user_types(sender, instance, created, **kwargs): instance.save() +@receiver(post_save, sender=CustomUser) +def update_dataset_migration_applied(sender, instance, **kwargs): + """Update the `dataset_migration_applied` attribute based on the presence of `v2_speciality` and `skills`.""" + + def update_migration(): + dataset_migration_applied = bool(instance.v2_speciality and instance.skills.exists()) + if instance.dataset_migration_applied != dataset_migration_applied: + CustomUser.objects.filter(pk=instance.pk).update( + dataset_migration_applied=dataset_migration_applied + ) + + # Delayed execution until transaction completes. + transaction.on_commit(update_migration) + + @receiver(reset_password_token_created) def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs): reset_password_url = ( From 1a3b658e3619901b78d775520853158736d7fb55 Mon Sep 17 00:00:00 2001 From: Alexey Kudelko Date: Wed, 17 Jul 2024 10:44:04 +0300 Subject: [PATCH 06/16] added endpoint to leave project --- projects/urls.py | 2 ++ projects/views.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/projects/urls.py b/projects/urls.py index f19ab6b7..889dfcaf 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -15,6 +15,7 @@ ProjectSubscribe, ProjectUnsubscribe, ProjectSubscribers, + LeaveProject, ) app_name = "projects" @@ -30,6 +31,7 @@ path("/news//set_viewed/", NewsDetailSetViewed.as_view()), path("/news//set_liked/", NewsDetailSetLiked.as_view()), path("/collaborators/", ProjectCollaborators.as_view()), + path("/collaborators/leave/", LeaveProject.as_view()), path("/", ProjectDetail.as_view()), path("/recommended_users", ProjectRecommendedUsers.as_view()), path("count/", ProjectCountView.as_view()), diff --git a/projects/views.py b/projects/views.py index 530252d9..53208b8c 100644 --- a/projects/views.py +++ b/projects/views.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.db.models import Q +from django.shortcuts import get_object_or_404 from django_filters import rest_framework as filters from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema @@ -22,7 +23,7 @@ check_related_fields_update, update_partner_program, ) -from projects.models import Project, Achievement, ProjectNews +from projects.models import Project, Achievement, ProjectNews, Collaborator from projects.pagination import ProjectNewsPagination, ProjectsPagination from projects.permissions import ( IsProjectLeaderOrReadOnlyForNonDrafts, @@ -461,3 +462,14 @@ def post(self, request, project_pk): return Response( {"detail": "Subscriber was successfully removed"}, status=status.HTTP_200_OK ) + + +class LeaveProject(generics.GenericAPIView): + permission_classes = [IsAuthenticated] + + def delete(self, request, pk: int) -> Response: + collaborator = get_object_or_404( + Collaborator.objects.all(), project_id=pk, user_id=self.request.user.id + ) + collaborator.delete() + return Response(status=204) From d14c47f18a7292a465dd89c25b097fafb36b2d8b Mon Sep 17 00:00:00 2001 From: Marsel Narbekov Date: Wed, 17 Jul 2024 12:12:30 +0300 Subject: [PATCH 07/16] PRO-350: full file info Now endpoin projects/{id}/responses/ returns full file info. --- projects/views.py | 4 ++-- vacancy/serializers.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/projects/views.py b/projects/views.py index 530252d9..c67b796c 100644 --- a/projects/views.py +++ b/projects/views.py @@ -43,7 +43,7 @@ from users.models import LikesOnProject from users.serializers import UserListSerializer from vacancy.models import VacancyResponse -from vacancy.serializers import VacancyResponseListSerializer +from vacancy.serializers import VacancyResponseFullFileInfoListSerializer logger = logging.getLogger() @@ -281,7 +281,7 @@ class AchievementDetail(generics.RetrieveUpdateDestroyAPIView): class ProjectVacancyResponses(generics.GenericAPIView): - serializer_class = VacancyResponseListSerializer + serializer_class = VacancyResponseFullFileInfoListSerializer permission_classes = [IsAuthenticated] def get_queryset(self): diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 89f66169..00086c4c 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -5,6 +5,7 @@ from core.models import Skill, SkillToObject from core.serializers import SkillToObjectSerializer from files.models import UserFile +from files.serializers import UserFileSerializer from projects.models import Project from users.serializers import UserDetailSerializer from vacancy.models import Vacancy, VacancyResponse @@ -178,6 +179,11 @@ 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) From bec27d375f9feb076dac894d7c47c343bd1cfec9 Mon Sep 17 00:00:00 2001 From: Alexey Kudelko Date: Wed, 17 Jul 2024 12:16:54 +0300 Subject: [PATCH 08/16] added endpoint to delete collaborators --- projects/urls.py | 2 ++ projects/views.py | 51 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/projects/urls.py b/projects/urls.py index f19ab6b7..af23aba5 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -15,6 +15,7 @@ ProjectSubscribe, ProjectUnsubscribe, ProjectSubscribers, + DeleteProjectCollaborators, ) app_name = "projects" @@ -30,6 +31,7 @@ path("/news//set_viewed/", NewsDetailSetViewed.as_view()), path("/news//set_liked/", NewsDetailSetLiked.as_view()), path("/collaborators/", ProjectCollaborators.as_view()), + path("/collaborators/kick/", DeleteProjectCollaborators.as_view()), path("/", ProjectDetail.as_view()), path("/recommended_users", ProjectRecommendedUsers.as_view()), path("count/", ProjectCountView.as_view()), diff --git a/projects/views.py b/projects/views.py index 530252d9..a9224c24 100644 --- a/projects/views.py +++ b/projects/views.py @@ -1,7 +1,8 @@ import logging +from typing import Annotated from django.contrib.auth import get_user_model -from django.db.models import Q +from django.db.models import Q, QuerySet from django_filters import rest_framework as filters from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema @@ -22,7 +23,7 @@ check_related_fields_update, update_partner_program, ) -from projects.models import Project, Achievement, ProjectNews +from projects.models import Project, Achievement, ProjectNews, Collaborator from projects.pagination import ProjectNewsPagination, ProjectsPagination from projects.permissions import ( IsProjectLeaderOrReadOnlyForNonDrafts, @@ -461,3 +462,49 @@ def post(self, request, project_pk): return Response( {"detail": "Subscriber was successfully removed"}, status=status.HTTP_200_OK ) + + +class DeleteProjectCollaborators(generics.GenericAPIView): + permission_classes = [IsProjectLeader] + queryset = Project.objects.all().select_related("leader") + + def _project_data( + self, + ) -> tuple[Annotated[int, "ID проекта"], Annotated[int, "ID лидера проекта"]]: + project = self.get_object() + return project.id, project.leader.id + + def _collabs_queryset( + self, project_id: int, requested_ids: set[int], leader_id: int + ) -> QuerySet: + return ( + Collaborator.objects.filter( + user__id__in=requested_ids, project__id=project_id + ) + .exclude(user__id=leader_id) # чтоб случайно лидер сам себя не удалил + .values_list("id", flat=True) + ) + + def delete(self, request, pk: int) -> Response: + requested_collabs_ids: set[int] = set(request.data) + + project_id, leader_id = self._project_data() + existing_collabs_ids: set[int] = set( + self._collabs_queryset(project_id, requested_collabs_ids, leader_id) + ) + + if leader_id in requested_collabs_ids: + return Response( + {"error": f"User with id: {leader_id} is a leader of a project."}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + if unexisting_collabs := requested_collabs_ids - existing_collabs_ids: + return Response( + { + "error": f"Users with ids: {list(unexisting_collabs)} are not part of this project." + }, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + Collaborator.objects.filter(id__in=existing_collabs_ids).delete() + return Response(status=204) From a689d23bc86b37d8d24d426d066c59e682afe1d6 Mon Sep 17 00:00:00 2001 From: Alexey Kudelko Date: Wed, 17 Jul 2024 13:02:01 +0300 Subject: [PATCH 09/16] added endpoint to switch leader --- projects/exceptions.py | 9 +++++++++ projects/urls.py | 2 ++ projects/views.py | 36 +++++++++++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 projects/exceptions.py diff --git a/projects/exceptions.py b/projects/exceptions.py new file mode 100644 index 00000000..cc1306ce --- /dev/null +++ b/projects/exceptions.py @@ -0,0 +1,9 @@ +from rest_framework import status +from rest_framework.exceptions import APIException +from django.utils.translation import gettext_lazy as _ + + +class CollaboratorDoesNotExist(APIException): + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + default_detail = _("Not found.") + default_code = "not_found" diff --git a/projects/urls.py b/projects/urls.py index f19ab6b7..cc785712 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -15,6 +15,7 @@ ProjectSubscribe, ProjectUnsubscribe, ProjectSubscribers, + SwitchLeaderRole, ) app_name = "projects" @@ -30,6 +31,7 @@ path("/news//set_viewed/", NewsDetailSetViewed.as_view()), path("/news//set_liked/", NewsDetailSetLiked.as_view()), path("/collaborators/", ProjectCollaborators.as_view()), + path("/collaborators/switch-leader/", SwitchLeaderRole.as_view()), path("/", ProjectDetail.as_view()), path("/recommended_users", ProjectRecommendedUsers.as_view()), path("count/", ProjectCountView.as_view()), diff --git a/projects/views.py b/projects/views.py index 530252d9..50f9a36f 100644 --- a/projects/views.py +++ b/projects/views.py @@ -1,6 +1,7 @@ import logging from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from django_filters import rest_framework as filters from drf_yasg import openapi @@ -15,6 +16,7 @@ from core.serializers import SetLikedSerializer from core.services import add_view, set_like from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from projects.exceptions import CollaboratorDoesNotExist from projects.filters import ProjectFilter from projects.constants import VERBOSE_STEPS from projects.helpers import ( @@ -22,7 +24,7 @@ check_related_fields_update, update_partner_program, ) -from projects.models import Project, Achievement, ProjectNews +from projects.models import Project, Achievement, ProjectNews, Collaborator from projects.pagination import ProjectNewsPagination, ProjectsPagination from projects.permissions import ( IsProjectLeaderOrReadOnlyForNonDrafts, @@ -461,3 +463,35 @@ def post(self, request, project_pk): return Response( {"detail": "Subscriber was successfully removed"}, status=status.HTTP_200_OK ) + + +class SwitchLeaderRole(generics.GenericAPIView): + permission_classes = [IsProjectLeader] + queryset = Project.objects.all().select_related("leader") + + def _get_new_leader(self, user_id: int, project: Project) -> Collaborator: + try: + return Collaborator.objects.select_related("user").get( + user_id=user_id, project=project + ) + except ObjectDoesNotExist: + raise CollaboratorDoesNotExist( + f"""Collaborator with user_id: {user_id} does not exist. Either user_id is not correct, or project_id + is not correct, or try adding this user to a project (as collaborator) before making them a leader. """ + ) + + def patch(self, request, pk: int): + project = self.get_object() + + new_leader_id = int(request.data["new_leader_id"]) + new_leader = self._get_new_leader(new_leader_id, project) + + if project.leader.id == new_leader_id: + return Response( + {"error": "User is already a leader of a project"}, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + project.leader = new_leader.user + project.save() + return Response(status=204) From 1d8003bbbc565604ffcd1cb03bf522b2127a2638 Mon Sep 17 00:00:00 2001 From: Alexey Kudelko Date: Fri, 19 Jul 2024 12:08:09 +0300 Subject: [PATCH 10/16] refactor code --- projects/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/views.py b/projects/views.py index e63df9bf..6794e7ca 100644 --- a/projects/views.py +++ b/projects/views.py @@ -551,7 +551,6 @@ def patch(self, request, project_pk: int, user_to_leader_pk: int) -> Response: project = self._get_project(project_pk) new_leader_id = user_to_leader_pk - new_leader = self._get_new_leader(new_leader_id, project) if project.leader.id == new_leader_id: return Response( @@ -559,6 +558,8 @@ def patch(self, request, project_pk: int, user_to_leader_pk: int) -> Response: status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) + new_leader = self._get_new_leader(new_leader_id, project) + project.leader = new_leader.user project.save() return Response(status=204) From 4dd271c2eb73a01bab4d5846ddcc0f1898c244fa Mon Sep 17 00:00:00 2001 From: Marsel Narbekov Date: Fri, 19 Jul 2024 14:21:07 +0300 Subject: [PATCH 11/16] PRO-336: responses number in vacancy Added read-only field with response count. --- vacancy/serializers.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/vacancy/serializers.py b/vacancy/serializers.py index ba324a65..3c550114 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -26,6 +26,11 @@ class RequiredSkillsWriteSerializerMixin(RequiredSkillsSerializerMixin): class AbstractVacancyReadOnlyFields(serializers.Serializer): """Abstract read-only fields for Vacancy.""" datetime_closed = serializers.DateTimeField(read_only=True) + response_count = serializers.SerializerMethodField(read_only=True) + + def get_response_count(self, obj): + """Returns count non status responses.""" + return obj.vacancy_requests.filter(is_approved=None).count() class ProjectForVacancySerializer(serializers.ModelSerializer[Project]): @@ -59,6 +64,7 @@ class Meta: "datetime_created", "datetime_updated", "datetime_closed", + "response_count", ] read_only_fields = ["project"] @@ -76,6 +82,8 @@ class Meta: "required_skills", "description", "is_active", + "datetime_closed", + "response_count", ] read_only_fields = [ "project", @@ -97,6 +105,7 @@ class Meta: "project", "is_active", "datetime_closed", + "response_count", ] @@ -146,6 +155,7 @@ class Meta: "project", "is_active", "datetime_closed", + "response_count", ] From a3535bea33c6c77c05e28ed0809bd50a95a4c8b9 Mon Sep 17 00:00:00 2001 From: Alexey Kudelko Date: Mon, 22 Jul 2024 10:32:55 +0300 Subject: [PATCH 12/16] fixed endpoint to delete collab --- projects/views.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/projects/views.py b/projects/views.py index 6794e7ca..aea2fef8 100644 --- a/projects/views.py +++ b/projects/views.py @@ -482,12 +482,13 @@ def delete(self, request, project_pk: int) -> Response: class DeleteProjectCollaborators(generics.GenericAPIView): permission_classes = [IsProjectLeader] - queryset = Project.objects.all().select_related("leader") def _project_data( - self, + self, project_pk: int ) -> tuple[Annotated[int, "ID проекта"], Annotated[int, "ID лидера проекта"]]: - project = self.get_object() + project = get_object_or_404( + Project.objects.select_related("leader"), id=project_pk + ) return project.id, project.leader.id @staticmethod @@ -505,7 +506,7 @@ def _collabs_queryset( def delete(self, request, project_pk: int) -> Response: requested_collabs_ids: set[int] = set(request.data) - project_id, leader_id = self._project_data() + project_id, leader_id = self._project_data(project_pk) existing_collabs_ids: set[int] = set( self._collabs_queryset(project_id, requested_collabs_ids, leader_id) ) @@ -516,6 +517,9 @@ def delete(self, request, project_pk: int) -> Response: status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) if unexisting_collabs := requested_collabs_ids - existing_collabs_ids: + raise ValueError( + unexisting_collabs, ".", requested_collabs_ids, ".", existing_collabs_ids + ) return Response( { "error": f"Users with ids: {list(unexisting_collabs)} are not part of this project." From 42a05d43868e80b773a7aa1cf5480abbea6f68bf Mon Sep 17 00:00:00 2001 From: Alexey Kudelko Date: Mon, 22 Jul 2024 10:56:39 +0300 Subject: [PATCH 13/16] fixed endpoint to delete collab --- projects/urls.py | 2 -- projects/views.py | 87 +++++++++++++++++++++++++++++++---------------- 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/projects/urls.py b/projects/urls.py index de4d6686..3504f8a3 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -16,7 +16,6 @@ ProjectUnsubscribe, ProjectSubscribers, LeaveProject, - DeleteProjectCollaborators, SwitchLeaderRole, ) @@ -34,7 +33,6 @@ path("/news//set_liked/", NewsDetailSetLiked.as_view()), path("/collaborators/", ProjectCollaborators.as_view()), path("/collaborators/leave/", LeaveProject.as_view()), - path("/collaborators/kick/", DeleteProjectCollaborators.as_view()), path( "/collaborators//switch-leader/", SwitchLeaderRole.as_view(), diff --git a/projects/views.py b/projects/views.py index aea2fef8..e2a99ae3 100644 --- a/projects/views.py +++ b/projects/views.py @@ -251,15 +251,48 @@ def post(self, request, pk: int): return Response(status=200) def delete(self, request, pk: int): - """delete collaborators from the project""" - m2m_manager = self.get_object().collaborators - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - collaborators = serializer.validated_data["collaborators"] - for user in collaborators: - # note: doesn't raise an error when we try to delete someone who isn't a collaborator - m2m_manager.remove(user) - return Response(status=200) + """delete collaborator from project""" + requested_collab_id: int = int(self.request.query_params.get("id")) + + project_id, leader_id = self._project_data(pk) + existing_collab_id = self._collabs_queryset( + project_id, requested_collab_id, leader_id + ) + + if leader_id == requested_collab_id: + return Response( + { + "error": f"User with id: {leader_id} is a leader of a project. " + f"Be careful not to delete yourself from a project!" + }, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + if not existing_collab_id: + return Response( + { + "error": f"User with id: {requested_collab_id} are not part of this project." + }, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + existing_collab_id.delete() + return Response(status=204) + + def _project_data( + self, project_pk: int + ) -> tuple[Annotated[int, "ID проекта"], Annotated[int, "ID лидера проекта"]]: + project = get_object_or_404( + Project.objects.select_related("leader"), id=project_pk + ) + return project.id, project.leader.id + + @staticmethod + def _collabs_queryset(project_id: int, requested_id: int, leader_id: int) -> QuerySet: + return Collaborator.objects.exclude( + user__id=leader_id + ).get( # чтоб случайно лидер сам себя не удалил + user__id=requested_id, project__id=project_id + ) class ProjectSteps(APIView): @@ -492,42 +525,38 @@ def _project_data( return project.id, project.leader.id @staticmethod - def _collabs_queryset( - project_id: int, requested_ids: set[int], leader_id: int - ) -> QuerySet: - return ( - Collaborator.objects.filter( - user__id__in=requested_ids, project__id=project_id - ) - .exclude(user__id=leader_id) # чтоб случайно лидер сам себя не удалил - .values_list("id", flat=True) + def _collabs_queryset(project_id: int, requested_id: int, leader_id: int) -> QuerySet: + return Collaborator.objects.exclude( + user__id=leader_id + ).get( # чтоб случайно лидер сам себя не удалил + user__id=requested_id, project__id=project_id ) def delete(self, request, project_pk: int) -> Response: - requested_collabs_ids: set[int] = set(request.data) + requested_collab_id: int = int(self.request.query_params.get("id")) project_id, leader_id = self._project_data(project_pk) - existing_collabs_ids: set[int] = set( - self._collabs_queryset(project_id, requested_collabs_ids, leader_id) + existing_collab_id = self._collabs_queryset( + project_id, requested_collab_id, leader_id ) - if leader_id in requested_collabs_ids: + if leader_id == requested_collab_id: return Response( - {"error": f"User with id: {leader_id} is a leader of a project."}, + { + "error": f"User with id: {leader_id} is a leader of a project. " + f"Be careful not to delete yourself from a project!" + }, status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) - if unexisting_collabs := requested_collabs_ids - existing_collabs_ids: - raise ValueError( - unexisting_collabs, ".", requested_collabs_ids, ".", existing_collabs_ids - ) + if not existing_collab_id: return Response( { - "error": f"Users with ids: {list(unexisting_collabs)} are not part of this project." + "error": f"User with id: {requested_collab_id} are not part of this project." }, status=status.HTTP_422_UNPROCESSABLE_ENTITY, ) - Collaborator.objects.filter(id__in=existing_collabs_ids).delete() + existing_collab_id.delete() return Response(status=204) From c08780fbf0082937980843f169521af15c487c4b Mon Sep 17 00:00:00 2001 From: Alexey Kudelko Date: Mon, 22 Jul 2024 11:08:35 +0300 Subject: [PATCH 14/16] fixed leave --- projects/views.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/projects/views.py b/projects/views.py index e2a99ae3..67b491aa 100644 --- a/projects/views.py +++ b/projects/views.py @@ -504,11 +504,21 @@ class LeaveProject(generics.GenericAPIView): permission_classes = [IsAuthenticated] def delete(self, request, project_pk: int) -> Response: + current_user_id = self.request.user.id collaborator = get_object_or_404( Collaborator.objects.all(), project_id=project_pk, - user_id=self.request.user.id, + user_id=current_user_id, ) + project = Project.objects.get(id=project_pk).select_related("leader") + if project.leader.id == current_user_id: + return Response( + { + "error": "You can't leave if you are a leader of a project. " + "Please, switch leadership!" + }, + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) collaborator.delete() return Response(status=204) From f45927bd6ee3e2697e87332d62f857ac3f01fd95 Mon Sep 17 00:00:00 2001 From: Alexey Kudelko Date: Mon, 22 Jul 2024 11:12:25 +0300 Subject: [PATCH 15/16] fixed leave --- projects/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/projects/views.py b/projects/views.py index 67b491aa..ee311225 100644 --- a/projects/views.py +++ b/projects/views.py @@ -510,7 +510,11 @@ def delete(self, request, project_pk: int) -> Response: project_id=project_pk, user_id=current_user_id, ) - project = Project.objects.get(id=project_pk).select_related("leader") + project = ( + Project.objects.select_related("leader") + .get(id=project_pk) + .select_related("leader") + ) if project.leader.id == current_user_id: return Response( { From 92e747d88ec5c43ab7525f67686c8b2af5936156 Mon Sep 17 00:00:00 2001 From: Alexey Kudelko Date: Mon, 22 Jul 2024 11:16:18 +0300 Subject: [PATCH 16/16] fixed leave --- projects/views.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/projects/views.py b/projects/views.py index ee311225..65d2901c 100644 --- a/projects/views.py +++ b/projects/views.py @@ -510,11 +510,7 @@ def delete(self, request, project_pk: int) -> Response: project_id=project_pk, user_id=current_user_id, ) - project = ( - Project.objects.select_related("leader") - .get(id=project_pk) - .select_related("leader") - ) + project = Project.objects.select_related("leader").get(id=project_pk) if project.leader.id == current_user_id: return Response( {