Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6a92214
PRO-343: Change logic on vacancy response
pavuchara Jul 16, 2024
5b5f4ce
Hotfix migration
pavuchara Jul 16, 2024
47af22e
Change: User cannot apply for a closed vacancy
pavuchara Jul 16, 2024
330aebb
fix 500 if vacancy is not-exist
pavuchara Jul 16, 2024
e5c4a1d
PRO-347: Bug with dataset_migration_applied
pavuchara Jul 16, 2024
1a3b658
added endpoint to leave project
sh1nkey Jul 17, 2024
d14c47f
PRO-350: full file info
pavuchara Jul 17, 2024
bec27d3
added endpoint to delete collaborators
sh1nkey Jul 17, 2024
a689d23
added endpoint to switch leader
sh1nkey Jul 17, 2024
4c0fc58
Merge pull request #388 from PROCOLLAB-github/feature/more_info_about…
gregor-tokarev Jul 17, 2024
36e1624
Merge pull request #391 from PROCOLLAB-github/feature/loaddata
sh1nkey Jul 17, 2024
69ddd7b
Merge pull request #389 from PROCOLLAB-github/feature/kick-project-pa…
sh1nkey Jul 18, 2024
c0ac887
Merge branch 'dev' into feature/leave-project
sh1nkey Jul 18, 2024
1b88b59
Merge pull request #390 from PROCOLLAB-github/feature/leave-project
sh1nkey Jul 18, 2024
8b7ba52
Merge pull request #385 from PROCOLLAB-github/fix/dont_delete_vacancy…
sh1nkey Jul 18, 2024
144ea57
Merge pull request #387 from PROCOLLAB-github/fix/new_user_dataset_mi…
sh1nkey Jul 18, 2024
d8c1932
refactor code
sh1nkey Jul 19, 2024
1d8003b
refactor code
sh1nkey Jul 19, 2024
8428e9f
Merge branch 'dev' into feature/switch-project-leader
sh1nkey Jul 19, 2024
499e061
Merge pull request #397 from PROCOLLAB-github/feature/switch-project-…
sh1nkey Jul 19, 2024
57e3f81
Merge branch 'dev' into refactor/new-collaborators/functional
sh1nkey Jul 19, 2024
99bf112
Merge pull request #398 from PROCOLLAB-github/refactor/new-collaborat…
sh1nkey Jul 19, 2024
4dd271c
PRO-336: responses number in vacancy
pavuchara Jul 19, 2024
ac3475b
Merge pull request #399 from PROCOLLAB-github/feature/responses_numbe…
sh1nkey Jul 19, 2024
a3535be
fixed endpoint to delete collab
sh1nkey Jul 22, 2024
a65fe24
Merge pull request #400 from PROCOLLAB-github/fix/delete-collabs
sh1nkey Jul 22, 2024
42a05d4
fixed endpoint to delete collab
sh1nkey Jul 22, 2024
b327947
Merge pull request #401 from PROCOLLAB-github/fix/delete-collabs
sh1nkey Jul 22, 2024
c08780f
fixed leave
sh1nkey Jul 22, 2024
ac31a7c
Merge pull request #402 from PROCOLLAB-github/fix/leave-project
sh1nkey Jul 22, 2024
f45927b
fixed leave
sh1nkey Jul 22, 2024
181c4d5
Merge pull request #403 from PROCOLLAB-github/fix/leave-project
sh1nkey Jul 22, 2024
92e747d
fixed leave
sh1nkey Jul 22, 2024
979c18a
Merge pull request #404 from PROCOLLAB-github/fix/leave-project
sh1nkey Jul 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions projects/exceptions.py
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
ProjectSubscribe,
ProjectUnsubscribe,
ProjectSubscribers,
SwitchLeaderRole,
LeaveProject,
SwitchLeaderRole,
)

app_name = "projects"
Expand All @@ -30,6 +33,11 @@
path("<int:project_pk>/news/<int:pk>/set_viewed/", NewsDetailSetViewed.as_view()),
path("<int:project_pk>/news/<int:pk>/set_liked/", NewsDetailSetLiked.as_view()),
path("<int:pk>/collaborators/", ProjectCollaborators.as_view()),
path("<int:project_pk>/collaborators/leave/", LeaveProject.as_view()),
path(
"<int:project_pk>/collaborators/<int:user_to_leader_pk>/switch-leader/",
SwitchLeaderRole.as_view(),
),
path("<int:pk>/", ProjectDetail.as_view()),
path("<int:pk>/recommended_users", ProjectRecommendedUsers.as_view()),
path("count/", ProjectCountView.as_view()),
Expand Down
204 changes: 191 additions & 13 deletions projects/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,14 +18,15 @@
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 (
get_recommended_users,
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,
Expand All @@ -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()

Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions users/signals.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = (
Expand Down
1 change: 1 addition & 0 deletions vacancy/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
39 changes: 23 additions & 16 deletions vacancy/migrations/0003_migrate_old_required_skills_to_new.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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),
]
Loading