Skip to content
Merged

Dev #449

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions partner_programs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
58 changes: 49 additions & 9 deletions projects/helpers.py
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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
53 changes: 53 additions & 0 deletions projects/permissions.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
HasInvolvementInProjectOrReadOnly,
IsProjectLeader,
IsNewsAuthorIsProjectLeaderOrReadOnly,
TimingAfterEndsProgramPermission,
)
from projects.serializers import (
ProjectDetailSerializer,
Expand Down Expand Up @@ -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):
Expand Down