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
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