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 92b4c8d2..bd1f0df2 100644 --- a/projects/models.py +++ b/projects/models.py @@ -383,3 +383,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 18b28556..5af6b320 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -153,6 +153,44 @@ def has_object_permission(self, request, view, obj): 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 + ) + + class CanBindProjectToProgram(BasePermission): message = "Привязать проект к программе может только её участник (или менеджер)." diff --git a/projects/serializers.py b/projects/serializers.py index abb9bcae..a8795793 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 c843b61f..fd8d54ee 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,13 +30,14 @@ 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 ( CanBindProjectToProgram, HasInvolvementInProjectOrReadOnly, IsNewsAuthorIsProjectLeaderOrReadOnly, IsProjectLeader, + IsProjectLeaderOrReadOnly, IsProjectLeaderOrReadOnlyForNonDrafts, TimingAfterEndsProgramPermission, ) @@ -46,6 +47,7 @@ ProjectCollaboratorSerializer, ProjectDetailSerializer, ProjectDuplicateRequestSerializer, + ProjectGoalSerializer, ProjectListSerializer, ProjectNewsDetailSerializer, ProjectNewsListSerializer, @@ -711,3 +713,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)