From 7fa251321d44d8bd05c3af05c9e7ef0a6fc4c78d Mon Sep 17 00:00:00 2001 From: Marsel Narbekov Date: Tue, 1 Oct 2024 12:29:28 +0300 Subject: [PATCH] =?UTF-8?q?PRO-463:=20Permission=20for=20projects=20in=20p?= =?UTF-8?q?rograms=20|=20Tag=20deletion=20bug=201.=D0=9E=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0,=20=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D1=89=D0=B5=D0=B3=D0=BE=20=D0=B2?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?:=20=D0=9D=D0=B0=20(=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5/=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5)=20=D0=BD=D0=B0=D0=B2=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=20permission=20=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D1=89=D0=B0=D1=8E=D1=89=D0=B8=D0=B9=20=D0=B2=D0=B7=D0=B0?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=B4=D0=BE=20=D0=B8=D1=81=D1=82=D0=B5=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?=D0=B0.=20=D0=A2=D0=B5=D0=BA=D1=83=D1=89=D0=B8=D0=B9=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B9=D0=BC=D0=B8=D0=BD=D0=B3=20=3D=2030=20=D0=B4=D0=BD?= =?UTF-8?q?=D0=B5=D0=B9.=20=D0=9F=D1=80=D0=B8=20=D0=BF=D0=BE=D0=BF=D1=8B?= =?UTF-8?q?=D1=82=D0=BA=D0=B5=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C/=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC?= =?UTF-8?q?=D0=BC=D1=83=20=D0=B2=D0=BE=D0=B7=D0=BD=D0=B8=D0=BA=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5,=20response=20=D1=81=D0=BB=D0=B5=D0=B4=D1=83=D1=8E=D1=89?= =?UTF-8?q?=D0=B8=D0=B9:=20=D0=9A=D0=BE=D0=B4:=20403=20body:=20```=20{=20?= =?UTF-8?q?=20=20=20=20"program=5Fname":=20"=D0=9D=D0=B0=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D1=8B",=20=20=20=20=20"when=5Fca?= =?UTF-8?q?n=5Fedit":=20"2024-10-30=2020:59:59+03:00",=20=20=20=20=20"days?= =?UTF-8?q?=5Funtil=5Fresolution":=20"29"=20}=20```=20=D0=93=D0=B4=D0=B5:?= =?UTF-8?q?=20program=5Fname:=20str=20->=20=D0=9D=D0=B0=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D1=8B.=20when=5Fcan=5Fedit:=20da?= =?UTF-8?q?tetime(str)=20->=20=D0=9C=D0=BE=D1=81=D0=BA=D0=BE=D0=B2=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=8F=20=D0=B4=D0=B0=D1=82=D0=B0-=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D1=8F=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20=D0=B1?= =?UTF-8?q?=D1=83=D0=B4=D0=B5=D1=82=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=20(=D1=81=20=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D1=81=D1=82=D1=8C?= =?UTF-8?q?=D1=8E=20=D0=B4=D0=BE=20=D1=81=D0=B5=D0=BA=D1=83=D0=BD=D0=B4).?= =?UTF-8?q?=20days=5Funtil=5Fresolution:=20str(int)=20->=20=D0=94=D0=BD?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=B4=D0=BE=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE?= =?UTF-8?q?=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.Баг - невозможно отвязать проект от программы: Исправлен, пользователь может отвязать проект от программы, разные кейсы: -Программа, к которой привязан проект, завершена, при этом тайминг не прошел (П.1) не получится отвязать. -Программа, к которой привязан проект, завершена, при этом тайминг не прошел (П.1) отвязать получится, не получится привязать обратно (завершена). -Проект не привязан к программе, выбрать получится только те программы, где юзер участник и программа не завершена. -Отвязать от любой не завершенной программы можно без проблем. -Обвязать от любой завершенной программы можно только через месяц. В случае возникновения исключения в рамках выбора завершенной программы, в response будет выбрасываться: Код: 400 ``` { "error": "Cannot select a completed program." } ``` Бек подстроент под запрос с фронта - если пользователь выбирает "Без тега" получаем `partner_program_id: int = 0`, что означает отвязку. С одной стороны, ожидаемое значение - null, но тогда возникнет проблема с PATCH запросом(пришлось бы лезть в логику view), в принципе 0 тоже уместно, т.к. id PK равный нулю не может существовать (ограничение БД в целом). --- partner_programs/admin.py | 6 ++++ projects/helpers.py | 58 +++++++++++++++++++++++++++++++++------ projects/permissions.py | 53 +++++++++++++++++++++++++++++++++++ projects/views.py | 3 +- 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 7feb3865..34e7289a 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -235,3 +235,9 @@ class PartnerProgramUserProfileAdmin(admin.ModelAdmin): ) search_fields = ("user__first_name", "user__last_name", "partner_program_data") date_hierarchy = "datetime_created" + + def get_form(self, request, obj=None, **kwargs): + """`partner_program` field is optional in admin panel (bc is nullable).""" + form = super().get_form(request, obj, **kwargs) + form.base_fields["project"].required = False + return form diff --git a/projects/helpers.py b/projects/helpers.py index 3b37a20c..36e7a50b 100644 --- a/projects/helpers.py +++ b/projects/helpers.py @@ -1,10 +1,15 @@ from random import sample +from django.db import transaction +from django.utils import timezone from django.contrib.auth import get_user_model +from rest_framework.exceptions import ValidationError + from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from projects.constants import RECOMMENDATIONS_COUNT from projects.models import Project, ProjectLink, Achievement +from users.models import CustomUser User = get_user_model() @@ -87,14 +92,49 @@ def update_links(links, pk): ) +@transaction.atomic def update_partner_program( - partner_program_id: int, user: "User", instance: "Project" + program_id: int, + user: CustomUser, + instance: Project, ) -> None: - if partner_program_id: - partner_program = PartnerProgram.objects.get(pk=partner_program_id) - partner_program_profile = PartnerProgramUserProfile.objects.get( - user=user, - partner_program=partner_program, - ) - partner_program_profile.project = instance - partner_program_profile.save() + """ + According to the current logic, 1 user project can be linked to only 1 program. + The user cannot select a ready program, but can edit a project with a ready program + (if the time period allows access). + If he changes the program (completed), he will not be able to return it. + """ + if program_id is not None: + # If the user removes the tag, frontend sends `int -> 0` (id == 0 cannot exist). + if program_id == 0: + clear_project_existing_from_profile(user, instance) + else: + partner_program = PartnerProgram.objects.get(pk=program_id) + existing_program_id: int | None = clear_project_existing_from_profile(user, instance) + + if ( + partner_program.datetime_finished < timezone.now() + and (existing_program_id != program_id) + ): + raise ValidationError({"error": "Cannot select a completed program."}) + + partner_program_profile = PartnerProgramUserProfile.objects.get( + user=user, + partner_program=partner_program, + ) + partner_program_profile.project = instance + partner_program_profile.save() + + +def clear_project_existing_from_profile(user, instance) -> None | int: + """Remove project from `PartnerProgramUserProfile` instance.""" + existing_program_profile = ( + PartnerProgramUserProfile.objects + .select_related("partner_program") + .filter(user=user, project=instance) + .first() + ) + if existing_program_profile: + existing_program_profile.project = None + existing_program_profile.save() + return existing_program_profile.partner_program.id diff --git a/projects/permissions.py b/projects/permissions.py index 163d98e0..4a18a68a 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -1,6 +1,12 @@ +from datetime import timedelta, datetime + +from django.utils import timezone + from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.exceptions import PermissionDenied from projects.models import Project +from partner_programs.models import PartnerProgramUserProfile class IsProjectLeaderOrReadOnlyForNonDrafts(BasePermission): @@ -67,6 +73,53 @@ def has_object_permission(self, request, view, obj): return False +class TimingAfterEndsProgramPermission(BasePermission): + """ + Forbidden editing/deleting self projects included in programs + for `_SECONDS_AFTER_CANT_EDIT` seconds -> days from the end of the program. + If the project is not in program or the request in `SAFE_METHODS` -> allowed. + """ + _SECONDS_AFTER_CANT_EDIT: int = 60 * 60 * 24 * 30 # Now 30 days. + + def has_object_permission(self, request, view, obj) -> bool: + if request.method in SAFE_METHODS: + return True + + program_profile = ( + PartnerProgramUserProfile.objects + .filter(user=request.user, project=obj) + .select_related("partner_program") + .first() + ) + moscow_time: datetime = timezone.localtime(timezone.now()) + + if program_profile: + date_from_end_program: timedelta = (moscow_time - program_profile.partner_program.datetime_finished) + days_from_end_program: int = date_from_end_program.days + seconds_from_end_program: int = date_from_end_program.total_seconds() + if 0 <= seconds_from_end_program <= self._SECONDS_AFTER_CANT_EDIT: + raise PermissionDenied(detail=self._prepare_exception_detail(days_from_end_program, program_profile)) + return True + + def _prepare_exception_detail(self, days_from_end_program: int, program_profile: PartnerProgramUserProfile): + """ + Prepare response body when `PermissionDenied` exception raised: + program_name: str -> Program title + when_can_edit: datetime -> Moskow datetime when user can edit self program + days_until_resolution: int -> Days when user can edit self program + """ + datetime_finished: datetime = program_profile.partner_program.datetime_finished + when_can_edit: datetime = timezone.localtime( + datetime_finished + timedelta(seconds=self._SECONDS_AFTER_CANT_EDIT) + ) + days_until_resolution: int = int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24) - days_from_end_program - 1 + return { + "program_name": program_profile.partner_program.name, + "when_can_edit": when_can_edit, + "days_until_resolution": days_until_resolution, + } + + class IsNewsAuthorIsProjectLeaderOrReadOnly(BasePermission): """ Allows access to update project news only to leader. diff --git a/projects/views.py b/projects/views.py index d6be9244..c7e9a8c8 100644 --- a/projects/views.py +++ b/projects/views.py @@ -33,6 +33,7 @@ HasInvolvementInProjectOrReadOnly, IsProjectLeader, IsNewsAuthorIsProjectLeaderOrReadOnly, + TimingAfterEndsProgramPermission, ) from projects.serializers import ( ProjectDetailSerializer, @@ -129,7 +130,7 @@ def post(self, request, *args, **kwargs): class ProjectDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Project.objects.get_projects_for_detail_view() - permission_classes = [HasInvolvementInProjectOrReadOnly] + permission_classes = [HasInvolvementInProjectOrReadOnly, TimingAfterEndsProgramPermission] serializer_class = ProjectDetailSerializer def retrieve(self, request, *args, **kwargs):