Skip to content

Commit 9499c6d

Browse files
committed
Реализованы цели для проекта
1 parent 99ce799 commit 9499c6d

7 files changed

Lines changed: 260 additions & 14 deletions

File tree

projects/admin.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,20 @@
66
DefaultProjectAvatar,
77
DefaultProjectCover,
88
Project,
9+
ProjectGoal,
910
ProjectLink,
1011
ProjectNews,
1112
)
1213

1314

15+
class ProjectGoalInline(admin.TabularInline):
16+
model = ProjectGoal
17+
extra = 0
18+
fields = ("title", "completion_date", "responsible", "is_done")
19+
show_change_link = True
20+
autocomplete_fields = ("responsible",)
21+
22+
1423
@admin.register(Project)
1524
class ProjectAdmin(admin.ModelAdmin):
1625
list_display = (
@@ -76,6 +85,28 @@ class ProjectAdmin(admin.ModelAdmin):
7685
),
7786
)
7887
readonly_fields = ("datetime_created", "datetime_updated")
88+
inlines = [ProjectGoalInline]
89+
90+
91+
@admin.register(ProjectGoal)
92+
class ProjectGoalAdmin(admin.ModelAdmin):
93+
list_display = (
94+
"id",
95+
"title",
96+
"project",
97+
"completion_date",
98+
"responsible",
99+
"is_done",
100+
)
101+
list_filter = ("is_done", "completion_date", "project")
102+
search_fields = (
103+
"title",
104+
"project__name",
105+
"responsible__username",
106+
"responsible__email",
107+
)
108+
list_select_related = ("project", "responsible")
109+
autocomplete_fields = ("project", "responsible")
79110

80111

81112
@admin.register(ProjectNews)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Generated by Django 4.2.11 on 2025-09-08 06:45
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
("projects", "0028_remove_project_direction_remove_project_goal_and_more"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="ProjectGoal",
18+
fields=[
19+
(
20+
"id",
21+
models.BigAutoField(
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
26+
),
27+
),
28+
("title", models.CharField(max_length=255, verbose_name="Название цели")),
29+
(
30+
"completion_date",
31+
models.DateField(
32+
blank=True, null=True, verbose_name="Срок реализации цели"
33+
),
34+
),
35+
("is_done", models.BooleanField(default=False, verbose_name="Выполнено")),
36+
(
37+
"project",
38+
models.ForeignKey(
39+
on_delete=django.db.models.deletion.CASCADE,
40+
related_name="goals",
41+
to="projects.project",
42+
verbose_name="Проект",
43+
),
44+
),
45+
(
46+
"responsible",
47+
models.ForeignKey(
48+
on_delete=django.db.models.deletion.CASCADE,
49+
related_name="responsible_goals",
50+
to=settings.AUTH_USER_MODEL,
51+
verbose_name="Ответственный",
52+
),
53+
),
54+
],
55+
options={
56+
"verbose_name": "Цель",
57+
"verbose_name_plural": "Цели",
58+
},
59+
),
60+
]

projects/models.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,3 +352,46 @@ class Meta:
352352
verbose_name = "Новость проекта"
353353
verbose_name_plural = "Новости проекта"
354354
ordering = ["-datetime_created"]
355+
356+
357+
class ProjectGoal(models.Model):
358+
"""
359+
Цель проекта (минимальная версия).
360+
"""
361+
362+
project = models.ForeignKey(
363+
"Project",
364+
on_delete=models.CASCADE,
365+
related_name="goals",
366+
verbose_name="Проект",
367+
)
368+
369+
title = models.CharField(
370+
max_length=255,
371+
verbose_name="Название цели",
372+
)
373+
374+
completion_date = models.DateField(
375+
null=True,
376+
blank=True,
377+
verbose_name="Срок реализации цели",
378+
)
379+
380+
responsible = models.ForeignKey(
381+
User,
382+
on_delete=models.CASCADE,
383+
related_name="responsible_goals",
384+
verbose_name="Ответственный",
385+
)
386+
387+
is_done = models.BooleanField(
388+
default=False,
389+
verbose_name="Выполнено",
390+
)
391+
392+
def __str__(self) -> str:
393+
return f"Проект [{self.project_id}] - {self.title}"
394+
395+
class Meta:
396+
verbose_name = "Цель"
397+
verbose_name_plural = "Цели"

projects/permissions.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
from datetime import timedelta, datetime
1+
from datetime import datetime, timedelta
22

33
from django.utils import timezone
4-
5-
from rest_framework.permissions import BasePermission, SAFE_METHODS
64
from rest_framework.exceptions import PermissionDenied
5+
from rest_framework.permissions import SAFE_METHODS, BasePermission
76

8-
from projects.models import Project
97
from partner_programs.models import PartnerProgramUserProfile
8+
from projects.models import Project
109

1110

1211
class IsProjectLeaderOrReadOnlyForNonDrafts(BasePermission):
@@ -79,29 +78,37 @@ class TimingAfterEndsProgramPermission(BasePermission):
7978
for `_SECONDS_AFTER_CANT_EDIT` seconds -> days from the end of the program.
8079
If the project is not in program or the request in `SAFE_METHODS` -> allowed.
8180
"""
81+
8282
_SECONDS_AFTER_CANT_EDIT: int = 60 * 60 * 24 * 30 # Now 30 days.
8383

8484
def has_object_permission(self, request, view, obj) -> bool:
8585
if request.method in SAFE_METHODS:
8686
return True
8787

8888
program_profile = (
89-
PartnerProgramUserProfile.objects
90-
.filter(user=request.user, project=obj)
89+
PartnerProgramUserProfile.objects.filter(user=request.user, project=obj)
9190
.select_related("partner_program")
9291
.first()
9392
)
9493
moscow_time: datetime = timezone.localtime(timezone.now())
9594

9695
if program_profile:
97-
date_from_end_program: timedelta = (moscow_time - program_profile.partner_program.datetime_finished)
96+
date_from_end_program: timedelta = (
97+
moscow_time - program_profile.partner_program.datetime_finished
98+
)
9899
days_from_end_program: int = date_from_end_program.days
99100
seconds_from_end_program: int = date_from_end_program.total_seconds()
100101
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+
raise PermissionDenied(
103+
detail=self._prepare_exception_detail(
104+
days_from_end_program, program_profile
105+
)
106+
)
102107
return True
103108

104-
def _prepare_exception_detail(self, days_from_end_program: int, program_profile: PartnerProgramUserProfile):
109+
def _prepare_exception_detail(
110+
self, days_from_end_program: int, program_profile: PartnerProgramUserProfile
111+
):
105112
"""
106113
Prepare response body when `PermissionDenied` exception raised:
107114
program_name: str -> Program title
@@ -112,7 +119,11 @@ def _prepare_exception_detail(self, days_from_end_program: int, program_profile:
112119
when_can_edit: datetime = timezone.localtime(
113120
datetime_finished + timedelta(seconds=self._SECONDS_AFTER_CANT_EDIT)
114121
)
115-
days_until_resolution: int = int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24) - days_from_end_program - 1
122+
days_until_resolution: int = (
123+
int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24)
124+
- days_from_end_program
125+
- 1
126+
)
116127
return {
117128
"program_name": program_profile.partner_program.name,
118129
"when_can_edit": when_can_edit,
@@ -140,3 +151,41 @@ def has_object_permission(self, request, view, obj):
140151
) or obj.project.leader == request.user:
141152
return True
142153
return False
154+
155+
156+
class IsProjectLeaderOrReadOnly(BasePermission):
157+
"""
158+
Читать могут все (в т.ч. анонимы).
159+
Создавать/изменять/удалять может только лидер проекта.
160+
"""
161+
162+
message = "Только лидер проекта может создавать, изменять или удалять цели."
163+
164+
def has_permission(self, request, view):
165+
if request.method in SAFE_METHODS:
166+
return True
167+
168+
if not request.user or not request.user.is_authenticated:
169+
return False
170+
171+
project_pk = view.kwargs.get("project_pk")
172+
project_id = project_pk or request.data.get("project")
173+
if not project_id:
174+
return False
175+
176+
try:
177+
project = Project.objects.only("id", "leader_id").get(pk=project_id)
178+
except Project.DoesNotExist:
179+
return False
180+
181+
return project.leader_id == request.user.id
182+
183+
def has_object_permission(self, request, view, obj):
184+
if request.method in SAFE_METHODS:
185+
return True
186+
187+
return (
188+
request.user
189+
and request.user.is_authenticated
190+
and obj.project.leader_id == request.user.id
191+
)

projects/serializers.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
PartnerProgramFieldSerializer,
2020
PartnerProgramFieldValueSerializer,
2121
)
22-
from projects.models import Achievement, Collaborator, Project, ProjectNews
22+
from projects.models import Achievement, Collaborator, Project, ProjectGoal, ProjectNews
2323
from projects.validators import validate_project
2424
from vacancy.serializers import ProjectVacancyListSerializer
2525

@@ -109,6 +109,31 @@ def get_program_field_values(self, obj):
109109
return PartnerProgramFieldValueSerializer(values_qs, many=True).data
110110

111111

112+
class ResponsibleMiniSerializer(serializers.ModelSerializer):
113+
class Meta:
114+
model = User
115+
fields = ("id", "first_name", "last_name", "avatar")
116+
117+
118+
class ProjectGoalSerializer(serializers.ModelSerializer):
119+
project = serializers.PrimaryKeyRelatedField(read_only=True)
120+
responsible = serializers.PrimaryKeyRelatedField(queryset=User.objects.all())
121+
responsible_info = ResponsibleMiniSerializer(source="responsible", read_only=True)
122+
123+
class Meta:
124+
model = ProjectGoal
125+
fields = [
126+
"id",
127+
"project",
128+
"title",
129+
"completion_date",
130+
"responsible",
131+
"responsible_info",
132+
"is_done",
133+
]
134+
read_only_fields = ["id", "project", "responsible_info"]
135+
136+
112137
class ProjectDetailSerializer(serializers.ModelSerializer):
113138
achievements = AchievementListSerializer(many=True, read_only=True)
114139
cover = UserFileSerializer(required=False)

projects/urls.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
AchievementDetail,
77
AchievementList,
88
DuplicateProjectView,
9+
GoalViewSet,
910
LeaveProject,
1011
ProjectCollaborators,
1112
ProjectCountView,
@@ -21,14 +22,28 @@
2122
)
2223

2324
app_name = "projects"
24-
25+
project_goal_list = GoalViewSet.as_view({"get": "list", "post": "create"})
26+
project_goal_detail = GoalViewSet.as_view(
27+
{
28+
"get": "retrieve",
29+
"put": "update",
30+
"patch": "partial_update",
31+
"delete": "destroy",
32+
}
33+
)
2534
urlpatterns = [
2635
path("", ProjectList.as_view()),
2736
path("<int:pk>/like/", SetLikeOnProject.as_view()),
2837
path("<int:project_pk>/news/", NewsList.as_view()),
2938
path("<int:project_pk>/subscribe/", ProjectSubscribe.as_view()),
3039
path("<int:project_pk>/unsubscribe/", ProjectUnsubscribe.as_view()),
3140
path("<int:project_pk>/subscribers/", ProjectSubscribers.as_view()),
41+
path("<int:project_pk>/goals/", project_goal_list, name="project-goals"),
42+
path(
43+
"<int:project_pk>/goals/<int:pk>/",
44+
project_goal_detail,
45+
name="project-goal-detail",
46+
),
3247
path("<int:project_pk>/news/<int:pk>/", NewsDetail.as_view()),
3348
path("<int:project_pk>/news/<int:pk>/set_viewed/", NewsDetailSetViewed.as_view()),
3449
path("<int:project_pk>/news/<int:pk>/set_liked/", NewsDetailSetLiked.as_view()),

0 commit comments

Comments
 (0)