Skip to content

Commit 52abaa1

Browse files
authored
Merge pull request #449 from PROCOLLAB-github/dev
Dev
2 parents 0f959ea + 5010755 commit 52abaa1

4 files changed

Lines changed: 110 additions & 10 deletions

File tree

partner_programs/admin.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,9 @@ class PartnerProgramUserProfileAdmin(admin.ModelAdmin):
235235
)
236236
search_fields = ("user__first_name", "user__last_name", "partner_program_data")
237237
date_hierarchy = "datetime_created"
238+
239+
def get_form(self, request, obj=None, **kwargs):
240+
"""`partner_program` field is optional in admin panel (bc is nullable)."""
241+
form = super().get_form(request, obj, **kwargs)
242+
form.base_fields["project"].required = False
243+
return form

projects/helpers.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
from random import sample
22

3+
from django.db import transaction
4+
from django.utils import timezone
35
from django.contrib.auth import get_user_model
46

7+
from rest_framework.exceptions import ValidationError
8+
59
from partner_programs.models import PartnerProgram, PartnerProgramUserProfile
610
from projects.constants import RECOMMENDATIONS_COUNT
711
from projects.models import Project, ProjectLink, Achievement
12+
from users.models import CustomUser
813

914
User = get_user_model()
1015

@@ -87,14 +92,49 @@ def update_links(links, pk):
8792
)
8893

8994

95+
@transaction.atomic
9096
def update_partner_program(
91-
partner_program_id: int, user: "User", instance: "Project"
97+
program_id: int,
98+
user: CustomUser,
99+
instance: Project,
92100
) -> None:
93-
if partner_program_id:
94-
partner_program = PartnerProgram.objects.get(pk=partner_program_id)
95-
partner_program_profile = PartnerProgramUserProfile.objects.get(
96-
user=user,
97-
partner_program=partner_program,
98-
)
99-
partner_program_profile.project = instance
100-
partner_program_profile.save()
101+
"""
102+
According to the current logic, 1 user project can be linked to only 1 program.
103+
The user cannot select a ready program, but can edit a project with a ready program
104+
(if the time period allows access).
105+
If he changes the program (completed), he will not be able to return it.
106+
"""
107+
if program_id is not None:
108+
# If the user removes the tag, frontend sends `int -> 0` (id == 0 cannot exist).
109+
if program_id == 0:
110+
clear_project_existing_from_profile(user, instance)
111+
else:
112+
partner_program = PartnerProgram.objects.get(pk=program_id)
113+
existing_program_id: int | None = clear_project_existing_from_profile(user, instance)
114+
115+
if (
116+
partner_program.datetime_finished < timezone.now()
117+
and (existing_program_id != program_id)
118+
):
119+
raise ValidationError({"error": "Cannot select a completed program."})
120+
121+
partner_program_profile = PartnerProgramUserProfile.objects.get(
122+
user=user,
123+
partner_program=partner_program,
124+
)
125+
partner_program_profile.project = instance
126+
partner_program_profile.save()
127+
128+
129+
def clear_project_existing_from_profile(user, instance) -> None | int:
130+
"""Remove project from `PartnerProgramUserProfile` instance."""
131+
existing_program_profile = (
132+
PartnerProgramUserProfile.objects
133+
.select_related("partner_program")
134+
.filter(user=user, project=instance)
135+
.first()
136+
)
137+
if existing_program_profile:
138+
existing_program_profile.project = None
139+
existing_program_profile.save()
140+
return existing_program_profile.partner_program.id

projects/permissions.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
from datetime import timedelta, datetime
2+
3+
from django.utils import timezone
4+
15
from rest_framework.permissions import BasePermission, SAFE_METHODS
6+
from rest_framework.exceptions import PermissionDenied
27

38
from projects.models import Project
9+
from partner_programs.models import PartnerProgramUserProfile
410

511

612
class IsProjectLeaderOrReadOnlyForNonDrafts(BasePermission):
@@ -67,6 +73,53 @@ def has_object_permission(self, request, view, obj):
6773
return False
6874

6975

76+
class TimingAfterEndsProgramPermission(BasePermission):
77+
"""
78+
Forbidden editing/deleting self projects included in programs
79+
for `_SECONDS_AFTER_CANT_EDIT` seconds -> days from the end of the program.
80+
If the project is not in program or the request in `SAFE_METHODS` -> allowed.
81+
"""
82+
_SECONDS_AFTER_CANT_EDIT: int = 60 * 60 * 24 * 30 # Now 30 days.
83+
84+
def has_object_permission(self, request, view, obj) -> bool:
85+
if request.method in SAFE_METHODS:
86+
return True
87+
88+
program_profile = (
89+
PartnerProgramUserProfile.objects
90+
.filter(user=request.user, project=obj)
91+
.select_related("partner_program")
92+
.first()
93+
)
94+
moscow_time: datetime = timezone.localtime(timezone.now())
95+
96+
if program_profile:
97+
date_from_end_program: timedelta = (moscow_time - program_profile.partner_program.datetime_finished)
98+
days_from_end_program: int = date_from_end_program.days
99+
seconds_from_end_program: int = date_from_end_program.total_seconds()
100+
if 0 <= seconds_from_end_program <= self._SECONDS_AFTER_CANT_EDIT:
101+
raise PermissionDenied(detail=self._prepare_exception_detail(days_from_end_program, program_profile))
102+
return True
103+
104+
def _prepare_exception_detail(self, days_from_end_program: int, program_profile: PartnerProgramUserProfile):
105+
"""
106+
Prepare response body when `PermissionDenied` exception raised:
107+
program_name: str -> Program title
108+
when_can_edit: datetime -> Moskow datetime when user can edit self program
109+
days_until_resolution: int -> Days when user can edit self program
110+
"""
111+
datetime_finished: datetime = program_profile.partner_program.datetime_finished
112+
when_can_edit: datetime = timezone.localtime(
113+
datetime_finished + timedelta(seconds=self._SECONDS_AFTER_CANT_EDIT)
114+
)
115+
days_until_resolution: int = int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24) - days_from_end_program - 1
116+
return {
117+
"program_name": program_profile.partner_program.name,
118+
"when_can_edit": when_can_edit,
119+
"days_until_resolution": days_until_resolution,
120+
}
121+
122+
70123
class IsNewsAuthorIsProjectLeaderOrReadOnly(BasePermission):
71124
"""
72125
Allows access to update project news only to leader.

projects/views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
HasInvolvementInProjectOrReadOnly,
3434
IsProjectLeader,
3535
IsNewsAuthorIsProjectLeaderOrReadOnly,
36+
TimingAfterEndsProgramPermission,
3637
)
3738
from projects.serializers import (
3839
ProjectDetailSerializer,
@@ -129,7 +130,7 @@ def post(self, request, *args, **kwargs):
129130

130131
class ProjectDetail(generics.RetrieveUpdateDestroyAPIView):
131132
queryset = Project.objects.get_projects_for_detail_view()
132-
permission_classes = [HasInvolvementInProjectOrReadOnly]
133+
permission_classes = [HasInvolvementInProjectOrReadOnly, TimingAfterEndsProgramPermission]
133134
serializer_class = ProjectDetailSerializer
134135

135136
def retrieve(self, request, *args, **kwargs):

0 commit comments

Comments
 (0)