From 5600e76cf3f2dd68717b09a7e046051e26fd2d1e Mon Sep 17 00:00:00 2001 From: Marsel Narbekov Date: Wed, 11 Sep 2024 15:13:01 +0300 Subject: [PATCH] =?UTF-8?q?PRO-341:=20Approving=20user=20skills=20=D0=94?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B2=D1=8B=D0=BA=D0=BE=D0=B2=20=D1=83=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 На данном эндпоинте: ``` users//approve_skill// ``` Есть 2 метода POST и DELETE необходимо указать id пользователя и id его навыка: `POST` запрос "подтверждает" навык `DELETE` запрос "удаляет подтверждение" навыка Параметры: `user_pk` - id пользователя которому надо подтвердить навык. `skill_pk` - id навыка пользователя которому его необходимо подтвердить. На остальные роуты связанные с пользователями добавлена информация непосредственно в навык. Пример: ``` { "some_user_field": null, "skills": [ { "id": 17, "name": "Создание контент-плана", "category": { "id": 1, "name": "Маркетинг" }, "approves": [] }, { "id": 15, "name": "MS Office", "category": { "id": 1, "name": "Маркетинг" }, "approves": [ { "confirmed_by": { "id": 1, "first_name": "Марсель", "last_name": "Марсель", "speciality": null, "v2_speciality": { "id": 1, "name": "Back-end" }, "avatar": null } } ] } ], "some_user_field2": null } ``` --- core/admin.py | 6 ++ core/models.py | 8 ++ users/admin.py | 19 ++++ .../0050_userskillconfirmation_and_more.py | 58 +++++++++++ users/models.py | 46 +++++++++ users/schema.py | 16 ++++ users/serializers.py | 96 ++++++++++++++++++- users/urls.py | 2 + users/views.py | 82 ++++++++++++++-- 9 files changed, 324 insertions(+), 9 deletions(-) create mode 100644 users/migrations/0050_userskillconfirmation_and_more.py create mode 100644 users/schema.py diff --git a/core/admin.py b/core/admin.py index c4d37f1e..32a75113 100644 --- a/core/admin.py +++ b/core/admin.py @@ -86,3 +86,9 @@ class SpecializationCategoryAdmin(admin.ModelAdmin): "id", "name", ) + + +@admin.register(SkillToObject) +class SkillToObjectAdmin(admin.ModelAdmin): + list_display = ("id", "__str__") + search_fields = ("skill__name",) diff --git a/core/models.py b/core/models.py index e2580fe6..59881d59 100644 --- a/core/models.py +++ b/core/models.py @@ -165,6 +165,14 @@ class Meta(TypedModelMeta): verbose_name = "Ссылка на навык" verbose_name_plural = "Ссылки на навыки" + def __str__(self): + try: + return f"{self.skill.name} - {self.content_object}" + # Possible contingencies with attributes. + except Exception: + pass + return super().__str__() + class SpecializationCategory(models.Model): name = models.TextField() diff --git a/users/admin.py b/users/admin.py index e84693b5..037b1697 100644 --- a/users/admin.py +++ b/users/admin.py @@ -19,6 +19,7 @@ Investor, UserLink, UserEducation, + UserSkillConfirmation, ) from core.admin import SkillToObjectInline @@ -331,3 +332,21 @@ class UserEducationAdmin(admin.ModelAdmin): list_display = ("id", "user", "organization_name", "entry_year") list_display_links = ("id", "organization_name") search_fields = ("user__first_name", "user__email") + + +@admin.register(UserSkillConfirmation) +class UserSkillConfirmationAdmin(admin.ModelAdmin): + list_display = ("id", "get_user_and_skill", "confirmed_by", "confirmed_at") + search_fields = ("skill_to_object__skill__name", "confirmed_by__first_name", "confirmed_by__last_name") + raw_id_fields = ("skill_to_object", "confirmed_by") + readonly_fields = ("confirmed_at",) + + def get_user_and_skill(self, obj): + try: + user = obj.skill_to_object.content_object + skill = obj.skill_to_object.skill + return f"{user} - {skill}" + # Possible contingencies with attributes. + except Exception: + return "" + get_user_and_skill.short_description = 'User and Skill' diff --git a/users/migrations/0050_userskillconfirmation_and_more.py b/users/migrations/0050_userskillconfirmation_and_more.py new file mode 100644 index 00000000..4f67413e --- /dev/null +++ b/users/migrations/0050_userskillconfirmation_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 4.2.11 on 2024-09-10 15:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0015_alter_skill_options_alter_skilltoobject_options"), + ("users", "0049_alter_customuser_key_skills_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="UserSkillConfirmation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("confirmed_at", models.DateTimeField(auto_now_add=True)), + ( + "confirmed_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="skill_confirmations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "skill_to_object", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="confirmations", + to="core.skilltoobject", + ), + ), + ], + options={ + "verbose_name": "Подтверждение навыка", + "verbose_name_plural": "Подтверждения навыков", + }, + ), + migrations.AddConstraint( + model_name="userskillconfirmation", + constraint=models.UniqueConstraint( + fields=("skill_to_object", "confirmed_by"), + name="unique_skill_confirmed_by", + ), + ), + ] diff --git a/users/models.py b/users/models.py index bcb7b999..64ba488a 100644 --- a/users/models.py +++ b/users/models.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import AbstractUser from django.db import models from django.db.models import QuerySet +from django.core.exceptions import ValidationError from django_stubs_ext.db.models import TypedModelMeta from django.contrib.contenttypes.fields import GenericRelation @@ -475,3 +476,48 @@ class Meta: def __str__(self) -> str: return (f"{self.user.first_name}: {self.organization_name} - " f"{self.entry_year} (id {self.id})") + + +class UserSkillConfirmation(models.Model): + """ + Store confirmations for skills. + + Attributes: + skill_to_object: FK SkillToObject. + confirmed_by: FK CustomUser. + confirmed_at: DateTimeField. + """ + skill_to_object = models.ForeignKey( + "core.SkillToObject", + on_delete=models.CASCADE, + related_name="confirmations" + ) + confirmed_by = models.ForeignKey( + CustomUser, + on_delete=models.CASCADE, + related_name="skill_confirmations" + ) + confirmed_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["skill_to_object", "confirmed_by"], + name="unique_skill_confirmed_by" + ) + ] + verbose_name = "Подтверждение навыка" + verbose_name_plural = "Подтверждения навыков" + + def clean(self) -> None: + # Check if the `skill_to_object` is related to a CustomUser. + if not isinstance(self.skill_to_object.content_object, CustomUser): + raise ValidationError("Skills can only be confirmed for users.") + # Check that the user does not confirm their own skill. + if self.confirmed_by == self.skill_to_object.content_object: + raise ValidationError("User cant approve own skills.") + return super().clean() + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) diff --git a/users/schema.py b/users/schema.py new file mode 100644 index 00000000..b151a044 --- /dev/null +++ b/users/schema.py @@ -0,0 +1,16 @@ +from drf_yasg import openapi + + +USER_PK_PARAM = openapi.Parameter( + "user_pk", + openapi.IN_PATH, + description="Id user to confirmed", + type=openapi.TYPE_INTEGER +) + +SKILL_PK_PARAM = openapi.Parameter( + "skill_pk", + openapi.IN_PATH, + description="Id skill user to confirmed", + type=openapi.TYPE_INTEGER +) diff --git a/users/serializers.py b/users/serializers.py index 33264955..a2487c3f 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -11,8 +11,17 @@ from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from projects.models import Project, Collaborator from projects.validators import validate_project -from .models import CustomUser, Expert, Investor, Member, Mentor, UserAchievement, UserEducation -from .validators import specialization_exists_validator +from users.validators import specialization_exists_validator +from users.models import ( + CustomUser, + Expert, + Investor, + Member, + Mentor, + UserAchievement, + UserEducation, + UserSkillConfirmation, +) from rest_framework_simplejwt.serializers import TokenObtainPairSerializer @@ -88,6 +97,87 @@ class Meta: ] +class UserDataConfirmationSerializer(serializers.ModelSerializer): + """Information about the User to add to the skill confirmation information.""" + v2_speciality = SpecializationSerializer() + + class Meta: + model = CustomUser + fields = [ + "id", + "first_name", + "last_name", + "speciality", + "v2_speciality", + "avatar", + ] + + +class UserSkillConfirmationSerializer(serializers.ModelSerializer): + """Represents a work that requires approval of the user's skills.""" + + class Meta: + model = UserSkillConfirmation + fields = [ + "skill_to_object", + "confirmed_by", + ] + extra_kwargs = { + "skill_to_object": {"write_only": True}, + } + + def validate(self, attrs): + """User cant approve own skills.""" + skill_to_object = attrs.get("skill_to_object") + confirmed_by = self.context["request"].user + + if skill_to_object.content_object == confirmed_by: + raise serializers.ValidationError("User cant approve own skills.") + + return attrs + + 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 + return data + + +class UserApproveSkillResponse(serializers.Serializer): + """For swagger response presentation.""" + confirmed_by = UserDataConfirmationSerializer(read_only=True) + + +class UserSkillsWithApprovesSerializer(SkillToObjectSerializer): + """Added field `approves` to response about User skills.""" + + approves = serializers.SerializerMethodField(allow_null=True, read_only=True) + + class Meta: + model = SkillToObject + fields = [ + "id", + "name", + "category", + "approves", + ] + + def get_approves(self, obj): + """Adds information about confirm to the skill.""" + confirmations = ( + UserSkillConfirmation.objects + .filter(skill_to_object=obj) + .select_related('confirmed_by') + ) + return [ + { + "confirmed_by": UserDataConfirmationSerializer(confirmation.confirmed_by).data, + } + for confirmation in confirmations + ] + + class SpecializationsSerializer(serializers.ModelSerializer[SpecializationCategory]): specializations = SpecializationSerializer(many=True) @@ -97,7 +187,7 @@ class Meta: class SkillsSerializerMixin(serializers.Serializer): - skills = SkillToObjectSerializer(many=True, read_only=True) + skills = UserSkillsWithApprovesSerializer(many=True, read_only=True) class SkillsWriteSerializerMixin(SkillsSerializerMixin): diff --git a/users/urls.py b/users/urls.py index 130e6a2b..b355e72d 100644 --- a/users/urls.py +++ b/users/urls.py @@ -23,6 +23,7 @@ UserSubscribedProjectsList, UserSpecializationsNestedView, UserSpecializationsInlineView, + UserSkillsApproveDeclineView, SingleUserDataView, RemoteViewSubscriptions, RemoteCreatePayment, @@ -49,6 +50,7 @@ path("users//news//", NewsDetail.as_view()), path("users//news//set_viewed/", NewsDetailSetViewed.as_view()), path("users//news//set_liked/", NewsDetailSetLiked.as_view()), + path("users//approve_skill//", UserSkillsApproveDeclineView.as_view()), path("users/current/", CurrentUser.as_view()), # todo: change password view path("users/current/programs/", CurrentUserPrograms.as_view()), diff --git a/users/views.py b/users/views.py index c0675b4f..60236650 100644 --- a/users/views.py +++ b/users/views.py @@ -1,15 +1,14 @@ import jwt import requests + from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model - +from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.db.models import Q from django.shortcuts import redirect, get_object_or_404 -from django_filters import rest_framework as filters -from rest_framework import status, permissions -from rest_framework import exceptions +from rest_framework import status, permissions, exceptions from rest_framework.generics import ( GenericAPIView, ListAPIView, @@ -23,7 +22,10 @@ from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken, TokenError -from core.models import SpecializationCategory, Specialization +from django_filters import rest_framework as filters +from drf_yasg.utils import swagger_auto_schema + +from core.models import SpecializationCategory, Specialization, SkillToObject from core.pagination import Pagination from core.permissions import IsOwnerOrReadOnly from events.models import Event @@ -46,7 +48,7 @@ VERIFY_EMAIL_REDIRECT_URL, OnboardingStage, ) -from users.models import UserAchievement, LikesOnProject +from users.models import UserAchievement, LikesOnProject, UserSkillConfirmation from users.permissions import IsAchievementOwnerOrReadOnly from users.serializers import ( AchievementDetailSerializer, @@ -57,6 +59,8 @@ ResendVerifyEmailSerializer, UserProjectListSerializer, UserSubscribedProjectsSerializer, + UserSkillConfirmationSerializer, + UserApproveSkillResponse, SpecializationsSerializer, SpecializationSerializer, UserCloneDataSerializer, @@ -66,6 +70,7 @@ from .filters import UserFilter, SpecializationFilter from .pagination import UsersPagination from .services.verification import VerificationTasks +from .schema import USER_PK_PARAM, SKILL_PK_PARAM User = get_user_model() Project = apps.get_model("projects", "Project") @@ -172,6 +177,71 @@ def get(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_200_OK) +class UserSkillsApproveDeclineView(APIView): + queryset = UserSkillConfirmation.objects.all() + permission_classes = [IsAuthenticated] + serializer_class = UserSkillConfirmationSerializer + + @swagger_auto_schema( + request_body=None, + operation_description=( + "Create skill confirmation.\nData in the body not required, " + "it is enough to pass the parameters `user_pk` == `user.id` " + "and `skill_pk` == `skill.id` in the query string." + ), + manual_parameters=[USER_PK_PARAM, SKILL_PK_PARAM], + responses={201: UserApproveSkillResponse} + ) + def post(self, request, *args, **kwargs) -> Response: + """Create confirmation of user skill by current user.""" + skill_to_object: SkillToObject = self._get_skill_to_object() + data: dict[str, int] = { + "skill_to_object": skill_to_object.id, + "confirmed_by": request.user.id + } + serializer = self.serializer_class(data=data, context={"request": request}) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @swagger_auto_schema( + operation_description=( + "Delete skill confirmation. `user_pk` == `user.id` " + "`skill_pk` == `skill.id` in the query string." + ), + manual_parameters=[USER_PK_PARAM, SKILL_PK_PARAM], + ) + def delete(self, request, *args, **kwargs) -> Response: + """Delete confirmation of user skill by current user.""" + instance: UserSkillConfirmation = self.get_object() + if instance.confirmed_by != request.user: + return Response( + {"error": "You can only delete your own confirmations."}, + status=status.HTTP_403_FORBIDDEN, + ) + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def get_object(self) -> UserSkillConfirmation: + skill_confirmation_object = get_object_or_404( + self.queryset, + skill_to_object=self._get_skill_to_object(), + confirmed_by=self.request.user.pk, + ) + return skill_confirmation_object + + def _get_skill_to_object(self) -> SkillToObject: + """Returns the `SkillToObject` instance of the user whose skill needs to be confirmed.""" + content_type = ContentType.objects.get_for_model(User) + skill_to_object = get_object_or_404( + SkillToObject, + object_id=self.kwargs["user_pk"], + content_type=content_type, + skill_id=self.kwargs["skill_pk"], + ) + return skill_to_object + + class CurrentUser(GenericAPIView): queryset = User.objects.get_users_for_detail_view() permission_classes = [IsAuthenticated]