From 9499c6d682c3cae557fc580f5644f844f3e5fcf7 Mon Sep 17 00:00:00 2001 From: Toksi86 Date: Mon, 8 Sep 2025 13:26:39 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D1=8B=20=D1=86=D0=B5=D0=BB=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/admin.py | 31 +++++++++++ projects/migrations/0029_projectgoal.py | 60 +++++++++++++++++++++ projects/models.py | 43 +++++++++++++++ projects/permissions.py | 69 +++++++++++++++++++++---- projects/serializers.py | 27 +++++++++- projects/urls.py | 17 +++++- projects/views.py | 27 +++++++++- 7 files changed, 260 insertions(+), 14 deletions(-) create mode 100644 projects/migrations/0029_projectgoal.py diff --git a/projects/admin.py b/projects/admin.py index cc30ab34..d3a293b7 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -6,11 +6,20 @@ DefaultProjectAvatar, DefaultProjectCover, Project, + ProjectGoal, ProjectLink, ProjectNews, ) +class ProjectGoalInline(admin.TabularInline): + model = ProjectGoal + extra = 0 + fields = ("title", "completion_date", "responsible", "is_done") + show_change_link = True + autocomplete_fields = ("responsible",) + + @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): list_display = ( @@ -76,6 +85,28 @@ class ProjectAdmin(admin.ModelAdmin): ), ) readonly_fields = ("datetime_created", "datetime_updated") + inlines = [ProjectGoalInline] + + +@admin.register(ProjectGoal) +class ProjectGoalAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "project", + "completion_date", + "responsible", + "is_done", + ) + list_filter = ("is_done", "completion_date", "project") + search_fields = ( + "title", + "project__name", + "responsible__username", + "responsible__email", + ) + list_select_related = ("project", "responsible") + autocomplete_fields = ("project", "responsible") @admin.register(ProjectNews) diff --git a/projects/migrations/0029_projectgoal.py b/projects/migrations/0029_projectgoal.py new file mode 100644 index 00000000..884dd200 --- /dev/null +++ b/projects/migrations/0029_projectgoal.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.11 on 2025-09-08 06:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projects", "0028_remove_project_direction_remove_project_goal_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectGoal", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255, verbose_name="Название цели")), + ( + "completion_date", + models.DateField( + blank=True, null=True, verbose_name="Срок реализации цели" + ), + ), + ("is_done", models.BooleanField(default=False, verbose_name="Выполнено")), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="goals", + to="projects.project", + verbose_name="Проект", + ), + ), + ( + "responsible", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="responsible_goals", + to=settings.AUTH_USER_MODEL, + verbose_name="Ответственный", + ), + ), + ], + options={ + "verbose_name": "Цель", + "verbose_name_plural": "Цели", + }, + ), + ] diff --git a/projects/models.py b/projects/models.py index 9bc99bfc..bd9dffb0 100644 --- a/projects/models.py +++ b/projects/models.py @@ -352,3 +352,46 @@ class Meta: verbose_name = "Новость проекта" verbose_name_plural = "Новости проекта" ordering = ["-datetime_created"] + + +class ProjectGoal(models.Model): + """ + Цель проекта (минимальная версия). + """ + + project = models.ForeignKey( + "Project", + on_delete=models.CASCADE, + related_name="goals", + verbose_name="Проект", + ) + + title = models.CharField( + max_length=255, + verbose_name="Название цели", + ) + + completion_date = models.DateField( + null=True, + blank=True, + verbose_name="Срок реализации цели", + ) + + responsible = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="responsible_goals", + verbose_name="Ответственный", + ) + + is_done = models.BooleanField( + default=False, + verbose_name="Выполнено", + ) + + def __str__(self) -> str: + return f"Проект [{self.project_id}] - {self.title}" + + class Meta: + verbose_name = "Цель" + verbose_name_plural = "Цели" diff --git a/projects/permissions.py b/projects/permissions.py index 4a18a68a..fe17ea9e 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -1,12 +1,11 @@ -from datetime import timedelta, datetime +from datetime import datetime, timedelta from django.utils import timezone - -from rest_framework.permissions import BasePermission, SAFE_METHODS from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import SAFE_METHODS, BasePermission -from projects.models import Project from partner_programs.models import PartnerProgramUserProfile +from projects.models import Project class IsProjectLeaderOrReadOnlyForNonDrafts(BasePermission): @@ -79,6 +78,7 @@ class TimingAfterEndsProgramPermission(BasePermission): 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: @@ -86,22 +86,29 @@ def has_object_permission(self, request, view, obj) -> bool: return True program_profile = ( - PartnerProgramUserProfile.objects - .filter(user=request.user, project=obj) + 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) + 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)) + 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): + 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 @@ -112,7 +119,11 @@ def _prepare_exception_detail(self, days_from_end_program: int, program_profile: 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 + 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, @@ -140,3 +151,41 @@ def has_object_permission(self, request, view, obj): ) or obj.project.leader == request.user: return True return False + + +class IsProjectLeaderOrReadOnly(BasePermission): + """ + Читать могут все (в т.ч. анонимы). + Создавать/изменять/удалять может только лидер проекта. + """ + + message = "Только лидер проекта может создавать, изменять или удалять цели." + + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + + if not request.user or not request.user.is_authenticated: + return False + + project_pk = view.kwargs.get("project_pk") + project_id = project_pk or request.data.get("project") + if not project_id: + return False + + try: + project = Project.objects.only("id", "leader_id").get(pk=project_id) + except Project.DoesNotExist: + return False + + return project.leader_id == request.user.id + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + + return ( + request.user + and request.user.is_authenticated + and obj.project.leader_id == request.user.id + ) diff --git a/projects/serializers.py b/projects/serializers.py index b6e409bf..94cf0fc8 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -19,7 +19,7 @@ PartnerProgramFieldSerializer, PartnerProgramFieldValueSerializer, ) -from projects.models import Achievement, Collaborator, Project, ProjectNews +from projects.models import Achievement, Collaborator, Project, ProjectGoal, ProjectNews from projects.validators import validate_project from vacancy.serializers import ProjectVacancyListSerializer @@ -109,6 +109,31 @@ def get_program_field_values(self, obj): return PartnerProgramFieldValueSerializer(values_qs, many=True).data +class ResponsibleMiniSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("id", "first_name", "last_name", "avatar") + + +class ProjectGoalSerializer(serializers.ModelSerializer): + project = serializers.PrimaryKeyRelatedField(read_only=True) + responsible = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) + responsible_info = ResponsibleMiniSerializer(source="responsible", read_only=True) + + class Meta: + model = ProjectGoal + fields = [ + "id", + "project", + "title", + "completion_date", + "responsible", + "responsible_info", + "is_done", + ] + read_only_fields = ["id", "project", "responsible_info"] + + class ProjectDetailSerializer(serializers.ModelSerializer): achievements = AchievementListSerializer(many=True, read_only=True) cover = UserFileSerializer(required=False) diff --git a/projects/urls.py b/projects/urls.py index 046b13d2..f06af3ec 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -6,6 +6,7 @@ AchievementDetail, AchievementList, DuplicateProjectView, + GoalViewSet, LeaveProject, ProjectCollaborators, ProjectCountView, @@ -21,7 +22,15 @@ ) app_name = "projects" - +project_goal_list = GoalViewSet.as_view({"get": "list", "post": "create"}) +project_goal_detail = GoalViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } +) urlpatterns = [ path("", ProjectList.as_view()), path("/like/", SetLikeOnProject.as_view()), @@ -29,6 +38,12 @@ path("/subscribe/", ProjectSubscribe.as_view()), path("/unsubscribe/", ProjectUnsubscribe.as_view()), path("/subscribers/", ProjectSubscribers.as_view()), + path("/goals/", project_goal_list, name="project-goals"), + path( + "/goals//", + project_goal_detail, + name="project-goal-detail", + ), path("/news//", NewsDetail.as_view()), path("/news//set_viewed/", NewsDetailSetViewed.as_view()), path("/news//set_liked/", NewsDetailSetLiked.as_view()), diff --git a/projects/views.py b/projects/views.py index 7fbdce6b..2978f0b9 100644 --- a/projects/views.py +++ b/projects/views.py @@ -9,7 +9,7 @@ from django_filters import rest_framework as filters from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from rest_framework import generics, permissions, status +from rest_framework import generics, permissions, status, viewsets from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -30,12 +30,13 @@ get_recommended_users, update_partner_program, ) -from projects.models import Achievement, Collaborator, Project, ProjectNews +from projects.models import Achievement, Collaborator, Project, ProjectGoal, ProjectNews from projects.pagination import ProjectNewsPagination, ProjectsPagination from projects.permissions import ( HasInvolvementInProjectOrReadOnly, IsNewsAuthorIsProjectLeaderOrReadOnly, IsProjectLeader, + IsProjectLeaderOrReadOnly, IsProjectLeaderOrReadOnlyForNonDrafts, TimingAfterEndsProgramPermission, ) @@ -45,6 +46,7 @@ ProjectCollaboratorSerializer, ProjectDetailSerializer, ProjectDuplicateRequestSerializer, + ProjectGoalSerializer, ProjectListSerializer, ProjectNewsDetailSerializer, ProjectNewsListSerializer, @@ -710,3 +712,24 @@ def post(self, request): }, status=status.HTTP_201_CREATED, ) + + +class GoalViewSet(viewsets.ModelViewSet): + queryset = ProjectGoal.objects.select_related("project", "responsible") + serializer_class = ProjectGoalSerializer + permission_classes = [IsProjectLeaderOrReadOnly] + + def get_queryset(self): + qs = super().get_queryset() + project_pk = self.kwargs.get("project_pk") + return qs.filter(project_id=project_pk) if project_pk is not None else qs + + def perform_create(self, serializer): + project_pk = self.kwargs.get("project_pk") + if project_pk is None: + serializer.save() + else: + serializer.save(project_id=project_pk) + + def perform_update(self, serializer): + serializer.save(project=self.get_object().project)