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):