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..62e7b4b7 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -15,6 +15,9 @@ ProjectSubscribe, ProjectUnsubscribe, ProjectSubscribers, + SwitchLeaderRole, + LeaveProject, + SwitchLeaderRole, ) app_name = "projects" @@ -30,6 +33,11 @@ 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( + "/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..82670c59 100644 --- a/projects/views.py +++ b/projects/views.py @@ -1,7 +1,10 @@ import logging +from typing import Annotated from django.contrib.auth import get_user_model -from django.db.models import Q +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import get_object_or_404 +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 @@ -15,6 +18,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 +26,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, @@ -43,7 +47,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() @@ -247,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): @@ -281,7 +318,7 @@ class AchievementDetail(generics.RetrieveUpdateDestroyAPIView): class ProjectVacancyResponses(generics.GenericAPIView): - serializer_class = VacancyResponseListSerializer + serializer_class = VacancyResponseFullFileInfoListSerializer permission_classes = [IsAuthenticated] def get_queryset(self): @@ -461,3 +498,144 @@ 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) + + +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=current_user_id, + ) + project = Project.objects.select_related("leader").get(id=project_pk) + 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) + + +class DeleteProjectCollaborators(generics.GenericAPIView): + permission_classes = [IsProjectLeader] + + 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 + ) + + def delete(self, request, project_pk: int) -> Response: + requested_collab_id: int = int(self.request.query_params.get("id")) + + project_id, leader_id = self._project_data(project_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) + + +class SwitchLeaderRole(generics.GenericAPIView): + permission_classes = [IsProjectLeader] + queryset = Project.objects.all().select_related("leader") + + @staticmethod + def _get_new_leader(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. """ + ) + + @staticmethod + def _get_project(project_pk: int) -> Project: + return get_object_or_404(Project.objects.all(), id=project_pk) + + 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 + + if project.leader.id == new_leader_id: + return Response( + {"error": "User is already a leader of a project"}, + 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) 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 = ( 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/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), ] 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..3c550114 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 @@ -22,6 +23,16 @@ 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]): class Meta: model = Project @@ -34,7 +45,9 @@ class Meta: class VacancyDetailSerializer( - serializers.ModelSerializer, RequiredSkillsWriteSerializerMixin[Vacancy] + serializers.ModelSerializer, + AbstractVacancyReadOnlyFields, + RequiredSkillsWriteSerializerMixin[Vacancy], ): project = ProjectForVacancySerializer(many=False, read_only=True) @@ -50,11 +63,17 @@ class Meta: "is_active", "datetime_created", "datetime_updated", + "datetime_closed", + "response_count", ] read_only_fields = ["project"] -class VacancyListSerializer(serializers.ModelSerializer, RequiredSkillsSerializerMixin[Vacancy]): +class VacancyListSerializer( + serializers.ModelSerializer, + RequiredSkillsSerializerMixin[Vacancy], + AbstractVacancyReadOnlyFields, +): class Meta: model = Vacancy fields = [ @@ -63,6 +82,8 @@ class Meta: "required_skills", "description", "is_active", + "datetime_closed", + "response_count", ] read_only_fields = [ "project", @@ -70,7 +91,9 @@ class Meta: class ProjectVacancyListSerializer( - serializers.ModelSerializer, RequiredSkillsSerializerMixin[Vacancy] + serializers.ModelSerializer, + AbstractVacancyReadOnlyFields, + RequiredSkillsSerializerMixin[Vacancy], ): class Meta: model = Vacancy @@ -81,11 +104,15 @@ class Meta: "description", "project", "is_active", + "datetime_closed", + "response_count", ] class ProjectVacancyCreateListSerializer( - serializers.ModelSerializer, RequiredSkillsWriteSerializerMixin[Vacancy] + serializers.ModelSerializer, + AbstractVacancyReadOnlyFields, + RequiredSkillsWriteSerializerMixin[Vacancy], ): def create(self, validated_data): project = validated_data["project"] @@ -127,6 +154,8 @@ class Meta: "description", "project", "is_active", + "datetime_closed", + "response_count", ] @@ -178,6 +207,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) diff --git a/vacancy/views.py b/vacancy/views.py index 077e9add..e0c8e357 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,6 +84,10 @@ def get_queryset(self): ) def post(self, request, 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) + try: request.data["user_id"] = self.request.user.id request.data["vacancy"] = vacancy_id @@ -159,12 +164,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)