diff --git a/src/backend/api/v1/projects/mixins.py b/src/backend/api/v1/projects/mixins.py new file mode 100644 index 0000000..14d5636 --- /dev/null +++ b/src/backend/api/v1/projects/mixins.py @@ -0,0 +1,93 @@ +from datetime import date +from typing import Any, Dict + +from django.db import transaction +from rest_framework import serializers + +from apps.projects.models import Project, ProjectSpecialist + + +class RecruitmentStatusMixin: + def calculate_recruitment_status(self, obj): + """Метод определения статуса набора в проект.""" + + if any( + specialist.is_required + for specialist in obj.project_specialists.all() + ): + return "Набор открыт" + return "Набор закрыт" + + +class ProjectOrDraftValidateMixin: + """Миксин валидации данных проекта или его черновика.""" + + def _validate_date(self, value, field_name) -> date: + """Метод валидации даты.""" + + if value < date.today(): + raise serializers.ValidationError( + f"Дата {field_name} не может быть в прошлом." + ) + return value + + def validate_started(self, value) -> date: + """Метод валидации даты начала проекта.""" + + return self._validate_date(value, "начала проекта") + + def validate_ended(self, value) -> date: + """Метод валидации даты завершения проекта.""" + + return self._validate_date(value, "завершения проекта") + + def validate(self, attrs) -> Dict[str, Any]: + """Метод валидации данных проекта или черновика.""" + + errors: Dict = {} + + queryset = Project.objects.filter( + name=attrs.get("name"), + creator=self.context.get("request").user, # type: ignore + ) + + if queryset.exists(): + errors.setdefault("unique", []).append( + "У вас уже есть проект или его черновик с таким названием." + ) + started = attrs.get("started") + ended = attrs.get("ended") + if (started and ended) is not None and started > ended: + errors.setdefault("invalid_dates", []).append( + "Дата завершения проекта не может быть раньше даты начала." + ) + + if errors: + raise serializers.ValidationError(errors) + return attrs + + +class ProjectOrDraftCreateMixin: + """Миксин создания проекта или его черновика.""" + + def create(self, validated_data) -> Project: + """Метод создания проекта или его черновика.""" + + directions = validated_data.pop("directions", None) + project_specialists = validated_data.pop("project_specialists", None) + with transaction.atomic(): + project_instance, _ = Project.objects.get_or_create( + **validated_data + ) + if directions is not None: + project_instance.directions.set(directions) + if project_specialists is not None: + for project_specialist_data in project_specialists: + skills_data = project_specialist_data.pop("skills") + project_specialist_instance = ( + ProjectSpecialist.objects.create( + project=project_instance, **project_specialist_data + ) + ) + project_specialist_instance.skills.set(skills_data) + return project_instance diff --git a/src/backend/api/v1/projects/permissions.py b/src/backend/api/v1/projects/permissions.py new file mode 100644 index 0000000..c5cddee --- /dev/null +++ b/src/backend/api/v1/projects/permissions.py @@ -0,0 +1,13 @@ +from rest_framework.permissions import SAFE_METHODS, IsAuthenticated + + +class IsCreatorOrOwnerOrReadOnly(IsAuthenticated): + """ + Класс прав доступа чтение - авторизованным пользователям, + редактирование - только создателю или владельцу объекта.""" + + def has_object_permission(self, request, view, obj): + return bool( + request.method in SAFE_METHODS + and (request.user == (obj.owner or obj.creator)) + ) diff --git a/src/backend/api/v1/projects/serializers.py b/src/backend/api/v1/projects/serializers.py index d2191f8..4075e12 100644 --- a/src/backend/api/v1/projects/serializers.py +++ b/src/backend/api/v1/projects/serializers.py @@ -1,12 +1,14 @@ -from datetime import date from typing import Any, Dict, List, Optional -from django.db import transaction from rest_framework import serializers from api.v1.general.serializers import SkillSerializer, SpecialistSerializer +from api.v1.projects.mixins import ( + ProjectOrDraftCreateMixin, + ProjectOrDraftValidateMixin, + RecruitmentStatusMixin, +) from apps.projects.constants import BUSYNESS_CHOICES, STATUS_CHOICES -from apps.projects.mixins import RecruitmentStatusMixin from apps.projects.models import Direction, Project, ProjectSpecialist @@ -18,12 +20,8 @@ class Meta: fields = "__all__" -class ReadProjectSpecialistSerializer(SpecialistSerializer): - """Сериализатор для чтения специалиста необходимого проекту.""" - - specialist = SpecialistSerializer() - skills = SkillSerializer(many=True) - level = serializers.SerializerMethodField() +class BaseProjectSpecialistSerializer(serializers.ModelSerializer): + """Общий сериализатор для специалиста необходимого проекту.""" class Meta: model = ProjectSpecialist @@ -36,42 +34,22 @@ class Meta: "is_required", ) - def get_level(self, obj) -> str: - """Метод получения представления для грейда.""" - - return obj.get_level_display() +class ReadProjectSpecialistSerializer(BaseProjectSpecialistSerializer): + """Сериализатор для чтения специалиста необходимого проекту.""" -class WriteProjectSpecialistSerializer(SpecialistSerializer): - """Сериализатор для записи специалиста необходимого проекту.""" + specialist = SpecialistSerializer() + skills = SkillSerializer(many=True) + level = serializers.SerializerMethodField() - class Meta: - model = ProjectSpecialist - fields = ( - "id", - "specialist", - "skills", - "count", - "level", - "is_required", - ) + def get_level(self, obj) -> str: + """Метод получения представления для грейда.""" + return obj.get_level_display() -class ReadProjectSerializer( - RecruitmentStatusMixin, serializers.ModelSerializer -): - """Сериализатор для чтения проектов.""" - directions = serializers.StringRelatedField(many=True) - status = serializers.ChoiceField( - choices=STATUS_CHOICES, source="get_status_display" - ) - recruitment_status = serializers.SerializerMethodField() - project_specialists = ReadProjectSpecialistSerializer(many=True) - creator = serializers.SlugRelatedField( - slug_field="username", read_only=True - ) - owner = serializers.SlugRelatedField(slug_field="username", read_only=True) +class BaseProjectSerializerMixin(RecruitmentStatusMixin): + """Общий сериализатор для проектов и черновиков.""" class Meta: model = Project @@ -81,6 +59,7 @@ class Meta: "description", "started", "ended", + "busyness", "directions", "creator", "owner", @@ -89,7 +68,21 @@ class Meta: "project_specialists", "status", ) - read_only_fields = fields + + def _get_base_fields(self): + return { + "creator": serializers.SlugRelatedField( + slug_field="username", read_only=True + ), + "owner": serializers.SlugRelatedField( + slug_field="username", read_only=True + ), + } + + def get_fields(self): + fields = super().get_fields() + fields.update(self._get_base_fields()) + return fields def get_recruitment_status(self, obj) -> str: """Метод определения статуса набора в проект.""" @@ -97,14 +90,29 @@ def get_recruitment_status(self, obj) -> str: return self.calculate_recruitment_status(obj) +class ReadProjectSerializer( + BaseProjectSerializerMixin, + serializers.ModelSerializer, +): + """Сериализатор для чтения проектов.""" + + directions = serializers.StringRelatedField(many=True) + status = serializers.ChoiceField( + choices=STATUS_CHOICES, source="get_status_display" + ) + recruitment_status = serializers.SerializerMethodField() + project_specialists = ReadProjectSpecialistSerializer(many=True) + + class WriteProjectSerializer( - RecruitmentStatusMixin, serializers.ModelSerializer + BaseProjectSerializerMixin, + ProjectOrDraftValidateMixin, + ProjectOrDraftCreateMixin, + serializers.ModelSerializer, ): """Сериализатор для записи проектов.""" - creator = serializers.SerializerMethodField(read_only=True) - owner = serializers.SerializerMethodField(read_only=True) - project_specialists = WriteProjectSpecialistSerializer( + project_specialists = BaseProjectSpecialistSerializer( many=True, ) busyness = serializers.ChoiceField( @@ -123,23 +131,9 @@ class WriteProjectSerializer( ) recruitment_status = serializers.SerializerMethodField() - class Meta: - model = Project - fields = ( - "id", - "name", - "description", - "started", - "ended", - "busyness", + class Meta(BaseProjectSerializerMixin.Meta): + fields = BaseProjectSerializerMixin.Meta.fields + ( # type: ignore "project_busyness", - "directions", - "creator", - "owner", - "link", - "recruitment_status", - "project_specialists", - "status", "project_status", ) extra_kwargs = { @@ -151,42 +145,8 @@ class Meta: "link": {"required": True}, } - def get_creator(self, obj) -> str: - """Метод получения username у создателя проекта.""" - - return obj.creator.username - - def get_owner(self, obj) -> str: - """Метод получения username у владельца проекта.""" - - return obj.owner.username - - def get_recruitment_status(self, obj) -> str: - """Метод определения статуса набора в проект.""" - - return self.calculate_recruitment_status(obj) - - def _validate_date(self, value, field_name) -> date: - """Метод валидации даты.""" - - if value < date.today(): - raise serializers.ValidationError( - f"Дата {field_name} не может быть в прошлом." - ) - return value - - def validate_started(self, value) -> date: - """Метод валидации даты начала проекта.""" - - return self._validate_date(value, "начала проекта") - - def validate_ended(self, value) -> date: - """Метод валидации даты завершения проекта.""" - - return self._validate_date(value, "завершения проекта") - def validate_status(self, value) -> int: - """Метод валидации даты завершения проекта.""" + """Метод валидации статуса проекта.""" if value == Project.DRAFT: raise serializers.ValidationError( @@ -194,48 +154,6 @@ def validate_status(self, value) -> int: ) return value - def validate(self, attrs) -> Dict[str, Any]: - """Метод валидации данных о проекте.""" - - errors: Dict = {} - - queryset = Project.objects.filter( - name=attrs.get("name"), - creator=self.context.get("request").user, - owner=self.context.get("request").user, - ) - - if queryset.exists(): - errors.setdefault("unique", []).append( - "У вас уже есть проект с таким названием." - ) - if attrs.get("started") > attrs.get("ended"): - errors.setdefault("invalid_dates", []).append( - "Дата завершения проекта не может быть раньше даты начала." - ) - - if errors: - raise serializers.ValidationError(errors) - return attrs - - def create(self, validated_data) -> Project: - """Метод создания проекта.""" - - directions = validated_data.pop("directions") - project_specialists = validated_data.pop("project_specialists") - with transaction.atomic(): - project_instance, _ = Project.objects.get_or_create( - **validated_data - ) - project_instance.directions.set(directions) - for project_specialist_data in project_specialists: - skills_data = project_specialist_data.pop("skills") - project_specialist_instance = ProjectSpecialist.objects.create( - project=project_instance, **project_specialist_data - ) - project_specialist_instance.skills.set(skills_data) - return project_instance - class ProjectPreviewMainSerializer(serializers.ModelSerializer): """Сериализатор превью проектов.""" @@ -261,3 +179,22 @@ class Meta: "directions", "specialists", ) + + +class DraftSerializer( + BaseProjectSerializerMixin, + ProjectOrDraftValidateMixin, + ProjectOrDraftCreateMixin, + serializers.ModelSerializer, +): + """Сериализатор черновиков проекта.""" + + status = serializers.ChoiceField(choices=STATUS_CHOICES, read_only=True) + link = serializers.URLField(read_only=True) + project_specialists = BaseProjectSpecialistSerializer( + many=True, required=False + ) + recruitment_status = serializers.SerializerMethodField() + + class Meta(BaseProjectSerializerMixin.Meta): + pass diff --git a/src/backend/api/v1/projects/urls.py b/src/backend/api/v1/projects/urls.py index 45093cc..584b257 100644 --- a/src/backend/api/v1/projects/urls.py +++ b/src/backend/api/v1/projects/urls.py @@ -1,18 +1,20 @@ from django.urls import include, path -from rest_framework.routers import DefaultRouter +from rest_framework.routers import SimpleRouter from api.v1.projects.views import ( DirectionViewSet, + DraftViewSet, ProjectPreviewMainViewSet, ProjectViewSet, ) -router = DefaultRouter() +router = SimpleRouter() router.register( "preview_main", ProjectPreviewMainViewSet, basename="projects-preview-main" ) router.register("directions", DirectionViewSet, basename="projects-directions") +router.register("drafts", DraftViewSet, basename="projects-drafts") router.register("", ProjectViewSet, basename="projects") diff --git a/src/backend/api/v1/projects/views.py b/src/backend/api/v1/projects/views.py index 855728b..9518b0b 100644 --- a/src/backend/api/v1/projects/views.py +++ b/src/backend/api/v1/projects/views.py @@ -1,4 +1,4 @@ -from django.db.models import Prefetch +from django.db.models import Prefetch, Q from rest_framework import mixins from rest_framework.permissions import ( SAFE_METHODS, @@ -16,8 +16,10 @@ ProjectPagination, ProjectPreviewMainPagination, ) +from api.v1.projects.permissions import IsCreatorOrOwnerOrReadOnly from api.v1.projects.serializers import ( DirectionSerializer, + DraftSerializer, ProjectPreviewMainSerializer, ReadProjectSerializer, WriteProjectSerializer, @@ -33,23 +35,55 @@ class DirectionViewSet(ReadOnlyModelViewSet): permission_classes = (IsAuthenticated,) -class ProjectViewSet(ModelViewSet): - """Представление проектов.""" +class BaseProjectViewSet: + """Общий viewset для проектов и черновиков.""" - queryset = Project.objects.select_related( - "creator", "owner" - ).prefetch_related( - Prefetch( - "project_specialists", - queryset=ProjectSpecialist.objects.select_related( - "specialist" - ).prefetch_related("skills"), - ), - "directions", + queryset = ( + Project.objects.select_related("creator", "owner") + .prefetch_related( + Prefetch( + "project_specialists", + queryset=ProjectSpecialist.objects.select_related( + "specialist" + ).prefetch_related("skills"), + ), + "directions", + ) + .order_by("-status", "-created") ) + + def get_queryset(self): + """Общий метод получения queryset-а для проектов и черновиков.""" + + queryset = super().get_queryset() + if self.request.user.is_anonymous: + return self._get_queryset_with_params(queryset, anonymous=True) + return self._get_queryset_with_params(queryset, anonymous=False) + + def perform_create(self, serializer): + """ + Общий метод предварительного создания объекта проекта или черновика. + """ + + serializer.save(**self._get_perform_create_data()) + + +class ProjectViewSet(BaseProjectViewSet, ModelViewSet): + """Представление проектов.""" + permission_classes = (IsAuthenticatedOrReadOnly,) pagination_class = ProjectPagination + def _get_queryset_with_params(self, queryset, *args, **kwargs): + """Метод получения queryset-а c параметрами для проекта.""" + + if kwargs.get("anonymous", False): + return queryset.exclude(status=Project.DRAFT) + return queryset.exclude( + Q(status=Project.DRAFT) + & (~(Q(creator=self.request.user) | Q(owner=self.request.user))) + ) + def get_serializer_class(self): """Метод получения сериализатора проектов.""" @@ -57,11 +91,15 @@ def get_serializer_class(self): return ReadProjectSerializer return WriteProjectSerializer - def perform_create(self, serializer): - """Метод предварительного создания объекта.""" + def _get_perform_create_data(self): + """ + Метод подготовки данных для процесса предварительного создания проекта. + """ - user = self.request.user - serializer.save(creator=user, owner=user) + return { + "creator": self.request.user, + "owner": self.request.user, + } class ProjectPreviewMainViewSet(mixins.ListModelMixin, GenericViewSet): @@ -89,3 +127,30 @@ class ProjectPreviewMainViewSet(mixins.ListModelMixin, GenericViewSet): permission_classes = (AllowAny,) serializer_class = ProjectPreviewMainSerializer pagination_class = ProjectPreviewMainPagination + + +class DraftViewSet(BaseProjectViewSet, ModelViewSet): + """Представление для черновиков проекта.""" + + permission_classes = (IsCreatorOrOwnerOrReadOnly,) + serializer_class = DraftSerializer + + def _get_queryset_with_params(self, queryset, *args, **kwargs): + """Метод получения queryset-а с параметрами для черновиков проекта.""" + + if kwargs.get("anonymous", False): + return queryset.filter() + return queryset.filter( + Q(creator=self.request.user) | Q(owner=self.request.user) + ) + + def _get_perform_create_data(self): + """ + Метод подготовки данных для процесса предварительного создания проекта. + """ + + return { + "creator": self.request.user, + "owner": self.request.user, + "status": Project.DRAFT, + } diff --git a/src/backend/apps/projects/admin.py b/src/backend/apps/projects/admin.py index fd6abee..e00b92d 100644 --- a/src/backend/apps/projects/admin.py +++ b/src/backend/apps/projects/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin +from api.v1.projects.mixins import RecruitmentStatusMixin from apps.projects.constants import PROJECTS_PER_PAGE -from apps.projects.mixins import RecruitmentStatusMixin from apps.projects.models import Project, ProjectSpecialist, Skill, Specialist diff --git a/src/backend/apps/projects/constants.py b/src/backend/apps/projects/constants.py index a1d3e15..686ff2e 100644 --- a/src/backend/apps/projects/constants.py +++ b/src/backend/apps/projects/constants.py @@ -11,7 +11,7 @@ f"Длина поля от {MIN_LENGTH_PROJECT_NAME} до {MAX_LENGTH_PROJECT_NAME} " "символов." ) -REGEX_PROJECT_NAME = r"(^[+/:,.0-9A-Za-zА-Яа-яЁё\-–—]+)\Z" +REGEX_PROJECT_NAME = r"(^[+/:,.0-9A-Za-zА-Яа-яЁё\s\-–—]+)\Z" REGEX_PROJECT_NAME_ERROR_TEXT = ( "Название проекта может содержать: кириллические и латинские символы, " "цифры и символы .,-—+/:" diff --git a/src/backend/apps/projects/migrations/0001_initial.py b/src/backend/apps/projects/migrations/0001_initial.py index f07dfea..553370b 100644 --- a/src/backend/apps/projects/migrations/0001_initial.py +++ b/src/backend/apps/projects/migrations/0001_initial.py @@ -114,7 +114,7 @@ class Migration(migrations.Migration): ), django.core.validators.RegexValidator( message="Название проекта может содержать: кириллические и латинские символы, цифры и символы .,-—+/:", - regex="(^[+/:,.0-9A-Za-zА-Яа-яЁё\\-–—]+)\\Z", + regex="(^[+/:,.0-9A-Za-zА-Яа-яЁё\\s\\-–—]+)\\Z", ), ], verbose_name="Название", diff --git a/src/backend/apps/projects/mixins.py b/src/backend/apps/projects/mixins.py deleted file mode 100644 index 384b512..0000000 --- a/src/backend/apps/projects/mixins.py +++ /dev/null @@ -1,10 +0,0 @@ -class RecruitmentStatusMixin: - def calculate_recruitment_status(self, obj): - """Метод определения статуса набора в проект.""" - - if any( - specialist.is_required - for specialist in obj.project_specialists.all() - ): - return "Набор открыт" - return "Набор закрыт" diff --git a/src/backend/config/settings/base.py b/src/backend/config/settings/base.py index aa9d593..3e3377a 100644 --- a/src/backend/config/settings/base.py +++ b/src/backend/config/settings/base.py @@ -57,6 +57,7 @@ ] ROOT_URLCONF = "config.urls" +APPEND_SLASH = False TEMPLATES = [ { @@ -152,6 +153,8 @@ "DEFAULT_FILTER_BACKENDS": [ "django_filters.rest_framework.DjangoFilterBackend", ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 6, "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", }