Skip to content

Commit 9cffc6d

Browse files
authored
Merge pull request #588 from PROCOLLAB-github/dev
Реализован новый способ подачи проектов на программы
2 parents 1b86193 + 8ab5fab commit 9cffc6d

21 files changed

Lines changed: 713 additions & 201 deletions

feed/views.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from feed.pagination import FeedPagination
99
from feed.services import get_liked_news
1010
from news.models import News
11-
from partner_programs.models import PartnerProgramUserProfile
1211
from projects.models import Project
1312
from vacancy.models import Vacancy
1413

@@ -28,29 +27,20 @@ def _get_filter_data(self) -> list[str]:
2827
news_types.append("customuser")
2928
return news_types
3029

31-
def _get_excluded_projects_ids(self) -> list[int]:
32-
"""IDs for exclude projects which in Partner Program."""
33-
excluded_projects = PartnerProgramUserProfile.objects.values_list(
34-
"project_id", flat=True
35-
).exclude(project_id__isnull=True)
36-
return excluded_projects
37-
3830
def get_queryset(self) -> QuerySet[News]:
3931
filters = self._get_filter_data()
40-
excluded_project_ids: list[int] = self._get_excluded_projects_ids()
4132

4233
queryset = (
4334
News.objects.select_related("content_type")
4435
.prefetch_related("content_object", "files")
4536
.filter(content_type__model__in=filters)
46-
.exclude(
47-
Q(content_type__model="project") & Q(object_id__in=excluded_project_ids)
48-
)
4937
.order_by("-datetime_created")
5038
)
5139

5240
existing_object_filters = {
53-
"project": Project.objects.values_list("id", flat=True),
41+
"project": Project.objects.filter(draft=False, is_public=True).values_list(
42+
"id", flat=True
43+
),
5444
"vacancy": Vacancy.objects.values_list("id", flat=True),
5545
}
5646
for model_name, ids_queryset in existing_object_filters.items():

news/views.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@
1818
)
1919
from partner_programs.models import PartnerProgram
2020
from projects.models import Project
21+
from projects.permissions import ProjectVisibilityPermission
2122

2223
User = get_user_model()
2324

2425

2526
class NewsList(NewsQuerysetMixin, generics.ListCreateAPIView):
2627
serializer_class = NewsListSerializer
27-
permission_classes = [IsNewsCreatorOrReadOnly]
28+
permission_classes = [ProjectVisibilityPermission, IsNewsCreatorOrReadOnly]
2829
pagination_class = NewsPagination
2930

3031
def post(self, request: Request, *args, **kwargs) -> Response:
@@ -62,7 +63,7 @@ def get(self, request: Request, *args, **kwargs) -> Response:
6263

6364
class NewsDetail(NewsQuerysetMixin, generics.RetrieveUpdateDestroyAPIView):
6465
serializer_class = NewsDetailSerializer
65-
permission_classes = [IsNewsCreatorOrReadOnly]
66+
permission_classes = [ProjectVisibilityPermission, IsNewsCreatorOrReadOnly]
6667

6768
def get(self, request: Request, *args, **kwargs) -> Response:
6869
try:
@@ -87,7 +88,7 @@ def update(self, request: Request, *args, **kwargs) -> Response:
8788

8889
class NewsDetailSetViewed(NewsQuerysetMixin, generics.CreateAPIView):
8990
serializer_class = SetViewedSerializer
90-
permission_classes = [IsAuthenticated]
91+
permission_classes = [IsAuthenticated, ProjectVisibilityPermission]
9192

9293
def post(self, request: Request, *args, **kwargs) -> Response:
9394
try:
@@ -100,7 +101,7 @@ def post(self, request: Request, *args, **kwargs) -> Response:
100101

101102
class NewsDetailSetLiked(NewsQuerysetMixin, generics.CreateAPIView):
102103
serializer_class = SetLikedSerializer
103-
permission_classes = [IsAuthenticated]
104+
permission_classes = [IsAuthenticated, ProjectVisibilityPermission]
104105

105106
def post(self, request: Request, *args, **kwargs) -> Response:
106107
try:

partner_programs/admin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,14 @@ class Meta:
7979
"city",
8080
"is_competitive",
8181
"projects_availability",
82+
"publish_projects_after_finish",
8283
"max_project_rates",
8384
"draft",
8485
(
8586
"datetime_started",
8687
"datetime_registration_ends",
88+
"datetime_project_submission_ends",
89+
"datetime_evaluation_ends",
8790
"datetime_finished",
8891
),
8992
(
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 4.2.11 on 2025-12-19 06:26
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("partner_programs", "0013_partnerprogram_max_project_rates"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="partnerprogram",
15+
name="datetime_evaluation_ends",
16+
field=models.DateTimeField(
17+
blank=True, null=True, verbose_name="Дата окончания оценки проектов"
18+
),
19+
),
20+
migrations.AddField(
21+
model_name="partnerprogram",
22+
name="datetime_project_submission_ends",
23+
field=models.DateTimeField(
24+
blank=True,
25+
help_text="Если не указано, используется дата окончания регистрации",
26+
null=True,
27+
verbose_name="Дата окончания подачи проектов",
28+
),
29+
),
30+
]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Codex CLI on 2025-12-19
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("partner_programs", "0014_partnerprogram_datetime_evaluation_ends_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="partnerprogram",
15+
name="publish_projects_after_finish",
16+
field=models.BooleanField(
17+
default=False,
18+
help_text="Если включено, проекты участников станут публичными после завершения программы",
19+
verbose_name="Публиковать проекты после окончания программы",
20+
),
21+
),
22+
]
23+

partner_programs/models.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.contrib.auth import get_user_model
22
from django.core.exceptions import ValidationError
33
from django.db import models
4+
from django.utils import timezone
45

56
from files.models import UserFile
67
from partner_programs.constants import get_default_data_schema
@@ -112,9 +113,25 @@ class PartnerProgram(models.Model):
112113
default="all_users",
113114
verbose_name="Доступность к дочерним проектам",
114115
)
116+
publish_projects_after_finish = models.BooleanField(
117+
default=False,
118+
verbose_name="Публиковать проекты после окончания программы",
119+
help_text="Если включено, проекты участников станут публичными после завершения программы",
120+
)
115121
datetime_registration_ends = models.DateTimeField(
116122
verbose_name="Дата окончания регистрации",
117123
)
124+
datetime_project_submission_ends = models.DateTimeField(
125+
null=True,
126+
blank=True,
127+
verbose_name="Дата окончания подачи проектов",
128+
help_text="Если не указано, используется дата окончания регистрации",
129+
)
130+
datetime_evaluation_ends = models.DateTimeField(
131+
null=True,
132+
blank=True,
133+
verbose_name="Дата окончания оценки проектов",
134+
)
118135
datetime_started = models.DateTimeField(
119136
verbose_name="Дата начала",
120137
)
@@ -143,6 +160,14 @@ class Meta:
143160
def __str__(self):
144161
return f"PartnerProgram<{self.pk}> - {self.name}"
145162

163+
def get_project_submission_deadline(self):
164+
"""Возвращает дедлайн подачи проектов: отдельное поле или дата окончания регистрации."""
165+
return self.datetime_project_submission_ends or self.datetime_registration_ends
166+
167+
def is_project_submission_open(self) -> bool:
168+
deadline = self.get_project_submission_deadline()
169+
return deadline is None or deadline >= timezone.now()
170+
146171

147172
class PartnerProgramUserProfile(models.Model):
148173
"""
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from .fields import PartnerProgramFieldValueUpdateSerializer
2+
from .programs import (
3+
PartnerProgramBaseSerializerMixin,
4+
PartnerProgramDataSchemaSerializer,
5+
PartnerProgramFieldSerializer,
6+
PartnerProgramFieldValueSerializer,
7+
PartnerProgramForMemberSerializer,
8+
PartnerProgramForUnregisteredUserSerializer,
9+
PartnerProgramListSerializer,
10+
PartnerProgramMaterialSerializer,
11+
PartnerProgramNewUserSerializer,
12+
PartnerProgramProjectApplySerializer,
13+
PartnerProgramUserSerializer,
14+
ProgramProjectCreateSerializer,
15+
ProgramProjectFilterRequestSerializer,
16+
UserProgramsSerializer,
17+
)
18+
19+
__all__ = [
20+
"PartnerProgramBaseSerializerMixin",
21+
"PartnerProgramDataSchemaSerializer",
22+
"PartnerProgramFieldSerializer",
23+
"PartnerProgramFieldValueSerializer",
24+
"PartnerProgramFieldValueUpdateSerializer",
25+
"PartnerProgramForMemberSerializer",
26+
"PartnerProgramForUnregisteredUserSerializer",
27+
"PartnerProgramListSerializer",
28+
"PartnerProgramMaterialSerializer",
29+
"PartnerProgramNewUserSerializer",
30+
"PartnerProgramProjectApplySerializer",
31+
"PartnerProgramUserSerializer",
32+
"ProgramProjectCreateSerializer",
33+
"ProgramProjectFilterRequestSerializer",
34+
"UserProgramsSerializer",
35+
]
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from urllib.parse import urlparse
2+
3+
from rest_framework import serializers
4+
5+
from partner_programs.models import PartnerProgramField
6+
7+
8+
class PartnerProgramFieldValueUpdateSerializer(serializers.Serializer):
9+
field_id = serializers.PrimaryKeyRelatedField(
10+
queryset=PartnerProgramField.objects.all(),
11+
source="field",
12+
)
13+
value_text = serializers.CharField(
14+
required=False,
15+
allow_blank=True,
16+
allow_null=True,
17+
help_text="Укажите значение для поля.",
18+
)
19+
20+
def validate(self, attrs):
21+
field = attrs.get("field")
22+
value_text = attrs.get("value_text")
23+
24+
validator = self._get_validator(field)
25+
validator(field, value_text, attrs)
26+
27+
return attrs
28+
29+
def _get_validator(self, field: PartnerProgramField):
30+
validators = {
31+
"text": self._validate_text,
32+
"textarea": self._validate_text,
33+
"checkbox": self._validate_checkbox,
34+
"select": self._validate_select,
35+
"radio": self._validate_radio,
36+
"file": self._validate_file,
37+
}
38+
try:
39+
return validators[field.field_type]
40+
except KeyError:
41+
raise serializers.ValidationError(
42+
f"Тип поля '{field.field_type}' не поддерживается."
43+
)
44+
45+
def _validate_text(self, field: PartnerProgramField, value, attrs):
46+
if field.is_required:
47+
if value is None or str(value).strip() == "":
48+
raise serializers.ValidationError(
49+
"Поле должно содержать текстовое значение."
50+
)
51+
else:
52+
if value is not None and not isinstance(value, str):
53+
raise serializers.ValidationError("Ожидается строка для текстового поля.")
54+
55+
def _validate_checkbox(self, field: PartnerProgramField, value, attrs):
56+
if field.is_required and value in (None, ""):
57+
raise serializers.ValidationError(
58+
"Значение обязательно для поля типа 'checkbox'."
59+
)
60+
61+
if value is not None:
62+
if isinstance(value, bool):
63+
attrs["value_text"] = "true" if value else "false"
64+
elif isinstance(value, str):
65+
normalized = value.strip().lower()
66+
if normalized not in ("true", "false"):
67+
raise serializers.ValidationError(
68+
"Для поля типа 'checkbox' ожидается 'true' или 'false'."
69+
)
70+
attrs["value_text"] = normalized
71+
else:
72+
raise serializers.ValidationError(
73+
"Неверный тип значения для поля 'checkbox'."
74+
)
75+
76+
def _validate_select(self, field: PartnerProgramField, value, attrs):
77+
self._validate_choice_field(field, value, "select")
78+
79+
def _validate_radio(self, field: PartnerProgramField, value, attrs):
80+
self._validate_choice_field(field, value, "radio")
81+
82+
def _validate_choice_field(self, field: PartnerProgramField, value, field_type):
83+
options = field.get_options_list()
84+
85+
if not options:
86+
raise serializers.ValidationError(
87+
f"Для поля типа '{field_type}' не заданы допустимые значения."
88+
)
89+
90+
if field.is_required:
91+
if value is None or value == "":
92+
raise serializers.ValidationError(
93+
f"Значение обязательно для поля типа '{field_type}'."
94+
)
95+
else:
96+
if value is None or value == "":
97+
return
98+
99+
if value is not None:
100+
if not isinstance(value, str):
101+
raise serializers.ValidationError(
102+
f"Ожидается строковое значение для поля типа '{field_type}'."
103+
)
104+
if value not in options:
105+
raise serializers.ValidationError(
106+
f"Недопустимое значение для поля типа '{field_type}'. "
107+
f"Ожидается одно из: {options}."
108+
)
109+
110+
def _validate_file(self, field: PartnerProgramField, value, attrs):
111+
if field.is_required:
112+
if value is None or value == "":
113+
raise serializers.ValidationError("Файл обязателен для этого поля.")
114+
115+
if value is not None:
116+
if not isinstance(value, str):
117+
raise serializers.ValidationError(
118+
"Ожидается строковое значение для поля 'file'."
119+
)
120+
121+
if not self._is_valid_url(value):
122+
raise serializers.ValidationError(
123+
"Ожидается корректная ссылка (URL) на файл."
124+
)
125+
126+
def _is_valid_url(self, url: str) -> bool:
127+
try:
128+
parsed = urlparse(url)
129+
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
130+
except Exception:
131+
return False

0 commit comments

Comments
 (0)