Skip to content
16 changes: 3 additions & 13 deletions feed/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from feed.pagination import FeedPagination
from feed.services import get_liked_news
from news.models import News
from partner_programs.models import PartnerProgramUserProfile
from projects.models import Project
from vacancy.models import Vacancy

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

def _get_excluded_projects_ids(self) -> list[int]:
"""IDs for exclude projects which in Partner Program."""
excluded_projects = PartnerProgramUserProfile.objects.values_list(
"project_id", flat=True
).exclude(project_id__isnull=True)
return excluded_projects

def get_queryset(self) -> QuerySet[News]:
filters = self._get_filter_data()
excluded_project_ids: list[int] = self._get_excluded_projects_ids()

queryset = (
News.objects.select_related("content_type")
.prefetch_related("content_object", "files")
.filter(content_type__model__in=filters)
.exclude(
Q(content_type__model="project") & Q(object_id__in=excluded_project_ids)
)
.order_by("-datetime_created")
)

existing_object_filters = {
"project": Project.objects.values_list("id", flat=True),
"project": Project.objects.filter(draft=False, is_public=True).values_list(
"id", flat=True
),
"vacancy": Vacancy.objects.values_list("id", flat=True),
}
for model_name, ids_queryset in existing_object_filters.items():
Expand Down
9 changes: 5 additions & 4 deletions news/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@
)
from partner_programs.models import PartnerProgram
from projects.models import Project
from projects.permissions import ProjectVisibilityPermission

User = get_user_model()


class NewsList(NewsQuerysetMixin, generics.ListCreateAPIView):
serializer_class = NewsListSerializer
permission_classes = [IsNewsCreatorOrReadOnly]
permission_classes = [ProjectVisibilityPermission, IsNewsCreatorOrReadOnly]
pagination_class = NewsPagination

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

class NewsDetail(NewsQuerysetMixin, generics.RetrieveUpdateDestroyAPIView):
serializer_class = NewsDetailSerializer
permission_classes = [IsNewsCreatorOrReadOnly]
permission_classes = [ProjectVisibilityPermission, IsNewsCreatorOrReadOnly]

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

class NewsDetailSetViewed(NewsQuerysetMixin, generics.CreateAPIView):
serializer_class = SetViewedSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticated, ProjectVisibilityPermission]

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

class NewsDetailSetLiked(NewsQuerysetMixin, generics.CreateAPIView):
serializer_class = SetLikedSerializer
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticated, ProjectVisibilityPermission]

def post(self, request: Request, *args, **kwargs) -> Response:
try:
Expand Down
3 changes: 3 additions & 0 deletions partner_programs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,14 @@ class Meta:
"city",
"is_competitive",
"projects_availability",
"publish_projects_after_finish",
"max_project_rates",
"draft",
(
"datetime_started",
"datetime_registration_ends",
"datetime_project_submission_ends",
"datetime_evaluation_ends",
"datetime_finished",
),
(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 4.2.11 on 2025-12-19 06:26

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("partner_programs", "0013_partnerprogram_max_project_rates"),
]

operations = [
migrations.AddField(
model_name="partnerprogram",
name="datetime_evaluation_ends",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Дата окончания оценки проектов"
),
),
migrations.AddField(
model_name="partnerprogram",
name="datetime_project_submission_ends",
field=models.DateTimeField(
blank=True,
help_text="Если не указано, используется дата окончания регистрации",
null=True,
verbose_name="Дата окончания подачи проектов",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Codex CLI on 2025-12-19

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("partner_programs", "0014_partnerprogram_datetime_evaluation_ends_and_more"),
]

operations = [
migrations.AddField(
model_name="partnerprogram",
name="publish_projects_after_finish",
field=models.BooleanField(
default=False,
help_text="Если включено, проекты участников станут публичными после завершения программы",
verbose_name="Публиковать проекты после окончания программы",
),
),
]

25 changes: 25 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone

from files.models import UserFile
from partner_programs.constants import get_default_data_schema
Expand Down Expand Up @@ -112,9 +113,25 @@ class PartnerProgram(models.Model):
default="all_users",
verbose_name="Доступность к дочерним проектам",
)
publish_projects_after_finish = models.BooleanField(
default=False,
verbose_name="Публиковать проекты после окончания программы",
help_text="Если включено, проекты участников станут публичными после завершения программы",
)
datetime_registration_ends = models.DateTimeField(
verbose_name="Дата окончания регистрации",
)
datetime_project_submission_ends = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата окончания подачи проектов",
help_text="Если не указано, используется дата окончания регистрации",
)
datetime_evaluation_ends = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата окончания оценки проектов",
)
datetime_started = models.DateTimeField(
verbose_name="Дата начала",
)
Expand Down Expand Up @@ -143,6 +160,14 @@ class Meta:
def __str__(self):
return f"PartnerProgram<{self.pk}> - {self.name}"

def get_project_submission_deadline(self):
"""Возвращает дедлайн подачи проектов: отдельное поле или дата окончания регистрации."""
return self.datetime_project_submission_ends or self.datetime_registration_ends

def is_project_submission_open(self) -> bool:
deadline = self.get_project_submission_deadline()
return deadline is None or deadline >= timezone.now()


class PartnerProgramUserProfile(models.Model):
"""
Expand Down
35 changes: 35 additions & 0 deletions partner_programs/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from .fields import PartnerProgramFieldValueUpdateSerializer
from .programs import (
PartnerProgramBaseSerializerMixin,
PartnerProgramDataSchemaSerializer,
PartnerProgramFieldSerializer,
PartnerProgramFieldValueSerializer,
PartnerProgramForMemberSerializer,
PartnerProgramForUnregisteredUserSerializer,
PartnerProgramListSerializer,
PartnerProgramMaterialSerializer,
PartnerProgramNewUserSerializer,
PartnerProgramProjectApplySerializer,
PartnerProgramUserSerializer,
ProgramProjectCreateSerializer,
ProgramProjectFilterRequestSerializer,
UserProgramsSerializer,
)

__all__ = [
"PartnerProgramBaseSerializerMixin",
"PartnerProgramDataSchemaSerializer",
"PartnerProgramFieldSerializer",
"PartnerProgramFieldValueSerializer",
"PartnerProgramFieldValueUpdateSerializer",
"PartnerProgramForMemberSerializer",
"PartnerProgramForUnregisteredUserSerializer",
"PartnerProgramListSerializer",
"PartnerProgramMaterialSerializer",
"PartnerProgramNewUserSerializer",
"PartnerProgramProjectApplySerializer",
"PartnerProgramUserSerializer",
"ProgramProjectCreateSerializer",
"ProgramProjectFilterRequestSerializer",
"UserProgramsSerializer",
]
131 changes: 131 additions & 0 deletions partner_programs/serializers/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
from urllib.parse import urlparse

from rest_framework import serializers

from partner_programs.models import PartnerProgramField


class PartnerProgramFieldValueUpdateSerializer(serializers.Serializer):
field_id = serializers.PrimaryKeyRelatedField(
queryset=PartnerProgramField.objects.all(),
source="field",
)
value_text = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
help_text="Укажите значение для поля.",
)

def validate(self, attrs):
field = attrs.get("field")
value_text = attrs.get("value_text")

validator = self._get_validator(field)
validator(field, value_text, attrs)

return attrs

def _get_validator(self, field: PartnerProgramField):
validators = {
"text": self._validate_text,
"textarea": self._validate_text,
"checkbox": self._validate_checkbox,
"select": self._validate_select,
"radio": self._validate_radio,
"file": self._validate_file,
}
try:
return validators[field.field_type]
except KeyError:
raise serializers.ValidationError(
f"Тип поля '{field.field_type}' не поддерживается."
)

def _validate_text(self, field: PartnerProgramField, value, attrs):
if field.is_required:
if value is None or str(value).strip() == "":
raise serializers.ValidationError(
"Поле должно содержать текстовое значение."
)
else:
if value is not None and not isinstance(value, str):
raise serializers.ValidationError("Ожидается строка для текстового поля.")

def _validate_checkbox(self, field: PartnerProgramField, value, attrs):
if field.is_required and value in (None, ""):
raise serializers.ValidationError(
"Значение обязательно для поля типа 'checkbox'."
)

if value is not None:
if isinstance(value, bool):
attrs["value_text"] = "true" if value else "false"
elif isinstance(value, str):
normalized = value.strip().lower()
if normalized not in ("true", "false"):
raise serializers.ValidationError(
"Для поля типа 'checkbox' ожидается 'true' или 'false'."
)
attrs["value_text"] = normalized
else:
raise serializers.ValidationError(
"Неверный тип значения для поля 'checkbox'."
)

def _validate_select(self, field: PartnerProgramField, value, attrs):
self._validate_choice_field(field, value, "select")

def _validate_radio(self, field: PartnerProgramField, value, attrs):
self._validate_choice_field(field, value, "radio")

def _validate_choice_field(self, field: PartnerProgramField, value, field_type):
options = field.get_options_list()

if not options:
raise serializers.ValidationError(
f"Для поля типа '{field_type}' не заданы допустимые значения."
)

if field.is_required:
if value is None or value == "":
raise serializers.ValidationError(
f"Значение обязательно для поля типа '{field_type}'."
)
else:
if value is None or value == "":
return

if value is not None:
if not isinstance(value, str):
raise serializers.ValidationError(
f"Ожидается строковое значение для поля типа '{field_type}'."
)
if value not in options:
raise serializers.ValidationError(
f"Недопустимое значение для поля типа '{field_type}'. "
f"Ожидается одно из: {options}."
)

def _validate_file(self, field: PartnerProgramField, value, attrs):
if field.is_required:
if value is None or value == "":
raise serializers.ValidationError("Файл обязателен для этого поля.")

if value is not None:
if not isinstance(value, str):
raise serializers.ValidationError(
"Ожидается строковое значение для поля 'file'."
)

if not self._is_valid_url(value):
raise serializers.ValidationError(
"Ожидается корректная ссылка (URL) на файл."
)

def _is_valid_url(self, url: str) -> bool:
try:
parsed = urlparse(url)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
except Exception:
return False
Loading