diff --git a/feed/views.py b/feed/views.py index 0db7865b..39316b8a 100644 --- a/feed/views.py +++ b/feed/views.py @@ -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 @@ -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(): diff --git a/news/views.py b/news/views.py index a93eabc8..f4260417 100644 --- a/news/views.py +++ b/news/views.py @@ -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: @@ -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: @@ -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: @@ -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: diff --git a/partner_programs/admin.py b/partner_programs/admin.py index f5c22e02..5144210b 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -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", ), ( diff --git a/partner_programs/migrations/0014_partnerprogram_datetime_evaluation_ends_and_more.py b/partner_programs/migrations/0014_partnerprogram_datetime_evaluation_ends_and_more.py new file mode 100644 index 00000000..7751eeb2 --- /dev/null +++ b/partner_programs/migrations/0014_partnerprogram_datetime_evaluation_ends_and_more.py @@ -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="Дата окончания подачи проектов", + ), + ), + ] diff --git a/partner_programs/migrations/0015_partnerprogram_publish_projects_after_finish.py b/partner_programs/migrations/0015_partnerprogram_publish_projects_after_finish.py new file mode 100644 index 00000000..6ae07ac0 --- /dev/null +++ b/partner_programs/migrations/0015_partnerprogram_publish_projects_after_finish.py @@ -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="Публиковать проекты после окончания программы", + ), + ), + ] + diff --git a/partner_programs/models.py b/partner_programs/models.py index ed685390..75054e1f 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -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 @@ -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="Дата начала", ) @@ -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): """ diff --git a/partner_programs/serializers/__init__.py b/partner_programs/serializers/__init__.py new file mode 100644 index 00000000..42b2f311 --- /dev/null +++ b/partner_programs/serializers/__init__.py @@ -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", +] diff --git a/partner_programs/serializers/fields.py b/partner_programs/serializers/fields.py new file mode 100644 index 00000000..5c4ec436 --- /dev/null +++ b/partner_programs/serializers/fields.py @@ -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 diff --git a/partner_programs/serializers.py b/partner_programs/serializers/programs.py similarity index 80% rename from partner_programs/serializers.py rename to partner_programs/serializers/programs.py index 500f7f42..54bed685 100644 --- a/partner_programs/serializers.py +++ b/partner_programs/serializers/programs.py @@ -2,12 +2,15 @@ from rest_framework import serializers from core.services import get_likes_count, get_links, get_views_count, is_fan +from .fields import PartnerProgramFieldValueUpdateSerializer from partner_programs.models import ( PartnerProgram, PartnerProgramField, PartnerProgramFieldValue, PartnerProgramMaterial, ) +from projects.models import Project +from projects.validators import validate_project User = get_user_model() @@ -21,6 +24,16 @@ class PartnerProgramListSerializer(serializers.ModelSerializer): method_name="get_short_description" ) is_user_liked = serializers.SerializerMethodField(method_name="get_is_user_liked") + is_user_member = serializers.SerializerMethodField(method_name="get_is_user_member") + + def _get_user(self): + user = self.context.get("user") + if user: + return user + request = self.context.get("request") + if request: + return request.user + return None def count_likes(self, program): return get_likes_count(program) @@ -35,11 +48,19 @@ def get_short_description(self, program): def get_is_user_liked(self, obj): # fixme: copy-paste in every serializer... - user = self.context.get("user") - if user: + user = self._get_user() + if user and user.is_authenticated: return is_fan(obj, user) return False + def get_is_user_member(self, program): + if hasattr(program, "is_user_member"): + return bool(program.is_user_member) + user = self._get_user() + if not user or not user.is_authenticated: + return False + return program.users.filter(pk=user.pk).exists() + class Meta: model = PartnerProgram fields = ( @@ -49,11 +70,15 @@ class Meta: "short_description", "registration_link", "datetime_registration_ends", + "datetime_project_submission_ends", + "datetime_evaluation_ends", + "publish_projects_after_finish", "datetime_started", "datetime_finished", "views_count", "likes_count", "is_user_liked", + "is_user_member", ) @@ -116,6 +141,9 @@ class Meta: "registration_link", "views_count", "datetime_registration_ends", + "datetime_project_submission_ends", + "datetime_evaluation_ends", + "publish_projects_after_finish", "is_user_manager", ) @@ -137,6 +165,9 @@ class Meta: "presentation_address", "registration_link", "datetime_registration_ends", + "datetime_project_submission_ends", + "datetime_evaluation_ends", + "publish_projects_after_finish", "is_user_manager", ) @@ -264,3 +295,34 @@ def validate_filters(self, value): cleaned[key.strip()] = normalized_values return cleaned + + +class ProgramProjectCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Project + fields = [ + "name", + "description", + "region", + "industry", + "presentation_address", + "image_address", + "cover_image_address", + "actuality", + "problem", + "target_audience", + "implementation_deadline", + "trl", + "is_company", + ] + + def validate(self, data): + validate_project({**data, "draft": True}) + return data + + +class PartnerProgramProjectApplySerializer(serializers.Serializer): + project = ProgramProjectCreateSerializer() + program_field_values = PartnerProgramFieldValueUpdateSerializer( + many=True, required=False + ) diff --git a/partner_programs/tests.py b/partner_programs/tests.py index 14eb4d4b..d4804970 100644 --- a/partner_programs/tests.py +++ b/partner_programs/tests.py @@ -2,7 +2,7 @@ from django.utils import timezone from partner_programs.models import PartnerProgram, PartnerProgramField -from projects.serializers import PartnerProgramFieldValueUpdateSerializer +from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer class PartnerProgramFieldValueUpdateSerializerInvalidTests(TestCase): diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 269e7c16..92403184 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -7,6 +7,7 @@ PartnerProgramDetail, PartnerProgramExportProjectsAPIView, PartnerProgramList, + PartnerProgramProjectApplyView, PartnerProgramProjectsAPIView, PartnerProgramProjectSubmitView, PartnerProgramRegister, @@ -51,6 +52,11 @@ PartnerProgramProjectsAPIView.as_view(), name="partner-program-projects", ), + path( + "/projects/apply/", + PartnerProgramProjectApplyView.as_view(), + name="partner-program-project-apply", + ), path( "/export-projects/", PartnerProgramExportProjectsAPIView.as_view(), diff --git a/partner_programs/views.py b/partner_programs/views.py index 8fa8dd43..7e5d6634 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction -from django.db.models import Prefetch +from django.db.models import Exists, OuterRef, Prefetch from django.http import FileResponse from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -38,6 +38,7 @@ PartnerProgramForUnregisteredUserSerializer, PartnerProgramListSerializer, PartnerProgramNewUserSerializer, + PartnerProgramProjectApplySerializer, PartnerProgramUserSerializer, ProgramProjectFilterRequestSerializer, ) @@ -49,10 +50,8 @@ ) from partner_programs.utils import filter_program_projects_by_field_name from projects.models import Collaborator, Project -from projects.serializers import ( - PartnerProgramFieldValueUpdateSerializer, - ProjectListSerializer, -) +from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer +from projects.serializers import ProjectListSerializer from vacancy.mapping import MessageTypeEnum, UserProgramRegisterParams from vacancy.tasks import send_email @@ -69,19 +68,28 @@ def get_queryset(self): base_qs = super().get_queryset() participating_flag = self.request.query_params.get("participating") if not participating_flag: - return base_qs + qs = base_qs + elif not self.request.user.is_authenticated: + qs = PartnerProgram.objects.none() + else: + now = timezone.now() + qs = ( + base_qs.filter( + partner_program_profiles__user=self.request.user, + datetime_finished__gte=now, + ) + .distinct() + ) - if not self.request.user.is_authenticated: - return PartnerProgram.objects.none() + user = self.request.user + if not user.is_authenticated: + return qs - now = timezone.now() - return ( - base_qs.filter( - partner_program_profiles__user=self.request.user, - datetime_finished__gte=now, - ) - .distinct() + member_qs = PartnerProgramUserProfile.objects.filter( + partner_program=OuterRef("pk"), + user=user, ) + return qs.annotate(is_user_member=Exists(member_qs)) class PartnerProgramDetail(generics.RetrieveAPIView): @@ -107,6 +115,140 @@ def get(self, request, *args, **kwargs): return Response(data, status=status.HTTP_200_OK) +class PartnerProgramProjectApplyView(GenericAPIView): + """ + Создание проекта в рамках программы (подать проект). + Проект создаётся как непубличный черновик. + """ + + permission_classes = [IsAuthenticated] + serializer_class = PartnerProgramProjectApplySerializer + queryset = PartnerProgram.objects.all() + + def _require_can_apply(self, program: PartnerProgram, user: User): + if not program.is_project_submission_open(): + raise ValidationError("Срок подачи проектов в программу завершён.") + + if program.is_manager(user): + return + + if not PartnerProgramUserProfile.objects.filter( + user=user, partner_program=program + ).exists(): + raise PermissionDenied("Подача проекта доступна только участникам программы.") + + def get(self, request, pk, *args, **kwargs): + program = self.get_object() + self._require_can_apply(program, request.user) + + fields_qs = program.fields.all() + return Response( + { + "program_id": program.id, + "can_submit": program.is_project_submission_open(), + "submission_deadline": program.get_project_submission_deadline(), + "program_fields": PartnerProgramFieldSerializer(fields_qs, many=True).data, + }, + status=status.HTTP_200_OK, + ) + + def post(self, request, pk, *args, **kwargs): + program = self.get_object() + self._require_can_apply(program, request.user) + + existing_link = ( + PartnerProgramProject.objects.select_related("project") + .filter(partner_program=program, project__leader=request.user) + .first() + ) + if existing_link: + return Response( + { + "detail": "Проект уже подан в эту программу.", + "project_id": existing_link.project_id, + "program_link_id": existing_link.id, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + project_data = data["project"] + values_data = data.get("program_field_values") or [] + + seen_field_ids: set[int] = set() + duplicate_ids: set[int] = set() + for item in values_data: + field_id = item["field"].id + if field_id in seen_field_ids: + duplicate_ids.add(field_id) + seen_field_ids.add(field_id) + if duplicate_ids: + raise ValidationError( + {"program_field_values": f"Есть повторяющиеся field_id: {sorted(duplicate_ids)}"} + ) + + required_fields = list( + program.fields.filter(is_required=True).values("id", "label") + ) + provided_field_ids = {item["field"].id for item in values_data} + missing_required = [ + f["label"] for f in required_fields if f["id"] not in provided_field_ids + ] + if missing_required: + raise ValidationError( + {"program_field_values": f"Не заполнены обязательные поля: {missing_required}"} + ) + + with transaction.atomic(): + project = Project.objects.create( + leader=request.user, + draft=True, + is_public=False, + **project_data, + ) + program_link = PartnerProgramProject.objects.create( + partner_program=program, project=project + ) + + profile = PartnerProgramUserProfile.objects.filter( + user=request.user, partner_program=program + ).first() + if profile: + profile.project = project + profile.save(update_fields=["project"]) + + value_objs: list[PartnerProgramFieldValue] = [] + for item in values_data: + field = item["field"] + if field.partner_program_id != program.id: + raise ValidationError( + { + "program_field_values": f"Поле id={field.id} не относится к этой программе." + } + ) + value_objs.append( + PartnerProgramFieldValue( + program_project=program_link, + field=field, + value_text=item.get("value_text") or "", + ) + ) + + if value_objs: + PartnerProgramFieldValue.objects.bulk_create(value_objs) + + return Response( + { + "project_id": project.id, + "program_link_id": program_link.id, + }, + status=status.HTTP_201_CREATED, + ) + + class PartnerProgramCreateUserAndRegister(generics.GenericAPIView): """ Create new user and register him to program and save additional data. diff --git a/procollab/settings.py b/procollab/settings.py index 7548a1e7..a5020e37 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -1,5 +1,6 @@ import os import mimetypes +import sys from datetime import timedelta from pathlib import Path @@ -183,6 +184,8 @@ ASGI_APPLICATION = "procollab.asgi.application" +RUNNING_TESTS = "test" in sys.argv + if DEBUG: DATABASES = { "default": { @@ -202,12 +205,24 @@ # } # } - CACHES = { - "default": { - "BACKEND": "django_prometheus.cache.backends.filebased.FileBasedCache", - "LOCATION": "/var/tmp/django_cache", + if RUNNING_TESTS: + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "test-cache", + } + } + else: + CACHES = { + "default": { + "BACKEND": "django_prometheus.cache.backends.filebased.FileBasedCache", + "LOCATION": config( + "DJANGO_FILE_CACHE_DIR", + default=str(BASE_DIR / ".cache" / "django_cache"), + cast=str, + ), + } } - } CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} else: diff --git a/projects/helpers.py b/projects/helpers.py index ad3c02bc..269d1305 100644 --- a/projects/helpers.py +++ b/projects/helpers.py @@ -6,7 +6,11 @@ from rest_framework.exceptions import ValidationError -from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) from projects.models import Project, ProjectLink, Achievement from users.models import CustomUser @@ -107,9 +111,23 @@ def update_partner_program( # If the user removes the tag, frontend sends `int -> 0` (id == 0 cannot exist). if program_id == 0: clear_project_existing_from_profile(user, instance) + PartnerProgramProject.objects.filter(project=instance).delete() else: partner_program = PartnerProgram.objects.get(pk=program_id) - existing_program_id: int | None = clear_project_existing_from_profile(user, instance) + existing_program_profile = ( + PartnerProgramUserProfile.objects.select_related("partner_program") + .filter(user=user, project=instance) + .first() + ) + existing_program_id: int | None = ( + existing_program_profile.partner_program_id + if existing_program_profile + else None + ) + + submission_deadline = partner_program.get_project_submission_deadline() + if submission_deadline and submission_deadline < timezone.now(): + raise ValidationError({"error": "Срок подачи проектов в программу завершён."}) if ( partner_program.datetime_finished < timezone.now() @@ -117,12 +135,24 @@ def update_partner_program( ): raise ValidationError({"error": "Cannot select a completed program."}) - partner_program_profile = PartnerProgramUserProfile.objects.get( + clear_project_existing_from_profile(user, instance) + instance.is_public = False + instance.save(update_fields=["is_public"]) + + PartnerProgramProject.objects.filter(project=instance).exclude( + partner_program_id=partner_program.id + ).delete() + PartnerProgramProject.objects.get_or_create( + partner_program=partner_program, project=instance + ) + + partner_program_profile = PartnerProgramUserProfile.objects.filter( user=user, partner_program=partner_program, - ) - partner_program_profile.project = instance - partner_program_profile.save() + ).first() + if partner_program_profile: + partner_program_profile.project = instance + partner_program_profile.save(update_fields=["project"]) def clear_project_existing_from_profile(user, instance) -> None | int: diff --git a/projects/managers.py b/projects/managers.py index a5b9a989..3365570b 100644 --- a/projects/managers.py +++ b/projects/managers.py @@ -18,7 +18,7 @@ class ProjectManager(Manager): def get_projects_for_list_view(self): return ( self.get_queryset() - .filter(draft=False) + .filter(draft=False, is_public=True) .prefetch_related("program_links__partner_program") ) diff --git a/projects/migrations/0031_project_is_public.py b/projects/migrations/0031_project_is_public.py new file mode 100644 index 00000000..476ec35e --- /dev/null +++ b/projects/migrations/0031_project_is_public.py @@ -0,0 +1,24 @@ +# Generated by Codex CLI on 2025-12-19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0030_company_resource_projectcompany_project_companies_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="is_public", + field=models.BooleanField( + db_index=True, + default=True, + help_text="Если выключено — проект скрыт из общего каталога и ленты.", + verbose_name="Публичный", + ), + ), + ] + diff --git a/projects/migrations/0032_hide_program_projects.py b/projects/migrations/0032_hide_program_projects.py new file mode 100644 index 00000000..eb62600e --- /dev/null +++ b/projects/migrations/0032_hide_program_projects.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.11 on 2025-12-19 + +from django.db import migrations + + +def hide_program_projects(apps, schema_editor): + Project = apps.get_model("projects", "Project") + PartnerProgramProject = apps.get_model("partner_programs", "PartnerProgramProject") + PartnerProgramUserProfile = apps.get_model( + "partner_programs", "PartnerProgramUserProfile" + ) + + project_ids_from_links = PartnerProgramProject.objects.values_list( + "project_id", flat=True + ).exclude(project_id__isnull=True) + project_ids_from_profiles = PartnerProgramUserProfile.objects.values_list( + "project_id", flat=True + ).exclude(project_id__isnull=True) + + Project.objects.filter(id__in=project_ids_from_links).update(is_public=False) + Project.objects.filter(id__in=project_ids_from_profiles).update(is_public=False) + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0031_project_is_public"), + ("partner_programs", "0015_partnerprogram_publish_projects_after_finish"), + ] + + operations = [ + migrations.RunPython(hide_program_projects, migrations.RunPython.noop), + ] diff --git a/projects/models.py b/projects/models.py index b36cd38e..a4443f78 100644 --- a/projects/models.py +++ b/projects/models.py @@ -183,6 +183,12 @@ class Project(models.Model): datetime_updated = models.DateTimeField( verbose_name="Дата изменения", null=False, auto_now=True ) + is_public = models.BooleanField( + default=True, + db_index=True, + verbose_name="Публичный", + help_text="Если выключено — проект скрыт из общего каталога и ленты.", + ) objects = ProjectManager() diff --git a/projects/permissions.py b/projects/permissions.py index 886a6d72..de7da2d7 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -1,13 +1,88 @@ from datetime import datetime, timedelta from django.utils import timezone +from django.db.models import Q from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.permissions import SAFE_METHODS, BasePermission -from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) from projects.models import Project +class ProjectVisibilityPermission(BasePermission): + """ + Ограничивает доступ к непубличным проектам. + + Непубличные проекты доступны: + - лидеру проекта; + - администраторам (staff/superuser); + - участникам команды (collaborators/invites); + - если проект в программе: менеджерам и экспертам этой программы. + """ + + message = "У вас нет доступа к этому проекту." + + def has_permission(self, request, view): + project_id = view.kwargs.get("project_pk") or view.kwargs.get("project_id") + if not project_id and view.__module__.startswith("projects."): + project_id = view.kwargs.get("id") or view.kwargs.get("pk") + + if not project_id and getattr(getattr(view, "queryset", None), "model", None) is Project: + project_id = view.kwargs.get("pk") + + if not project_id: + return True + + try: + project = Project.objects.only("id", "leader_id", "is_public").get( + pk=project_id + ) + except Project.DoesNotExist: + return True + + return self._can_view_project(request, project) + + def has_object_permission(self, request, view, obj): + if isinstance(obj, Project): + project = obj + else: + project = getattr(obj, "project", None) + if project is None: + return True + return self._can_view_project(request, project) + + def _can_view_project(self, request, project: Project) -> bool: + if project.is_public: + return True + + user = getattr(request, "user", None) + if not user or not user.is_authenticated: + return False + + if user.is_superuser or user.is_staff: + return True + + if project.leader_id == user.id: + return True + + if project.collaborator_set.filter(user_id=user.id).exists(): + return True + + if project.invite_set.filter(user_id=user.id).exists(): + return True + + return PartnerProgramProject.objects.filter( + project_id=project.id, + ).filter( + Q(partner_program__managers__id=user.id) + | Q(partner_program__experts__user_id=user.id) + ).exists() + + class IsProjectLeaderOrReadOnlyForNonDrafts(BasePermission): """ Allows access to update only to project leader. @@ -204,6 +279,13 @@ def has_permission(self, request, view): except PartnerProgram.DoesNotExist: raise ValidationError({"partner_program_id": "Программа не найдена."}) + submission_deadline = program.get_project_submission_deadline() + if submission_deadline and submission_deadline < timezone.now(): + raise ValidationError({"partner_program_id": "Срок подачи проектов в программу завершён."}) + + if program.datetime_finished < timezone.now(): + raise ValidationError({"partner_program_id": "Нельзя выбрать завершённую программу."}) + if program.is_manager(request.user): return True diff --git a/projects/serializers.py b/projects/serializers.py index 6b75eed9..c6b5a175 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -1,5 +1,3 @@ -from urllib.parse import urlparse - from django.contrib.auth import get_user_model from django.core.cache import cache from django.db import transaction @@ -12,7 +10,6 @@ from industries.models import Industry from partner_programs.models import ( PartnerProgram, - PartnerProgramField, PartnerProgramFieldValue, PartnerProgramProject, ) @@ -312,6 +309,7 @@ class Meta: "datetime_created", "datetime_updated", "views_count", + "is_public", "cover", "cover_image_address", "actuality", @@ -328,6 +326,7 @@ class Meta: "datetime_created", "datetime_updated", "is_company", + "is_public", ] @@ -561,132 +560,6 @@ def validate(self, data): return data -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): - 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, 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, 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, value, attrs): - self._validate_choice_field(field, value, "select") - - def _validate_radio(self, field, value, attrs): - self._validate_choice_field(field, value, "radio") - - def _validate_choice_field(self, field, 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, 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 - - class ProjectCompanyUpsertSerializer(serializers.Serializer): name = serializers.CharField(max_length=255) inn = serializers.RegexField(regex=r"^\d{10}(\d{2})?$") diff --git a/projects/views.py b/projects/views.py index 2e4f3181..528cea12 100644 --- a/projects/views.py +++ b/projects/views.py @@ -49,6 +49,7 @@ IsProjectLeader, IsProjectLeaderOrReadOnly, IsProjectLeaderOrReadOnlyForNonDrafts, + ProjectVisibilityPermission, TimingAfterEndsProgramPermission, ) from core.serializers import EmptySerializer @@ -81,19 +82,17 @@ class ProjectList(generics.ListCreateAPIView): serializer_class = ProjectListSerializer - permission_classes = [IsAuthenticated, permissions.IsAuthenticatedOrReadOnly] + permission_classes = [ + IsAuthenticated, + permissions.IsAuthenticatedOrReadOnly, + CanBindProjectToProgram, + ] filter_backends = (filters.DjangoFilterBackend,) filterset_class = ProjectFilter pagination_class = ProjectsPagination def get_queryset(self) -> QuerySet[Project]: queryset = Project.objects.get_projects_for_list_view() - is_program_needed = self.request.query_params.get("partner_program", None) - if not is_program_needed: - queryset_without_projects_linked_to_programs = queryset.filter( - partner_program_profiles__isnull=True - ) - return queryset_without_projects_linked_to_programs return queryset def create(self, request, *args, **kwargs): @@ -154,6 +153,7 @@ def post(self, request, *args, **kwargs): class ProjectDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Project.objects.get_projects_for_detail_view() permission_classes = [ + ProjectVisibilityPermission, HasInvolvementInProjectOrReadOnly, TimingAfterEndsProgramPermission, ] @@ -219,7 +219,7 @@ def get(self, request, pk, **kwargs): class SetLikeOnProject(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, ProjectVisibilityPermission] def post(self, request, pk): """ @@ -251,7 +251,7 @@ class ProjectCountView(generics.GenericAPIView): def get(self, request): return Response( { - "all": self.get_queryset().filter(draft=False).count(), + "all": self.get_queryset().filter(draft=False, is_public=True).count(), "my": self.get_queryset() .filter(Q(leader_id=request.user.id) | Q(collaborator__user=request.user)) .distinct() @@ -266,7 +266,7 @@ class ProjectCollaborators(generics.GenericAPIView): Project collaborator retrieve/add/delete view """ - permission_classes = [IsProjectLeaderOrReadOnlyForNonDrafts] + permission_classes = [ProjectVisibilityPermission, IsProjectLeaderOrReadOnlyForNonDrafts] queryset = Project.objects.all() serializer_class = ProjectCollaboratorSerializer @@ -345,7 +345,7 @@ class AchievementDetail(generics.RetrieveUpdateDestroyAPIView): class ProjectVacancyResponses(generics.GenericAPIView): serializer_class = VacancyResponseFullFileInfoListSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, ProjectVisibilityPermission] def get_queryset(self): return VacancyResponse.objects.filter(vacancy__project_id=self.kwargs["id"]) @@ -358,7 +358,7 @@ def get(self, *args, **kwargs): class ProjectNewsList(generics.ListCreateAPIView): serializer_class = ProjectNewsListSerializer - permission_classes = [IsNewsAuthorIsProjectLeaderOrReadOnly] + permission_classes = [ProjectVisibilityPermission, IsNewsAuthorIsProjectLeaderOrReadOnly] pagination_class = ProjectNewsPagination def perform_create(self, serializer): @@ -379,7 +379,7 @@ def get(self, request, *args, **kwargs): class ProjectNewsDetail(generics.RetrieveUpdateDestroyAPIView): queryset = ProjectNews.objects.all() serializer_class = ProjectNewsDetailSerializer - permission_classes = [IsNewsAuthorIsProjectLeaderOrReadOnly] + permission_classes = [ProjectVisibilityPermission, IsNewsAuthorIsProjectLeaderOrReadOnly] def get_queryset(self): try: @@ -414,7 +414,7 @@ class ProjectNewsDetailSetViewed(generics.CreateAPIView): queryset = ProjectNews.objects.all() # fixme # serializer_class = SetViewedSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, ProjectVisibilityPermission] def get_queryset(self): try: @@ -435,7 +435,7 @@ def post(self, request, *args, **kwargs): class ProjectNewsDetailSetLiked(generics.CreateAPIView): serializer_class = SetLikedSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, ProjectVisibilityPermission] def get_queryset(self): try: @@ -455,7 +455,7 @@ def post(self, request, *args, **kwargs): class ProjectSubscribers(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, ProjectVisibilityPermission] @swagger_auto_schema( responses={ @@ -477,7 +477,7 @@ def get(self, request, *args, **kwargs): class ProjectSubscribe(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, ProjectVisibilityPermission] def post(self, request, project_pk): try: @@ -502,7 +502,7 @@ def post(self, request, project_pk): class ProjectUnsubscribe(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, ProjectVisibilityPermission] def post(self, request, project_pk): try: @@ -738,6 +738,7 @@ def post(self, request): image_address=original_project.image_address, leader=request.user, draft=True, + is_public=False, is_company=original_project.is_company, cover_image_address=original_project.cover_image_address, cover=original_project.cover, @@ -762,7 +763,7 @@ def post(self, request): class GoalViewSet(viewsets.ModelViewSet): queryset = ProjectGoal.objects.select_related("project", "responsible") serializer_class = ProjectGoalSerializer - permission_classes = [IsProjectLeaderOrReadOnly] + permission_classes = [ProjectVisibilityPermission, IsProjectLeaderOrReadOnly] def get_queryset(self): project_pk = self.kwargs.get("project_pk") @@ -813,7 +814,7 @@ def perform_update(self, serializer): class CompanyViewSet(viewsets.ModelViewSet): queryset = Company.objects.all().order_by("name") serializer_class = CompanySerializer - permission_classes = (IsProjectLeaderOrReadOnly,) + permission_classes = (ProjectVisibilityPermission, IsProjectLeaderOrReadOnly) filterset_fields = ("inn",) search_fields = ("name", "inn") @@ -821,7 +822,7 @@ class CompanyViewSet(viewsets.ModelViewSet): class ResourceViewSet(viewsets.ModelViewSet): queryset = Resource.objects.select_related("project", "partner_company").all() serializer_class = ResourceSerializer - permission_classes = (IsProjectLeaderOrReadOnly,) + permission_classes = (ProjectVisibilityPermission, IsProjectLeaderOrReadOnly) filterset_fields = ("type", "project", "partner_company") search_fields = ("description", "project__name", "partner_company__name") @@ -847,7 +848,7 @@ class ProjectCompanyUpsertView(APIView): - если нет — создаём компанию и тут же связываем. """ - permission_classes = (IsProjectLeaderOrReadOnly,) + permission_classes = (ProjectVisibilityPermission, IsProjectLeaderOrReadOnly) @swagger_auto_schema( request_body=ProjectCompanyUpsertSerializer, @@ -882,7 +883,7 @@ class ProjectCompaniesListView(ListAPIView): """ serializer_class = ProjectCompanySerializer - permission_classes = (IsProjectLeaderOrReadOnly,) + permission_classes = (ProjectVisibilityPermission, IsProjectLeaderOrReadOnly) @swagger_auto_schema( operation_summary="Список партнёров проекта", @@ -908,7 +909,7 @@ class ProjectCompanyDetailView(APIView): DELETE - удаляет только связь; Company остаётся в БД """ - permission_classes = (IsProjectLeaderOrReadOnly,) + permission_classes = (ProjectVisibilityPermission, IsProjectLeaderOrReadOnly) project_id_param = openapi.Parameter( "project_id", openapi.IN_PATH,