Skip to content
Merged
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
31 changes: 31 additions & 0 deletions projects/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions projects/migrations/0029_projectgoal.py
Original file line number Diff line number Diff line change
@@ -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": "Цели",
},
),
]
43 changes: 43 additions & 0 deletions projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "Цели"
38 changes: 38 additions & 0 deletions projects/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "Привязать проект к программе может только её участник (или менеджер)."

Expand Down
27 changes: 26 additions & 1 deletion projects/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
17 changes: 16 additions & 1 deletion projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
AchievementDetail,
AchievementList,
DuplicateProjectView,
GoalViewSet,
LeaveProject,
ProjectCollaborators,
ProjectCountView,
Expand All @@ -21,14 +22,28 @@
)

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("<int:pk>/like/", SetLikeOnProject.as_view()),
path("<int:project_pk>/news/", NewsList.as_view()),
path("<int:project_pk>/subscribe/", ProjectSubscribe.as_view()),
path("<int:project_pk>/unsubscribe/", ProjectUnsubscribe.as_view()),
path("<int:project_pk>/subscribers/", ProjectSubscribers.as_view()),
path("<int:project_pk>/goals/", project_goal_list, name="project-goals"),
path(
"<int:project_pk>/goals/<int:pk>/",
project_goal_detail,
name="project-goal-detail",
),
path("<int:project_pk>/news/<int:pk>/", NewsDetail.as_view()),
path("<int:project_pk>/news/<int:pk>/set_viewed/", NewsDetailSetViewed.as_view()),
path("<int:project_pk>/news/<int:pk>/set_liked/", NewsDetailSetLiked.as_view()),
Expand Down
27 changes: 25 additions & 2 deletions projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand All @@ -46,6 +47,7 @@
ProjectCollaboratorSerializer,
ProjectDetailSerializer,
ProjectDuplicateRequestSerializer,
ProjectGoalSerializer,
ProjectListSerializer,
ProjectNewsDetailSerializer,
ProjectNewsListSerializer,
Expand Down Expand Up @@ -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)