Skip to content

Commit 49b00f9

Browse files
authored
Merge pull request #405 from PROCOLLAB-github/dev
merge from dev
2 parents d40ddd1 + 979c18a commit 49b00f9

11 files changed

Lines changed: 331 additions & 36 deletions

File tree

projects/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from rest_framework import status
2+
from rest_framework.exceptions import APIException
3+
from django.utils.translation import gettext_lazy as _
4+
5+
6+
class CollaboratorDoesNotExist(APIException):
7+
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
8+
default_detail = _("Not found.")
9+
default_code = "not_found"

projects/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
ProjectSubscribe,
1616
ProjectUnsubscribe,
1717
ProjectSubscribers,
18+
SwitchLeaderRole,
19+
LeaveProject,
20+
SwitchLeaderRole,
1821
)
1922

2023
app_name = "projects"
@@ -30,6 +33,11 @@
3033
path("<int:project_pk>/news/<int:pk>/set_viewed/", NewsDetailSetViewed.as_view()),
3134
path("<int:project_pk>/news/<int:pk>/set_liked/", NewsDetailSetLiked.as_view()),
3235
path("<int:pk>/collaborators/", ProjectCollaborators.as_view()),
36+
path("<int:project_pk>/collaborators/leave/", LeaveProject.as_view()),
37+
path(
38+
"<int:project_pk>/collaborators/<int:user_to_leader_pk>/switch-leader/",
39+
SwitchLeaderRole.as_view(),
40+
),
3341
path("<int:pk>/", ProjectDetail.as_view()),
3442
path("<int:pk>/recommended_users", ProjectRecommendedUsers.as_view()),
3543
path("count/", ProjectCountView.as_view()),

projects/views.py

Lines changed: 191 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import logging
2+
from typing import Annotated
23

34
from django.contrib.auth import get_user_model
4-
from django.db.models import Q
5+
from django.core.exceptions import ObjectDoesNotExist
6+
from django.shortcuts import get_object_or_404
7+
from django.db.models import Q, QuerySet
58
from django_filters import rest_framework as filters
69
from drf_yasg import openapi
710
from drf_yasg.utils import swagger_auto_schema
@@ -15,14 +18,15 @@
1518
from core.serializers import SetLikedSerializer
1619
from core.services import add_view, set_like
1720
from partner_programs.models import PartnerProgram, PartnerProgramUserProfile
21+
from projects.exceptions import CollaboratorDoesNotExist
1822
from projects.filters import ProjectFilter
1923
from projects.constants import VERBOSE_STEPS
2024
from projects.helpers import (
2125
get_recommended_users,
2226
check_related_fields_update,
2327
update_partner_program,
2428
)
25-
from projects.models import Project, Achievement, ProjectNews
29+
from projects.models import Project, Achievement, ProjectNews, Collaborator
2630
from projects.pagination import ProjectNewsPagination, ProjectsPagination
2731
from projects.permissions import (
2832
IsProjectLeaderOrReadOnlyForNonDrafts,
@@ -43,7 +47,7 @@
4347
from users.models import LikesOnProject
4448
from users.serializers import UserListSerializer
4549
from vacancy.models import VacancyResponse
46-
from vacancy.serializers import VacancyResponseListSerializer
50+
from vacancy.serializers import VacancyResponseFullFileInfoListSerializer
4751

4852
logger = logging.getLogger()
4953

@@ -247,15 +251,48 @@ def post(self, request, pk: int):
247251
return Response(status=200)
248252

249253
def delete(self, request, pk: int):
250-
"""delete collaborators from the project"""
251-
m2m_manager = self.get_object().collaborators
252-
serializer = self.get_serializer(data=request.data)
253-
serializer.is_valid(raise_exception=True)
254-
collaborators = serializer.validated_data["collaborators"]
255-
for user in collaborators:
256-
# note: doesn't raise an error when we try to delete someone who isn't a collaborator
257-
m2m_manager.remove(user)
258-
return Response(status=200)
254+
"""delete collaborator from project"""
255+
requested_collab_id: int = int(self.request.query_params.get("id"))
256+
257+
project_id, leader_id = self._project_data(pk)
258+
existing_collab_id = self._collabs_queryset(
259+
project_id, requested_collab_id, leader_id
260+
)
261+
262+
if leader_id == requested_collab_id:
263+
return Response(
264+
{
265+
"error": f"User with id: {leader_id} is a leader of a project. "
266+
f"Be careful not to delete yourself from a project!"
267+
},
268+
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
269+
)
270+
if not existing_collab_id:
271+
return Response(
272+
{
273+
"error": f"User with id: {requested_collab_id} are not part of this project."
274+
},
275+
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
276+
)
277+
278+
existing_collab_id.delete()
279+
return Response(status=204)
280+
281+
def _project_data(
282+
self, project_pk: int
283+
) -> tuple[Annotated[int, "ID проекта"], Annotated[int, "ID лидера проекта"]]:
284+
project = get_object_or_404(
285+
Project.objects.select_related("leader"), id=project_pk
286+
)
287+
return project.id, project.leader.id
288+
289+
@staticmethod
290+
def _collabs_queryset(project_id: int, requested_id: int, leader_id: int) -> QuerySet:
291+
return Collaborator.objects.exclude(
292+
user__id=leader_id
293+
).get( # чтоб случайно лидер сам себя не удалил
294+
user__id=requested_id, project__id=project_id
295+
)
259296

260297

261298
class ProjectSteps(APIView):
@@ -281,7 +318,7 @@ class AchievementDetail(generics.RetrieveUpdateDestroyAPIView):
281318

282319

283320
class ProjectVacancyResponses(generics.GenericAPIView):
284-
serializer_class = VacancyResponseListSerializer
321+
serializer_class = VacancyResponseFullFileInfoListSerializer
285322
permission_classes = [IsAuthenticated]
286323

287324
def get_queryset(self):
@@ -461,3 +498,144 @@ def post(self, request, project_pk):
461498
return Response(
462499
{"detail": "Subscriber was successfully removed"}, status=status.HTTP_200_OK
463500
)
501+
502+
503+
504+
class SwitchLeaderRole(generics.GenericAPIView):
505+
permission_classes = [IsProjectLeader]
506+
queryset = Project.objects.all().select_related("leader")
507+
508+
def _get_new_leader(self, user_id: int, project: Project) -> Collaborator:
509+
try:
510+
return Collaborator.objects.select_related("user").get(
511+
user_id=user_id, project=project
512+
)
513+
except ObjectDoesNotExist:
514+
raise CollaboratorDoesNotExist(
515+
f"""Collaborator with user_id: {user_id} does not exist. Either user_id is not correct, or project_id
516+
is not correct, or try adding this user to a project (as collaborator) before making them a leader. """
517+
)
518+
519+
def patch(self, request, pk: int):
520+
project = self.get_object()
521+
522+
new_leader_id = int(request.data["new_leader_id"])
523+
new_leader = self._get_new_leader(new_leader_id, project)
524+
525+
if project.leader.id == new_leader_id:
526+
return Response(
527+
{"error": "User is already a leader of a project"},
528+
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
529+
)
530+
531+
project.leader = new_leader.user
532+
project.save()
533+
return Response(status=204)
534+
535+
536+
class LeaveProject(generics.GenericAPIView):
537+
permission_classes = [IsAuthenticated]
538+
539+
def delete(self, request, project_pk: int) -> Response:
540+
current_user_id = self.request.user.id
541+
collaborator = get_object_or_404(
542+
Collaborator.objects.all(),
543+
project_id=project_pk,
544+
user_id=current_user_id,
545+
)
546+
project = Project.objects.select_related("leader").get(id=project_pk)
547+
if project.leader.id == current_user_id:
548+
return Response(
549+
{
550+
"error": "You can't leave if you are a leader of a project. "
551+
"Please, switch leadership!"
552+
},
553+
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
554+
)
555+
collaborator.delete()
556+
return Response(status=204)
557+
558+
559+
class DeleteProjectCollaborators(generics.GenericAPIView):
560+
permission_classes = [IsProjectLeader]
561+
562+
def _project_data(
563+
self, project_pk: int
564+
) -> tuple[Annotated[int, "ID проекта"], Annotated[int, "ID лидера проекта"]]:
565+
project = get_object_or_404(
566+
Project.objects.select_related("leader"), id=project_pk
567+
)
568+
return project.id, project.leader.id
569+
570+
@staticmethod
571+
def _collabs_queryset(project_id: int, requested_id: int, leader_id: int) -> QuerySet:
572+
return Collaborator.objects.exclude(
573+
user__id=leader_id
574+
).get( # чтоб случайно лидер сам себя не удалил
575+
user__id=requested_id, project__id=project_id
576+
)
577+
578+
def delete(self, request, project_pk: int) -> Response:
579+
requested_collab_id: int = int(self.request.query_params.get("id"))
580+
581+
project_id, leader_id = self._project_data(project_pk)
582+
existing_collab_id = self._collabs_queryset(
583+
project_id, requested_collab_id, leader_id
584+
)
585+
586+
if leader_id == requested_collab_id:
587+
return Response(
588+
{
589+
"error": f"User with id: {leader_id} is a leader of a project. "
590+
f"Be careful not to delete yourself from a project!"
591+
},
592+
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
593+
)
594+
if not existing_collab_id:
595+
return Response(
596+
{
597+
"error": f"User with id: {requested_collab_id} are not part of this project."
598+
},
599+
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
600+
)
601+
602+
existing_collab_id.delete()
603+
return Response(status=204)
604+
605+
606+
class SwitchLeaderRole(generics.GenericAPIView):
607+
permission_classes = [IsProjectLeader]
608+
queryset = Project.objects.all().select_related("leader")
609+
610+
@staticmethod
611+
def _get_new_leader(user_id: int, project: Project) -> Collaborator:
612+
try:
613+
return Collaborator.objects.select_related("user").get(
614+
user_id=user_id, project=project
615+
)
616+
except ObjectDoesNotExist:
617+
raise CollaboratorDoesNotExist(
618+
f"""Collaborator with user_id: {user_id} does not exist. Either user_id is not correct, or project_id
619+
is not correct, or try adding this user to a project (as collaborator) before making them a leader. """
620+
)
621+
622+
@staticmethod
623+
def _get_project(project_pk: int) -> Project:
624+
return get_object_or_404(Project.objects.all(), id=project_pk)
625+
626+
def patch(self, request, project_pk: int, user_to_leader_pk: int) -> Response:
627+
project = self._get_project(project_pk)
628+
629+
new_leader_id = user_to_leader_pk
630+
631+
if project.leader.id == new_leader_id:
632+
return Response(
633+
{"error": "User is already a leader of a project"},
634+
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
635+
)
636+
637+
new_leader = self._get_new_leader(new_leader_id, project)
638+
639+
project.leader = new_leader.user
640+
project.save()
641+
return Response(status=204)

users/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ class CustomUser(AbstractUser):
5151
speciality: CharField instance the user's specialty.
5252
datetime_updated: A DateTimeField indicating date of update.
5353
datetime_created: A DateTimeField indicating date of creation.
54+
dataset_migration_applied: A BooleanField indicating based on
55+
the `v2_speciality` and `skills`.
5456
"""
5557

5658
ADMIN = ADMIN

users/signals.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.core.mail import EmailMultiAlternatives
2+
from django.db import transaction
23
from django.db.models.signals import post_save
34
from django.dispatch import receiver
45
from django.template.loader import render_to_string
@@ -25,6 +26,21 @@ def create_or_update_user_types(sender, instance, created, **kwargs):
2526
instance.save()
2627

2728

29+
@receiver(post_save, sender=CustomUser)
30+
def update_dataset_migration_applied(sender, instance, **kwargs):
31+
"""Update the `dataset_migration_applied` attribute based on the presence of `v2_speciality` and `skills`."""
32+
33+
def update_migration():
34+
dataset_migration_applied = bool(instance.v2_speciality and instance.skills.exists())
35+
if instance.dataset_migration_applied != dataset_migration_applied:
36+
CustomUser.objects.filter(pk=instance.pk).update(
37+
dataset_migration_applied=dataset_migration_applied
38+
)
39+
40+
# Delayed execution until transaction completes.
41+
transaction.on_commit(update_migration)
42+
43+
2844
@receiver(reset_password_token_created)
2945
def password_reset_token_created(sender, instance, reset_password_token, *args, **kwargs):
3046
reset_password_url = (

vacancy/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class VacancyAdmin(admin.ModelAdmin):
2424
inlines = [
2525
VacancySkillToObjectInline,
2626
]
27+
readonly_fields = ('datetime_closed',)
2728
list_display_links = ["role"]
2829

2930
change_list_template = "vacancies/vacancies_change_list.html"
Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
11
# Generated by Django 4.2.3 on 2024-03-02 22:32
2-
from django.contrib.contenttypes.models import ContentType
2+
3+
"""
4+
The migration is irrelevant and breaks other migrations,
5+
providing access to the database before formation.
6+
The edits described in this migration have already been done manually.
7+
"""
8+
9+
# from django.contrib.contenttypes.models import ContentType
310
from django.db import migrations
4-
from core.models import Skill, SkillToObject
5-
from vacancy.models import Vacancy
11+
# from core.models import Skill, SkillToObject
12+
# from vacancy.models import Vacancy
613

714

8-
def migrate_required_skills(apps, schema_editor):
9-
for vacancy in Vacancy.objects.all():
10-
if vacancy.required_skills_old:
11-
for skill_name in vacancy.required_skills_old.lower().split(','):
12-
skill_name = skill_name.strip()
13-
skill = Skill.objects.filter(name__iexact=skill_name).first()
14-
if skill:
15-
SkillToObject.objects.get_or_create(
16-
skill=skill, content_type=ContentType.objects.get_for_model(Vacancy), object_id=vacancy.id
17-
)
15+
# def migrate_required_skills(apps, schema_editor):
16+
# for vacancy in Vacancy.objects.all():
17+
# if vacancy.required_skills_old:
18+
# for skill_name in vacancy.required_skills_old.lower().split(','):
19+
# skill_name = skill_name.strip()
20+
# skill = Skill.objects.filter(name__iexact=skill_name).first()
21+
# if skill:
22+
# SkillToObject.objects.get_or_create(
23+
# skill=skill, content_type=ContentType.objects.get_for_model(Vacancy), object_id=vacancy.id
24+
# )
1825

1926

20-
def reverse(apps, schema_editor):
21-
SkillToObject.objects.filter(content_type=ContentType.objects.get_for_model(Vacancy)).delete()
27+
# def reverse(apps, schema_editor):
28+
# SkillToObject.objects.filter(content_type=ContentType.objects.get_for_model(Vacancy)).delete()
2229

2330

2431
class Migration(migrations.Migration):
@@ -29,5 +36,5 @@ class Migration(migrations.Migration):
2936
]
3037

3138
operations = [
32-
migrations.RunPython(migrate_required_skills, reverse_code=reverse),
39+
# migrations.RunPython(migrate_required_skills, reverse_code=reverse),
3340
]

0 commit comments

Comments
 (0)