Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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 = "Цели"
69 changes: 59 additions & 10 deletions projects/permissions.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -79,29 +78,37 @@ 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:
if request.method in SAFE_METHODS:
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
Expand All @@ -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,
Expand Down Expand Up @@ -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
)
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
Loading