diff --git a/news/permissions.py b/news/permissions.py index 34107135..a1906a24 100644 --- a/news/permissions.py +++ b/news/permissions.py @@ -1,6 +1,6 @@ from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 from rest_framework import permissions -from rest_framework.exceptions import NotFound from rest_framework.permissions import SAFE_METHODS from partner_programs.models import PartnerProgram @@ -10,47 +10,37 @@ class IsNewsCreatorOrReadOnly(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - """ - read/update/delete permission - currently can only be updated/deleted in admin panel - """ + def has_permission(self, request, view): if request.method in SAFE_METHODS: return True - if ( - isinstance(obj.content_object, Project) - and obj.content_object.leader == request.user - ): - return True - if isinstance(obj.content_object, User) and obj.content_object == request.user: - return True - if isinstance(obj.content_object, PartnerProgram): - # TODO: implement - pass + + if view.kwargs.get("project_pk"): + project = get_object_or_404(Project, pk=view.kwargs["project_pk"]) + return request.user == project.leader + + if view.kwargs.get("user_pk"): + user = get_object_or_404(User, pk=view.kwargs["user_pk"]) + return request.user == user + + if view.kwargs.get("partnerprogram_pk"): + program = get_object_or_404( + PartnerProgram, pk=view.kwargs["partnerprogram_pk"] + ) + return program.is_manager(request.user) + return False - def has_permission(self, request, view): - """ - Creation permission - Currently can only be created via admin panel - """ + def has_object_permission(self, request, view, obj): if request.method in SAFE_METHODS: return True - if view.kwargs.get("project_pk"): - try: - project = Project.objects.get(pk=view.kwargs["project_pk"]) - if request.method in SAFE_METHODS or (request.user == project.leader): - return True - except Project.DoesNotExist: - raise NotFound + if isinstance(obj.content_object, Project): + return obj.content_object.leader == request.user - if view.kwargs.get("user_pk"): - try: - user = User.objects.get(pk=view.kwargs["user_pk"]) - if request.method in SAFE_METHODS or (request.user == user): - return True - except User.DoesNotExist: - raise NotFound + if isinstance(obj.content_object, User): + return obj.content_object == request.user + + if isinstance(obj.content_object, PartnerProgram): + return obj.content_object.is_manager(request.user) return False diff --git a/news/views.py b/news/views.py index f29e1599..a93eabc8 100644 --- a/news/views.py +++ b/news/views.py @@ -2,20 +2,21 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics, status from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response from rest_framework.request import Request +from rest_framework.response import Response -from core.serializers import SetViewedSerializer, SetLikedSerializer +from core.serializers import SetLikedSerializer, SetViewedSerializer from core.services import add_view, set_like from news.mixins import NewsQuerysetMixin from news.models import News from news.pagination import NewsPagination from news.permissions import IsNewsCreatorOrReadOnly from news.serializers import ( - NewsListSerializer, NewsDetailSerializer, NewsListCreateSerializer, + NewsListSerializer, ) +from partner_programs.models import PartnerProgram from projects.models import Project User = get_user_model() @@ -44,7 +45,12 @@ def post(self, request: Request, *args, **kwargs) -> Response: NewsDetailSerializer(news).data, status=status.HTTP_201_CREATED ) - # creating partner program news, not implemented yet, return 400 + if kwargs.get("partnerprogram_pk"): + program = get_object_or_404(PartnerProgram, pk=kwargs["partnerprogram_pk"]) + news = News.objects.add_news(program, **data) + return Response( + NewsDetailSerializer(news).data, status=status.HTTP_201_CREATED + ) return Response(status=status.HTTP_400_BAD_REQUEST) def get(self, request: Request, *args, **kwargs) -> Response: diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 4b7465f7..a9612592 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -1,22 +1,43 @@ -import tablib import re import urllib.parse + +import tablib from django.contrib import admin from django.db.models import QuerySet -from django.http import HttpResponse, HttpRequest +from django.http import HttpRequest, HttpResponse from django.urls import path from django.utils import timezone -from mailing.views import MailingTemplateRender from core.utils import XlsxFileToExport -from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from mailing.views import MailingTemplateRender +from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramMaterial, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from partner_programs.services import ProjectScoreDataPreparer from project_rates.models import Criteria, ProjectScore from projects.models import Project -from partner_programs.services import ProjectScoreDataPreparer + + +class PartnerProgramMaterialInline(admin.StackedInline): + model = PartnerProgramMaterial + extra = 1 + fields = ("title", "url", "file") + readonly_fields = ("datetime_created", "datetime_updated") + + +class PartnerProgramFieldInline(admin.TabularInline): + model = PartnerProgramField + extra = 0 @admin.register(PartnerProgram) class PartnerProgramAdmin(admin.ModelAdmin): + inlines = [PartnerProgramMaterialInline, PartnerProgramFieldInline] list_display = ("id", "name", "tag", "city", "datetime_created") list_display_links = ( "id", @@ -32,7 +53,7 @@ class PartnerProgramAdmin(admin.ModelAdmin): ) list_filter = ("city",) - filter_horizontal = ("users",) + filter_horizontal = ("users", "managers") date_hierarchy = "datetime_started" def get_queryset(self, request: HttpRequest) -> QuerySet[PartnerProgram]: @@ -54,7 +75,9 @@ def change_view(self, request, object_id, form_url="", extra_context=None): "partner_programs/admin/program_manager_change_form.html" ) else: - self.change_form_template = "partner_programs/admin/programs_change_form.html" + self.change_form_template = ( + "partner_programs/admin/programs_change_form.html" + ) return super().change_view(request, object_id, form_url, extra_context) @@ -145,7 +168,7 @@ def get_export_file(self, partner_program: PartnerProgram): binary_data = response_data.export("xlsx") file_name = ( - f'{partner_program.name} {timezone.now().strftime("%d-%m-%Y %H:%M:%S")}' + f"{partner_program.name} {timezone.now().strftime('%d-%m-%Y %H:%M:%S')}" ) response = HttpResponse( content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -155,7 +178,9 @@ def get_export_file(self, partner_program: PartnerProgram): return response def get_export_rates_view(self, request, object_id): - rates_data_to_write: list[dict] = self._get_prepared_rates_data_for_export(object_id) + rates_data_to_write: list[dict] = self._get_prepared_rates_data_for_export( + object_id + ) xlsx_file_writer = XlsxFileToExport() xlsx_file_writer.write_data_to_xlsx(rates_data_to_write) @@ -163,14 +188,16 @@ def get_export_rates_view(self, request, object_id): xlsx_file_writer.delete_self_xlsx_file_from_local_machine() encoded_file_name: str = urllib.parse.quote( - f'{PartnerProgram.objects.get(pk=object_id).name}_оценки {timezone.now().strftime("%d-%m-%Y %H:%M:%S")}' + f"{PartnerProgram.objects.get(pk=object_id).name}_оценки {timezone.now().strftime('%d-%m-%Y %H:%M:%S')}" f".xlsx" ) response = HttpResponse( binary_data_to_export, content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) - response["Content-Disposition"] = f'attachment; filename*=UTF-8\'\'{encoded_file_name}' + response["Content-Disposition"] = ( + f"attachment; filename*=UTF-8''{encoded_file_name}" + ) return response def _get_prepared_rates_data_for_export(self, program_id: int) -> list[dict]: @@ -179,19 +206,22 @@ def _get_prepared_rates_data_for_export(self, program_id: int) -> list[dict]: Columns example: ФИО|Email|Регион_РФ|Учебное_заведение|Название_учебного_заведения|Класс_курс|Фамилия эксперта|**criteria """ - criterias = Criteria.objects.filter(partner_program__id=program_id).select_related("partner_program") + criterias = Criteria.objects.filter( + partner_program__id=program_id + ).select_related("partner_program") scores = ( - ProjectScore.objects - .filter(criteria__in=criterias) + ProjectScore.objects.filter(criteria__in=criterias) .select_related("user", "criteria", "project") .order_by("project", "criteria") ) - user_programm_profiles = ( - PartnerProgramUserProfile.objects - .filter(partner_program__id=program_id) - .select_related("user") + user_programm_profiles = PartnerProgramUserProfile.objects.filter( + partner_program__id=program_id + ).select_related("user") + projects = ( + Project.objects.filter(scores__in=scores) + .select_related("leader") + .distinct() ) - projects = Project.objects.filter(scores__in=scores).select_related("leader").distinct() # To reduce the number of DB requests. user_profiles_dict: dict[int, PartnerProgramUserProfile] = { @@ -203,7 +233,9 @@ def _get_prepared_rates_data_for_export(self, program_id: int) -> list[dict]: prepared_projects_rates_data: list[dict] = [] for project in projects: - project_data_preparer = ProjectScoreDataPreparer(user_profiles_dict, scores_dict, project.id, program_id) + project_data_preparer = ProjectScoreDataPreparer( + user_profiles_dict, scores_dict, project.id, program_id + ) full_project_rates_data: dict = { **project_data_preparer.get_project_user_info(), **project_data_preparer.get_project_expert_info(), @@ -242,3 +274,66 @@ def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) form.base_fields["project"].required = False return form + + +@admin.register(PartnerProgramMaterial) +class PartnerProgramMaterialAdmin(admin.ModelAdmin): + list_display = ("title", "program", "short_url", "has_file", "datetime_created") + list_filter = ("program",) + search_fields = ("title", "program__name") + + readonly_fields = ("datetime_created", "datetime_updated") + + def short_url(self, obj): + return obj.url[:60] if obj.url else "—" + + short_url.short_description = "Ссылка" + + def has_file(self, obj): + return bool(obj.file) + + has_file.boolean = True + has_file.short_description = "Файл" + + +class PartnerProgramFieldValueInline(admin.TabularInline): + model = PartnerProgramFieldValue + extra = 0 + autocomplete_fields = ("field",) + readonly_fields = ("get_display_value",) + + def get_display_value(self, obj): + return obj.value_text or "-" + + get_display_value.short_description = "Значение" + + +@admin.register(PartnerProgramProject) +class PartnerProgramProjectAdmin(admin.ModelAdmin): + list_display = ( + "id", + "project", + "partner_program", + "datetime_created", + "submitted", + "datetime_submitted", + ) + list_filter = ("partner_program",) + search_fields = ("project__name", "partner_program__name") + inlines = [PartnerProgramFieldValueInline] + autocomplete_fields = ("project", "partner_program") + + +@admin.register(PartnerProgramField) +class PartnerProgramFieldAdmin(admin.ModelAdmin): + list_display = ( + "id", + "partner_program", + "name", + "label", + "field_type", + "is_required", + "show_filter", + ) + list_filter = ("partner_program",) + search_fields = ("name", "label", "help_text") diff --git a/partner_programs/migrations/0007_partnerprogrammaterial.py b/partner_programs/migrations/0007_partnerprogrammaterial.py new file mode 100644 index 00000000..d2649d0d --- /dev/null +++ b/partner_programs/migrations/0007_partnerprogrammaterial.py @@ -0,0 +1,72 @@ +# Generated by Django 4.2.11 on 2025-07-21 09:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0007_auto_20230929_1727"), + ("partner_programs", "0006_partnerprogram_projects_availability"), + ] + + operations = [ + migrations.CreateModel( + name="PartnerProgramMaterial", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "title", + models.CharField( + help_text="Например, 'Кейс Сбера'", + max_length=255, + verbose_name="Название материала", + ), + ), + ( + "url", + models.URLField( + blank=True, + help_text="Укажите ссылку вручную или прикрепите файл", + null=True, + verbose_name="Ссылка на материал", + ), + ), + ("datetime_created", models.DateTimeField(auto_now_add=True)), + ("datetime_updated", models.DateTimeField(auto_now=True)), + ( + "file", + models.ForeignKey( + blank=True, + help_text="Если указан файл, ссылка берётся из него", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="files.userfile", + verbose_name="Файл", + ), + ), + ( + "program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="materials", + to="partner_programs.partnerprogram", + verbose_name="Программа", + ), + ), + ], + options={ + "verbose_name": "Материал программы", + "verbose_name_plural": "Материалы программ", + }, + ), + ] diff --git a/partner_programs/migrations/0008_partnerprogram_managers_and_more.py b/partner_programs/migrations/0008_partnerprogram_managers_and_more.py new file mode 100644 index 00000000..1be902a9 --- /dev/null +++ b/partner_programs/migrations/0008_partnerprogram_managers_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.11 on 2025-07-22 08:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("partner_programs", "0007_partnerprogrammaterial"), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="managers", + field=models.ManyToManyField( + blank=True, + help_text="Пользователи, имеющие право создавать и редактировать новости", + related_name="managed_partner_programs", + to=settings.AUTH_USER_MODEL, + verbose_name="Менеджеры программы", + ), + ), + migrations.AlterField( + model_name="partnerprogrammaterial", + name="title", + field=models.CharField( + help_text="Укажите текст для гиперссылки", + max_length=255, + verbose_name="Название материала", + ), + ), + ] diff --git a/partner_programs/migrations/0009_partnerprogramfield_partnerprogramproject_and_more.py b/partner_programs/migrations/0009_partnerprogramfield_partnerprogramproject_and_more.py new file mode 100644 index 00000000..2ccdb299 --- /dev/null +++ b/partner_programs/migrations/0009_partnerprogramfield_partnerprogramproject_and_more.py @@ -0,0 +1,158 @@ +# Generated by Django 4.2.11 on 2025-07-28 07:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0007_auto_20230929_1727"), + ("projects", "0027_alter_defaultprojectcover_datetime_created_and_more"), + ("partner_programs", "0008_partnerprogram_managers_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="PartnerProgramField", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=128, verbose_name="Служебное имя")), + ( + "label", + models.CharField( + max_length=256, verbose_name="Отображаемое название" + ), + ), + ( + "field_type", + models.CharField( + choices=[ + ("text", "Однострочный текст"), + ("textarea", "Многострочный текст"), + ("checkbox", "Чекбокс"), + ("select", "Выпадающий список"), + ("radio", "Радио-кнопка"), + ("file", "Файл"), + ], + max_length=20, + ), + ), + ("is_required", models.BooleanField(default=False)), + ("help_text", models.TextField(blank=True, null=True)), + ("show_filter", models.BooleanField(default=False)), + ( + "options", + models.TextField( + blank=True, + help_text="Опции через | (для select/radio/checkbox)", + null=True, + ), + ), + ( + "partner_program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="fields", + to="partner_programs.partnerprogram", + ), + ), + ], + options={ + "verbose_name": "Дополнительное поле программы", + "verbose_name_plural": "Дополнительные поля программы", + "unique_together": {("partner_program", "name")}, + }, + ), + migrations.CreateModel( + name="PartnerProgramProject", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("datetime_created", models.DateTimeField(auto_now_add=True)), + ("datetime_updated", models.DateTimeField(auto_now=True)), + ( + "partner_program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="program_projects", + to="partner_programs.partnerprogram", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="program_links", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "Участие проекта в программе", + "verbose_name_plural": "Участия проекта в программах", + "unique_together": {("partner_program", "project")}, + }, + ), + migrations.CreateModel( + name="PartnerProgramFieldValue", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value_text", models.TextField(blank=True, null=True)), + ( + "field", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="values", + to="partner_programs.partnerprogramfield", + ), + ), + ( + "program_project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="field_values", + to="partner_programs.partnerprogramproject", + ), + ), + ( + "value_file", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="custom_field_usages", + to="files.userfile", + ), + ), + ], + options={ + "verbose_name": "Значение поля программы для проекта", + "verbose_name_plural": "Значения полей программы для проекта", + "unique_together": {("program_project", "field")}, + }, + ), + ] diff --git a/partner_programs/migrations/0010_alter_partnerprogramfield_options_and_more.py b/partner_programs/migrations/0010_alter_partnerprogramfield_options_and_more.py new file mode 100644 index 00000000..c188ef36 --- /dev/null +++ b/partner_programs/migrations/0010_alter_partnerprogramfield_options_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.11 on 2025-07-30 08:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0009_partnerprogramfield_partnerprogramproject_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="partnerprogramfield", + options={ + "verbose_name": "Дополнительное поле программы", + "verbose_name_plural": "Дополнительные поля программ", + }, + ), + migrations.AlterModelOptions( + name="partnerprogramproject", + options={ + "verbose_name": "Проект участующий в программе", + "verbose_name_plural": "Проекеты участвующие в программах", + }, + ), + migrations.RemoveField( + model_name="partnerprogramfieldvalue", + name="value_file", + ), + migrations.AlterField( + model_name="partnerprogramfieldvalue", + name="value_text", + field=models.TextField(default="Нет значения"), + preserve_default=False, + ), + migrations.AddIndex( + model_name="partnerprogramfieldvalue", + index=models.Index( + fields=["program_project"], name="partner_pro_program_8cb59d_idx" + ), + ), + migrations.AddIndex( + model_name="partnerprogramfieldvalue", + index=models.Index(fields=["field"], name="partner_pro_field_i_6a74d2_idx"), + ), + ] diff --git a/partner_programs/migrations/0011_partnerprogram_is_competitive_and_more.py b/partner_programs/migrations/0011_partnerprogram_is_competitive_and_more.py new file mode 100644 index 00000000..ba250673 --- /dev/null +++ b/partner_programs/migrations/0011_partnerprogram_is_competitive_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.11 on 2025-08-11 06:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0010_alter_partnerprogramfield_options_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="is_competitive", + field=models.BooleanField( + default=False, + help_text="Если включено, проекты участников подлежат сдаче на проверку", + verbose_name="Конкурсная программа", + ), + ), + migrations.AddField( + model_name="partnerprogramproject", + name="datetime_submitted", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Дата сдачи проекта" + ), + ), + migrations.AddField( + model_name="partnerprogramproject", + name="submitted", + field=models.BooleanField(default=False, verbose_name="Проект сдан"), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index cd7d3550..34eb2e3a 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -1,6 +1,8 @@ from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.db import models +from files.models import UserFile from partner_programs.constants import get_default_data_schema from projects.models import Project @@ -26,6 +28,7 @@ class PartnerProgram(models.Model): datetime_created: A DateTimeField indicating date of creation. datetime_updated: A DateTimeField indicating date of update. """ + PROJECTS_AVAILABILITY_CHOISES = [ ("all_users", "Всем пользователям"), ("experts_only", "Только экспертам"), @@ -42,6 +45,11 @@ class PartnerProgram(models.Model): blank=True, verbose_name="Описание", ) + is_competitive = models.BooleanField( + default=False, + verbose_name="Конкурсная программа", + help_text="Если включено, проекты участников подлежат сдаче на проверку", + ) city = models.TextField( verbose_name="Город", ) @@ -77,6 +85,13 @@ class PartnerProgram(models.Model): verbose_name="Участники программы", through="PartnerProgramUserProfile", ) + managers = models.ManyToManyField( + User, + related_name="managed_partner_programs", + blank=True, + verbose_name="Менеджеры программы", + help_text="Пользователи, имеющие право создавать и редактировать новости", + ) draft = models.BooleanField(blank=False, default=True) projects_availability = models.CharField( choices=PROJECTS_AVAILABILITY_CHOISES, @@ -96,7 +111,17 @@ class PartnerProgram(models.Model): datetime_created = models.DateTimeField( verbose_name="Дата создания", auto_now_add=True ) - datetime_updated = models.DateTimeField(verbose_name="Дата изменения", auto_now=True) + datetime_updated = models.DateTimeField( + verbose_name="Дата изменения", auto_now=True + ) + + def is_manager(self, user: User) -> bool: + """ + Возвращает True, если пользователь — менеджер этой программы. + """ + if not user or not user.is_authenticated: + return False + return self.managers.filter(pk=user.pk).exists() class Meta: verbose_name = "Программа" @@ -148,3 +173,171 @@ class Meta: def __str__(self): return f"PartnerProgramUserProfile<{self.pk}> - {self.user} {self.project} {self.partner_program}" + + +class PartnerProgramMaterial(models.Model): + """ + Материал для программы: прямая ссылка или ссылка на прикреплённый файл. + """ + + program = models.ForeignKey( + PartnerProgram, + on_delete=models.CASCADE, + related_name="materials", + verbose_name="Программа", + ) + + title = models.CharField( + max_length=255, + verbose_name="Название материала", + help_text="Укажите текст для гиперссылки", + ) + + url = models.URLField( + blank=True, + null=True, + verbose_name="Ссылка на материал", + help_text="Укажите ссылку вручную или прикрепите файл", + ) + + file = models.ForeignKey( + UserFile, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name="Файл", + help_text="Если указан файл, ссылка берётся из него", + ) + + datetime_created = models.DateTimeField(auto_now_add=True) + datetime_updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Материал программы" + verbose_name_plural = "Материалы программ" + + def clean(self): + if self.file and not self.url: + self.url = self.file.link + + if not self.file and not self.url: + raise ValidationError("Необходимо указать либо файл, либо ссылку.") + + if self.file and self.url and self.url != self.file.link: + raise ValidationError("Укажите либо файл, либо ссылку, но не оба сразу.") + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.title} для программы {self.program.name}" + + +class PartnerProgramProject(models.Model): + partner_program = models.ForeignKey( + PartnerProgram, on_delete=models.CASCADE, related_name="program_projects" + ) + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name="program_links" + ) + datetime_created = models.DateTimeField(auto_now_add=True) + datetime_updated = models.DateTimeField(auto_now=True) + submitted = models.BooleanField(default=False, verbose_name="Проект сдан") + datetime_submitted = models.DateTimeField( + null=True, blank=True, verbose_name="Дата сдачи проекта" + ) + + def can_edit(self, user: User) -> bool: + if not user or not user.is_authenticated: + return False + + if user.is_superuser or user.is_staff: + return True + + if self.project.leader_id == user.id: + return not self.submitted + + return False + + class Meta: + unique_together = ("partner_program", "project") + verbose_name = "Проект участующий в программе" + verbose_name_plural = "Проекеты участвующие в программах" + + def __str__(self): + return f"{self.project} в программе {self.partner_program}" + + +class PartnerProgramField(models.Model): + FIELD_TYPES = [ + ("text", "Однострочный текст"), + ("textarea", "Многострочный текст"), + ("checkbox", "Чекбокс"), + ("select", "Выпадающий список"), + ("radio", "Радио-кнопка"), + ("file", "Файл"), + ] + + partner_program = models.ForeignKey( + PartnerProgram, on_delete=models.CASCADE, related_name="fields" + ) + name = models.CharField(max_length=128, verbose_name="Служебное имя") + label = models.CharField(max_length=256, verbose_name="Отображаемое название") + field_type = models.CharField(max_length=20, choices=FIELD_TYPES) + is_required = models.BooleanField(default=False) + help_text = models.TextField(blank=True, null=True) + show_filter = models.BooleanField(default=False) + options = models.TextField( + blank=True, null=True, help_text="Опции через | (для select/radio/checkbox)" + ) + + def __str__(self): + return f"{self.partner_program.name} — {self.label} ({self.name})" + + class Meta: + verbose_name = "Дополнительное поле программы" + verbose_name_plural = "Дополнительные поля программ" + unique_together = ("partner_program", "name") + + def get_options_list(self) -> list[str]: + opts = self.options.split("|") if self.options else [] + return [opt.strip() for opt in opts if opt.strip()] + + +class PartnerProgramFieldValue(models.Model): + program_project = models.ForeignKey( + PartnerProgramProject, on_delete=models.CASCADE, related_name="field_values" + ) + field = models.ForeignKey( + PartnerProgramField, on_delete=models.CASCADE, related_name="values" + ) + value_text = models.TextField(blank=False) + + class Meta: + unique_together = ("program_project", "field") + verbose_name = "Значение поля программы для проекта" + verbose_name_plural = "Значения полей программы для проекта" + indexes = [ + models.Index(fields=["program_project"]), + models.Index(fields=["field"]), + ] + + def __str__(self): + return f"{self.field}: {self.value_text[:50]}" + + def get_value(self): + return self.value_text + + def clean(self): + if ( + self.program_project.partner_program.is_competitive + and self.program_project.submitted + ): + raise ValidationError( + "Нельзя изменять значения полей программы после сдачи проекта на проверку." + ) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) diff --git a/partner_programs/permissions.py b/partner_programs/permissions.py new file mode 100644 index 00000000..b96037df --- /dev/null +++ b/partner_programs/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class IsProjectLeader(BasePermission): + def has_object_permission(self, request, view, obj): + return obj.project.leader == request.user diff --git a/partner_programs/serializers.py b/partner_programs/serializers.py index a623f4dd..43e2806f 100644 --- a/partner_programs/serializers.py +++ b/partner_programs/serializers.py @@ -2,7 +2,12 @@ from rest_framework import serializers from core.services import get_likes_count, get_links, get_views_count, is_fan -from partner_programs.models import PartnerProgram +from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramMaterial, +) User = get_user_model() @@ -49,11 +54,35 @@ class Meta: ) -class PartnerProgramForMemberSerializer(serializers.ModelSerializer): +class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer): + """ + Базовый миксин для сериализаторов PartnerProgram, + включает общие поля: materials и is_user_manager. + """ + + materials = serializers.SerializerMethodField() + is_user_manager = serializers.SerializerMethodField() + + def get_materials(self, program: PartnerProgram): + materials = program.materials.all() + return PartnerProgramMaterialSerializer(materials, many=True).data + + def get_is_user_manager(self, program: PartnerProgram) -> bool: + user = self.context.get("user") + return bool(user and program.is_manager(user)) + + class Meta: + abstract = True + + +class PartnerProgramForMemberSerializer(PartnerProgramBaseSerializerMixin): """Serializer for PartnerProgram model for member of this program""" views_count = serializers.SerializerMethodField(method_name="count_views") links = serializers.SerializerMethodField(method_name="get_links") + is_user_manager = serializers.SerializerMethodField( + method_name="get_is_user_manager" + ) def count_views(self, program): return get_views_count(program) @@ -79,15 +108,17 @@ class Meta: "description", "city", "links", + "materials", "image_address", "cover_image_address", "presentation_address", "views_count", "datetime_registration_ends", + "is_user_manager", ) -class PartnerProgramForUnregisteredUserSerializer(serializers.ModelSerializer): +class PartnerProgramForUnregisteredUserSerializer(PartnerProgramBaseSerializerMixin): """Serializer for PartnerProgram model for unregistered users in the program""" class Meta: @@ -97,11 +128,13 @@ class Meta: "name", "tag", "city", + "materials", "image_address", "cover_image_address", "advertisement_image_address", "presentation_address", "datetime_registration_ends", + "is_user_manager", ) @@ -138,3 +171,93 @@ class Meta: "name", "tag", ] + + +class PartnerProgramMaterialSerializer(serializers.ModelSerializer): + class Meta: + model = PartnerProgramMaterial + fields = ("title", "url") + + +class PartnerProgramFieldValueSerializer(serializers.ModelSerializer): + field_name = serializers.CharField(source="field.name") + value = serializers.SerializerMethodField() + + class Meta: + model = PartnerProgramFieldValue + fields = [ + "field_name", + "value", + ] + + def get_value(self, obj): + if obj.field.field_type == "file": + return obj.value_file.link if obj.value_file else None + return obj.value_text + + +class PartnerProgramFieldSerializer(serializers.ModelSerializer): + options = serializers.SerializerMethodField() + + class Meta: + model = PartnerProgramField + fields = [ + "id", + "name", + "label", + "field_type", + "is_required", + "show_filter", + "help_text", + "options", + ] + + def get_options(self, obj): + return obj.get_options_list() + + +class ProgramProjectFilterRequestSerializer(serializers.Serializer): + filters = serializers.DictField( + child=serializers.ListField(child=serializers.CharField()), + required=False, + help_text="Словарь: ключ = PartnerProgramField.name, значение = список выбранных опций", + ) + page = serializers.IntegerField(required=False, default=1, min_value=1) + page_size = serializers.IntegerField( + required=False, default=20, min_value=1, max_value=200 + ) + MAX_FILTERS = 3 + + def validate_filters(self, value): + if not isinstance(value, dict): + raise serializers.ValidationError( + "Поле filters должно быть объектом (словарём ключ-значение)" + ) + + if len(value) > self.MAX_FILTERS: + raise serializers.ValidationError( + f"Можно передать не более {self.MAX_FILTERS} фильтров." + ) + + cleaned: dict = {} + for key, raw_values in value.items(): + if not isinstance(key, str) or not key.strip(): + raise serializers.ValidationError( + f"Ключи фильтров должны быть непустыми строками. Некорректный ключ: {key}" + ) + + if isinstance(raw_values, list): + normalized_values = [ + str(item).strip() for item in raw_values if str(item).strip() != "" + ] + else: + normalized_values = ( + [str(raw_values).strip()] if str(raw_values).strip() != "" else [] + ) + + if not normalized_values: + continue + + cleaned[key.strip()] = normalized_values + + return cleaned diff --git a/partner_programs/tests.py b/partner_programs/tests.py new file mode 100644 index 00000000..14eb4d4b --- /dev/null +++ b/partner_programs/tests.py @@ -0,0 +1,213 @@ +from django.test import TestCase +from django.utils import timezone + +from partner_programs.models import PartnerProgram, PartnerProgramField +from projects.serializers import PartnerProgramFieldValueUpdateSerializer + + +class PartnerProgramFieldValueUpdateSerializerInvalidTests(TestCase): + def setUp(self): + now = timezone.now() + self.partner_program = PartnerProgram.objects.create( + name="Тестовая программа", + tag="test_tag", + description="Описание тестовой программы", + city="Москва", + image_address="https://example.com/image.png", + cover_image_address="https://example.com/cover.png", + advertisement_image_address="https://example.com/advertisement.png", + presentation_address="https://example.com/presentation.pdf", + data_schema={}, + draft=True, + projects_availability="all_users", + datetime_registration_ends=now + timezone.timedelta(days=30), + datetime_started=now, + datetime_finished=now + timezone.timedelta(days=60), + ) + + def make_field(self, field_type, is_required, options=None): + return PartnerProgramField.objects.create( + partner_program=self.partner_program, + name="test_field", + label="Test Field", + field_type=field_type, + is_required=is_required, + options="|".join(options) if options else "", + ) + + def test_required_text_field_empty(self): + field = self.make_field("text", is_required=True) + data = {"field_id": field.id, "value_text": ""} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Поле должно содержать текстовое значение.", str(serializer.errors) + ) + + def test_required_textarea_field_null(self): + field = self.make_field("textarea", is_required=True) + data = {"field_id": field.id, "value_text": None} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Поле должно содержать текстовое значение.", str(serializer.errors) + ) + + def test_checkbox_invalid_string(self): + field = self.make_field("checkbox", is_required=True) + data = {"field_id": field.id, "value_text": "maybe"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("ожидается 'true' или 'false'", str(serializer.errors).lower()) + + def test_checkbox_invalid_type(self): + field = self.make_field("checkbox", is_required=True) + data = {"field_id": field.id, "value_text": 1} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("ожидается 'true' или 'false'", str(serializer.errors).lower()) + + def test_select_invalid_choice(self): + field = self.make_field("select", is_required=True, options=["арбуз", "ананас"]) + data = {"field_id": field.id, "value_text": "яблоко"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Недопустимое значение для поля типа 'select'", str(serializer.errors) + ) + + def test_select_required_empty(self): + field = self.make_field("select", is_required=True, options=["арбуз", "ананас"]) + data = {"field_id": field.id, "value_text": ""} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Значение обязательно для поля типа 'select'", str(serializer.errors) + ) + + def test_radio_invalid_type(self): + field = self.make_field("radio", is_required=True, options=["арбуз", "ананас"]) + data = {"field_id": field.id, "value_text": ["арбуз"]} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("Not a valid string.", str(serializer.errors)) + + def test_radio_invalid_value(self): + field = self.make_field("radio", is_required=True, options=["арбуз", "ананас"]) + data = {"field_id": field.id, "value_text": "груша"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Недопустимое значение для поля типа 'radio'", str(serializer.errors) + ) + + def test_file_invalid_type(self): + field = self.make_field("file", is_required=True) + data = {"field_id": field.id, "value_text": 123} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Ожидается корректная ссылка (URL) на файл.", str(serializer.errors) + ) + + def test_file_empty_required(self): + field = self.make_field("file", is_required=True) + data = {"field_id": field.id, "value_text": ""} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("Файл обязателен для этого поля.", str(serializer.errors)) + + +class PartnerProgramFieldValueUpdateSerializerValidTests(TestCase): + def setUp(self): + now = timezone.now() + self.partner_program = PartnerProgram.objects.create( + name="Тестовая программа", + tag="test_tag", + description="Описание тестовой программы", + city="Москва", + image_address="https://example.com/image.png", + cover_image_address="https://example.com/cover.png", + advertisement_image_address="https://example.com/advertisement.png", + presentation_address="https://example.com/presentation.pdf", + data_schema={}, + draft=True, + projects_availability="all_users", + datetime_registration_ends=now + timezone.timedelta(days=30), + datetime_started=now, + datetime_finished=now + timezone.timedelta(days=60), + ) + + def make_field(self, field_type, is_required, options=None): + return PartnerProgramField.objects.create( + partner_program=self.partner_program, + name="test_field", + label="Test Field", + field_type=field_type, + is_required=is_required, + options="|".join(options) if options else "", + ) + + def test_optional_text_field_valid(self): + field = self.make_field("text", is_required=False) + data = {"field_id": field.id, "value_text": "some value"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_required_text_field_valid(self): + field = self.make_field("text", is_required=True) + data = {"field_id": field.id, "value_text": "not empty"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_optional_textarea_valid(self): + field = self.make_field("textarea", is_required=False) + data = {"field_id": field.id, "value_text": "optional long text"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_required_textarea_valid(self): + field = self.make_field("textarea", is_required=True) + data = {"field_id": field.id, "value_text": "required long text"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_checkbox_true_valid(self): + field = self.make_field("checkbox", is_required=True) + data = {"field_id": field.id, "value_text": "true"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_checkbox_false_valid(self): + field = self.make_field("checkbox", is_required=False) + data = {"field_id": field.id, "value_text": "false"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_select_valid(self): + field = self.make_field("select", is_required=True, options=["арбуз", "ананас"]) + data = {"field_id": field.id, "value_text": "ананас"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_radio_valid(self): + field = self.make_field( + "radio", is_required=True, options=["арбуз", "апельсин"] + ) + data = {"field_id": field.id, "value_text": "апельсин"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_optional_select_empty_valid(self): + field = self.make_field( + "select", is_required=False, options=["арбуз", "апельсин"] + ) + data = {"field_id": field.id, "value_text": ""} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_file_valid_url(self): + field = self.make_field("file", is_required=True) + data = {"field_id": field.id, "value_text": "https://example.com/file.pdf"} + serializer = PartnerProgramFieldValueUpdateSerializer(data=data) + self.assertTrue(serializer.is_valid()) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index ce64dbdb..3a361024 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -1,14 +1,17 @@ from django.urls import path -from news.views import NewsList, NewsDetail, NewsDetailSetViewed, NewsDetailSetLiked +from news.views import NewsDetail, NewsDetailSetLiked, NewsDetailSetViewed, NewsList from partner_programs.views import ( - PartnerProgramList, - PartnerProgramDetail, PartnerProgramCreateUserAndRegister, - PartnerProgramRegister, PartnerProgramDataSchema, + PartnerProgramDetail, + PartnerProgramList, + PartnerProgramProjectSubmitView, + PartnerProgramRegister, PartnerProgramSetLiked, PartnerProgramSetViewed, + ProgramFiltersAPIView, + ProgramProjectFilterAPIView, ) app_name = "partner_programs" @@ -16,6 +19,11 @@ urlpatterns = [ path("", PartnerProgramList.as_view()), path("/", PartnerProgramDetail.as_view()), + path( + "partner-program-projects//submit/", + PartnerProgramProjectSubmitView.as_view(), + name="partner-program-project-submit", + ), path("/schema/", PartnerProgramDataSchema.as_view()), path("/register/", PartnerProgramRegister.as_view()), path("/register_new/", PartnerProgramCreateUserAndRegister.as_view()), @@ -24,9 +32,16 @@ path("/news/", NewsList.as_view()), path("/news//", NewsDetail.as_view()), path( - "/news//set_viewed/", NewsDetailSetViewed.as_view() + "/news//set_viewed/", + NewsDetailSetViewed.as_view(), ), path( "/news//set_liked/", NewsDetailSetLiked.as_view() ), + path("/filters/", ProgramFiltersAPIView.as_view(), name="program-filters"), + path( + "/projects/filter/", + ProgramProjectFilterAPIView.as_view(), + name="program-projects-filter", + ), ] diff --git a/partner_programs/utils.py b/partner_programs/utils.py new file mode 100644 index 00000000..a6288e9b --- /dev/null +++ b/partner_programs/utils.py @@ -0,0 +1,43 @@ +from typing import Dict, List + +from django.db.models import Exists, OuterRef + +from .models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, +) + + +def filter_program_projects_by_field_name( + program: PartnerProgram, filters: Dict[str, List[str]] +): + """ + filters: {"field_name": ["val1", "val2"], ...} + Возвращает queryset PartnerProgramProject, отфильтрованный по условиям. + Ключи MUST быть field.name (строки). Иначе — ошибка должна быть выброшена на уровне вьюхи. + """ + qs = PartnerProgramProject.objects.filter(partner_program=program) + + if not filters: + return qs.select_related("project").distinct() + + for field_name, values in filters.items(): + if not isinstance(field_name, str) or not field_name.strip(): + raise ValueError("Не правильное имя поля") + + field_name = field_name.strip() + + field_obj = PartnerProgramField.objects.filter( + partner_program=program, name=field_name + ).first() + if not field_obj: + raise ValueError(f"Поле {field_name} не найдено в программе с id {program.pk}") + + subq = PartnerProgramFieldValue.objects.filter( + program_project=OuterRef("pk"), field=field_obj, value_text__in=values + ) + qs = qs.filter(Exists(subq)) + + return qs.select_related("project").distinct() diff --git a/partner_programs/views.py b/partner_programs/views.py index 9db6f67d..993a1c6d 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -1,22 +1,44 @@ from django.contrib.auth import get_user_model -from django.db import IntegrityError +from django.db import IntegrityError, transaction +from django.shortcuts import get_object_or_404 from django.utils import timezone +from django.utils.timezone import now +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, permissions, status +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError +from rest_framework.generics import GenericAPIView from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response +from rest_framework.views import APIView from core.serializers import SetLikedSerializer, SetViewedSerializer from core.services import add_view, set_like from partner_programs.helpers import date_to_iso -from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, + PartnerProgramUserProfile, +) from partner_programs.pagination import PartnerProgramPagination +from partner_programs.permissions import IsProjectLeader from partner_programs.serializers import ( PartnerProgramDataSchemaSerializer, + PartnerProgramFieldSerializer, PartnerProgramForMemberSerializer, PartnerProgramForUnregisteredUserSerializer, PartnerProgramListSerializer, PartnerProgramNewUserSerializer, PartnerProgramUserSerializer, + ProgramProjectFilterRequestSerializer, +) +from partner_programs.utils import filter_program_projects_by_field_name +from projects.models import Project +from projects.serializers import ( + PartnerProgramFieldValueUpdateSerializer, + ProjectListSerializer, ) from vacancy.mapping import ( MessageTypeEnum, @@ -35,27 +57,25 @@ class PartnerProgramList(generics.ListCreateAPIView): class PartnerProgramDetail(generics.RetrieveAPIView): - queryset = PartnerProgram.objects.all() + queryset = PartnerProgram.objects.prefetch_related("materials", "managers").all() permission_classes = [permissions.IsAuthenticatedOrReadOnly] - serializer_class = PartnerProgramForUnregisteredUserSerializer def get(self, request, *args, **kwargs): - try: - program = self.get_object() - is_user_member = program.users.filter(pk=request.user.pk).exists() - serializer_class = ( - PartnerProgramForMemberSerializer - if is_user_member - else PartnerProgramForUnregisteredUserSerializer - ) - data = serializer_class(program).data - data["is_user_member"] = is_user_member - if request.user.is_authenticated: - add_view(program, request.user) - - return Response(data=data, status=status.HTTP_200_OK) - except PartnerProgram.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) + program = self.get_object() + is_user_member = program.users.filter(pk=request.user.pk).exists() + serializer_class = ( + PartnerProgramForMemberSerializer + if is_user_member + else PartnerProgramForUnregisteredUserSerializer + ) + serializer = serializer_class( + program, context={"request": request, "user": request.user} + ) + data = serializer.data + data["is_user_member"] = is_user_member + if request.user.is_authenticated: + add_view(program, request.user) + return Response(data, status=status.HTTP_200_OK) class PartnerProgramCreateUserAndRegister(generics.GenericAPIView): @@ -223,3 +243,184 @@ class PartnerProgramDataSchema(generics.RetrieveAPIView): queryset = PartnerProgram.objects.all() serializer_class = PartnerProgramDataSchemaSerializer permission_classes = [permissions.IsAuthenticated] + + +class PartnerProgramFieldValueBulkUpdateView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = PartnerProgramFieldValueUpdateSerializer + + def get_project(self, project_id): + try: + return Project.objects.get(id=project_id) + except Project.DoesNotExist: + raise NotFound("Проект не найден") + + @swagger_auto_schema( + request_body=PartnerProgramFieldValueUpdateSerializer(many=True) + ) + def put(self, request, project_id, *args, **kwargs): + project = self.get_project(project_id) + + if project.leader != request.user: + raise PermissionDenied("Вы не являетесь лидером этого проекта") + + try: + program_project = PartnerProgramProject.objects.select_related( + "partner_program" + ).get(project=project) + except PartnerProgramProject.DoesNotExist: + raise ValidationError("Проект не привязан ни к одной программе") + + partner_program = program_project.partner_program + + if partner_program.is_competitive and program_project.submitted: + raise ValidationError( + "Нельзя изменять значения полей программы после сдачи проекта на проверку." + ) + + serializer = self.serializer_class(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + for item in serializer.validated_data: + field = item["field"] + + if field.partner_program_id != partner_program.id: + raise ValidationError( + f"Поле с id={field.id} не относится к программе этого проекта" + ) + + value_text = item.get("value_text") + + obj, created = PartnerProgramFieldValue.objects.update_or_create( + program_project=program_project, + field=field, + defaults={"value_text": value_text}, + ) + + if created: + try: + obj.full_clean() + except ValidationError as e: + raise ValidationError(e.message_dict) + + return Response( + {"detail": "Значения успешно обновлены"}, + status=status.HTTP_200_OK, + ) + + +class PartnerProgramProjectSubmitView(GenericAPIView): + permission_classes = [IsAuthenticated, IsProjectLeader] + serializer_class = None + queryset = PartnerProgramProject.objects.all() + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + name="id", + in_=openapi.IN_PATH, + description="Уникальный идентификатор связи проекта и программы", + type=openapi.TYPE_INTEGER, + required=True, + ), + ] + ) + def post(self, request, pk, *args, **kwargs): + program_project = self.get_object() + + if not program_project.partner_program.is_competitive: + return Response( + {"detail": "Программа не является конкурсной."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if program_project.submitted: + return Response( + {"detail": "Проект уже был сдан на проверку."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + program_project.submitted = True + program_project.datetime_submitted = now() + program_project.save() + + return Response( + {"detail": "Проект успешно сдан на проверку."}, + status=status.HTTP_200_OK, + ) + + +class ProgramFiltersAPIView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, pk): + program = get_object_or_404(PartnerProgram, pk=pk) + fields = PartnerProgramField.objects.filter( + partner_program=program, show_filter=True + ) + serializer = PartnerProgramFieldSerializer(fields, many=True) + return Response(serializer.data) + + +class ProgramProjectFilterAPIView(GenericAPIView): + serializer_class = ProgramProjectFilterRequestSerializer + permission_classes = [permissions.IsAuthenticated] + pagination_class = PartnerProgramPagination + + def post(self, request, pk): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + program = get_object_or_404(PartnerProgram, pk=pk) + filters = data.get("filters", {}) + + field_names = list(filters.keys()) + field_qs = PartnerProgramField.objects.filter( + partner_program=program, name__in=field_names + ) + field_by_name = {f.name: f for f in field_qs} + + missing = [name for name in field_names if name not in field_by_name] + if missing: + return Response( + {"detail": f"Поля не найденные в программе: {missing}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + for field_name, values in filters.items(): + field_obj = field_by_name[field_name] + if not field_obj.show_filter: + return Response( + { + "detail": f"Поле '{field_name}' недоступно для фильтрации (show_filter=False)." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + opts = field_obj.get_options_list() + if opts: + invalid_values = [val for val in values if val not in opts] + if invalid_values: + return Response( + { + "detail": f"Неверные значения для поля '{field_name}'.", + "invalid": invalid_values, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return Response( + {"detail": f"Поле '{field_name}' не имеет вариантов (options)."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + qs = filter_program_projects_by_field_name(program, filters) + + paginator = self.pagination_class() + page = paginator.paginate_queryset(qs, request, view=self) + projects = [pp.project for pp in page] + serializer_out = ProjectListSerializer( + projects, many=True, context={"request": request} + ) + return paginator.get_paginated_response(serializer_out.data) diff --git a/projects/filters.py b/projects/filters.py index 2b7c771a..3d1f36c6 100644 --- a/projects/filters.py +++ b/projects/filters.py @@ -1,10 +1,10 @@ -from django.db.models import Count +from django.db.models import Count, Q from django_filters import rest_framework as filters -from users.models import Expert -from partner_programs.models import PartnerProgram, PartnerProgramUserProfile -from projects.models import Project +from partner_programs.models import PartnerProgram from project_rates.models import ProjectScore +from projects.models import Project +from users.models import Expert class ProjectFilter(filters.FilterSet): @@ -56,33 +56,30 @@ def vacancy_filter(cls, queryset, name, value): return queryset def filter_by_partner_program(self, queryset, name, value): - program_id = value + if str(value) == "0": + return queryset.filter( + partner_program_profiles__isnull=True, + program_links__isnull=True, + ).distinct() user = self.request.user - try: - program = PartnerProgram.objects.get(pk=program_id) - program_status = program.projects_availability - # If available to all users or request.user is an expert of this program. - if ( - program_status == "all_users" - or Expert.objects.filter(user=user, programs=program).exists() - ): - profiles_qs = ( - PartnerProgramUserProfile.objects.filter( - partner_program=program, project__isnull=False - ) - .select_related("project") - .only("project") - ) - return queryset.filter( - pk__in=[profile.project.pk for profile in profiles_qs] - ) - else: - return Project.objects.none() + try: + program = PartnerProgram.objects.get(pk=value) except PartnerProgram.DoesNotExist: return Project.objects.none() + program_status = program.projects_availability + user_is_expert = Expert.objects.filter(user=user, programs=program).exists() + + if program_status != "all_users" and not user_is_expert: + return Project.objects.none() + + return queryset.filter( + Q(partner_program_profiles__partner_program=program) + | Q(program_links__partner_program=program) + ).distinct() + def filter_by_have_expert_rates(self, queryset, name, value): rated_projects_ids = ProjectScore.objects.values_list( "project_id", flat=True @@ -107,7 +104,9 @@ def filter_by_have_expert_rates(self, queryset, name, value): ) # filters by whether there are any vacancies in the project - any_vacancies = filters.BooleanFilter(field_name="vacancies", method="vacancy_filter") + any_vacancies = filters.BooleanFilter( + field_name="vacancies", method="vacancy_filter" + ) collaborator__count__gte = filters.NumberFilter( field_name="collaborator", method="filter_collaborator_count_gte" ) @@ -120,7 +119,9 @@ def filter_by_have_expert_rates(self, queryset, name, value): ) is_rated_by_expert = filters.BooleanFilter( method="filter_by_have_expert_rates", - label=("is_rated_by_expert\n`1`/`true` rated projects\n`0`/`false` dosn't rated"), + label=( + "is_rated_by_expert\n`1`/`true` rated projects\n`0`/`false` dosn't rated" + ), ) class Meta: diff --git a/projects/serializers.py b/projects/serializers.py index 141e9f79..e2d300be 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -1,3 +1,5 @@ +from urllib.parse import urlparse + from django.contrib.auth import get_user_model from django.core.cache import cache from rest_framework import serializers @@ -7,6 +9,16 @@ from core.utils import get_user_online_cache_key from files.serializers import UserFileSerializer from industries.models import Industry +from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, +) +from partner_programs.serializers import ( + PartnerProgramFieldSerializer, + PartnerProgramFieldValueSerializer, +) from projects.models import Achievement, Collaborator, Project, ProjectNews from projects.validators import validate_project from vacancy.serializers import ProjectVacancyListSerializer @@ -64,6 +76,39 @@ class Meta: fields = ["collaborators"] +class PartnerProgramProjectSerializer(serializers.ModelSerializer): + program_link_id = serializers.IntegerField(source="pk", read_only=True) + program_id = serializers.IntegerField(source="partner_program.id", read_only=True) + is_submitted = serializers.BooleanField(source="submitted", read_only=True) + can_submit = serializers.SerializerMethodField() + program_fields = serializers.SerializerMethodField() + program_field_values = serializers.SerializerMethodField() + + class Meta: + model = PartnerProgramProject + fields = [ + "program_link_id", + "program_id", + "is_submitted", + "can_submit", + "program_fields", + "program_field_values", + ] + + def get_can_submit(self, obj): + return obj.partner_program.is_competitive and not obj.submitted + + def get_program_fields(self, obj): + fields_qs = obj.partner_program.fields.all() + return PartnerProgramFieldSerializer(fields_qs, many=True).data + + def get_program_field_values(self, obj): + values_qs = PartnerProgramFieldValue.objects.filter( + program_project=obj + ).select_related("field") + return PartnerProgramFieldValueSerializer(values_qs, many=True).data + + class ProjectDetailSerializer(serializers.ModelSerializer): achievements = AchievementListSerializer(many=True, read_only=True) cover = UserFileSerializer(required=False) @@ -75,15 +120,23 @@ class ProjectDetailSerializer(serializers.ModelSerializer): industry_id = serializers.IntegerField(required=False) views_count = serializers.SerializerMethodField(method_name="count_views") links = serializers.SerializerMethodField() - partner_programs_tags = serializers.SerializerMethodField() + partner_program = serializers.SerializerMethodField() + partner_program_tags = serializers.SerializerMethodField() track = serializers.CharField(required=False, allow_null=True, allow_blank=True) direction = serializers.CharField(required=False, allow_null=True, allow_blank=True) actuality = serializers.CharField(required=False, allow_null=True, allow_blank=True) goal = serializers.CharField(required=False, allow_null=True, allow_blank=True) problem = serializers.CharField(required=False, allow_null=True, allow_blank=True) + def get_partner_program(self, project): + try: + link = project.program_links.select_related("partner_program").get() + return PartnerProgramProjectSerializer(link).data + except PartnerProgramProject.DoesNotExist: + return None + @classmethod - def get_partner_programs_tags(cls, project): + def get_partner_program_tags(cls, project): profiles_qs = project.partner_program_profiles.select_related( "partner_program" ).only("partner_program__tag") @@ -134,12 +187,13 @@ class Meta: "views_count", "cover", "cover_image_address", - "partner_programs_tags", "track", "direction", "actuality", "goal", "problem", + "partner_program_tags", + "partner_program", ] read_only_fields = [ "leader", @@ -314,3 +368,169 @@ class Meta: "avatar", "is_online", ] + + +class ProjectDuplicateRequestSerializer(serializers.Serializer): + project_id = serializers.IntegerField() + partner_program_id = serializers.IntegerField() + + def validate(self, data): + project_id = data["project_id"] + partner_program_id = data["partner_program_id"] + request = self.context["request"] + + try: + project = Project.objects.get(pk=project_id) + except Project.DoesNotExist: + raise serializers.ValidationError("Проект с указанным ID не найден.") + + if project.leader != request.user: + raise serializers.ValidationError( + "Только лидер проекта может дублировать его в программу." + ) + + try: + partner_program = PartnerProgram.objects.get(pk=partner_program_id) + except PartnerProgram.DoesNotExist: + raise serializers.ValidationError( + "Партнёрская программа с указанным ID не найдена." + ) + + exists = PartnerProgramProject.objects.filter( + project__name=project.name, partner_program=partner_program + ).exists() + + if exists: + raise serializers.ValidationError( + f"Проект с именем '{project.name}' уже привязан к партнёрской программе '{partner_program.name}'." + ) + + 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 diff --git a/projects/urls.py b/projects/urls.py index 835ca5fe..636f8ca7 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -1,22 +1,24 @@ from django.urls import path -from news.views import NewsList, NewsDetail, NewsDetailSetLiked, NewsDetailSetViewed +from news.views import NewsDetail, NewsDetailSetLiked, NewsDetailSetViewed, NewsList +from partner_programs.views import PartnerProgramFieldValueBulkUpdateView from projects.views import ( - ProjectDetail, - ProjectList, - ProjectSteps, - AchievementList, AchievementDetail, + AchievementList, + DuplicateProjectView, + LeaveProject, ProjectCollaborators, ProjectCountView, - ProjectVacancyResponses, + ProjectDetail, + ProjectList, ProjectRecommendedUsers, - SetLikeOnProject, + ProjectSteps, ProjectSubscribe, - ProjectUnsubscribe, ProjectSubscribers, + ProjectUnsubscribe, + ProjectVacancyResponses, + SetLikeOnProject, SwitchLeaderRole, - LeaveProject, ) app_name = "projects" @@ -39,6 +41,14 @@ ), path("/", ProjectDetail.as_view()), path("/recommended_users", ProjectRecommendedUsers.as_view()), + path( + "assign-to-program/", DuplicateProjectView.as_view(), name="duplicate-project" + ), + path( + "/program-fields/", + PartnerProgramFieldValueBulkUpdateView.as_view(), + name="update_program_fields", + ), path("count/", ProjectCountView.as_view()), path("steps/", ProjectSteps.as_view()), path("achievements/", AchievementList.as_view()), diff --git a/projects/views.py b/projects/views.py index c7e9a8c8..bc330f9e 100644 --- a/projects/views.py +++ b/projects/views.py @@ -3,8 +3,9 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist -from django.shortcuts import get_object_or_404 +from django.db import transaction from django.db.models import Q, QuerySet +from django.shortcuts import get_object_or_404 from django_filters import rest_framework as filters from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema @@ -17,32 +18,37 @@ from core.permissions import IsStaffOrReadOnly from core.serializers import SetLikedSerializer from core.services import add_view, set_like -from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from projects.constants import VERBOSE_STEPS from projects.exceptions import CollaboratorDoesNotExist from projects.filters import ProjectFilter -from projects.constants import VERBOSE_STEPS from projects.helpers import ( - get_recommended_users, check_related_fields_update, + get_recommended_users, update_partner_program, ) -from projects.models import Project, Achievement, ProjectNews, Collaborator +from projects.models import Achievement, Collaborator, Project, ProjectNews from projects.pagination import ProjectNewsPagination, ProjectsPagination from projects.permissions import ( - IsProjectLeaderOrReadOnlyForNonDrafts, HasInvolvementInProjectOrReadOnly, - IsProjectLeader, IsNewsAuthorIsProjectLeaderOrReadOnly, + IsProjectLeader, + IsProjectLeaderOrReadOnlyForNonDrafts, TimingAfterEndsProgramPermission, ) from projects.serializers import ( - ProjectDetailSerializer, - AchievementListSerializer, - ProjectListSerializer, AchievementDetailSerializer, + AchievementListSerializer, ProjectCollaboratorSerializer, - ProjectNewsListSerializer, + ProjectDetailSerializer, + ProjectDuplicateRequestSerializer, + ProjectListSerializer, ProjectNewsDetailSerializer, + ProjectNewsListSerializer, ProjectSubscribersListSerializer, ) from users.models import LikesOnProject @@ -82,7 +88,9 @@ def create(self, request, *args, **kwargs): try: partner_program_id = request.data.get("partner_program_id") - update_partner_program(partner_program_id, request.user, serializer.instance) + update_partner_program( + partner_program_id, request.user, serializer.instance + ) except PartnerProgram.DoesNotExist: return Response( {"detail": "Partner program with this id does not exist"}, @@ -95,7 +103,9 @@ def create(self, request, *args, **kwargs): ) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) def post(self, request, *args, **kwargs): """ @@ -130,7 +140,10 @@ def post(self, request, *args, **kwargs): class ProjectDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Project.objects.get_projects_for_detail_view() - permission_classes = [HasInvolvementInProjectOrReadOnly, TimingAfterEndsProgramPermission] + permission_classes = [ + HasInvolvementInProjectOrReadOnly, + TimingAfterEndsProgramPermission, + ] serializer_class = ProjectDetailSerializer def retrieve(self, request, *args, **kwargs): @@ -227,7 +240,9 @@ def get(self, request): { "all": self.get_queryset().filter(draft=False).count(), "my": self.get_queryset() - .filter(Q(leader_id=request.user.id) | Q(collaborator__user=request.user)) + .filter( + Q(leader_id=request.user.id) | Q(collaborator__user=request.user) + ) .distinct() .count(), }, @@ -297,7 +312,9 @@ def _project_data( return project.id, project.leader.id @staticmethod - def _collabs_queryset(project_id: int, requested_id: int, leader_id: int) -> QuerySet: + def _collabs_queryset( + project_id: int, requested_id: int, leader_id: int + ) -> QuerySet: return Collaborator.objects.exclude( user__id=leader_id ).get( # чтоб случайно лидер сам себя не удалил @@ -444,7 +461,8 @@ class ProjectSubscribers(APIView): @swagger_auto_schema( responses={ 200: openapi.Response( - "List of project subscribers", ProjectSubscribersListSerializer(many=True) + "List of project subscribers", + ProjectSubscribersListSerializer(many=True), ) } ) @@ -577,7 +595,9 @@ def _project_data( return project.id, project.leader.id @staticmethod - def _collabs_queryset(project_id: int, requested_id: int, leader_id: int) -> QuerySet: + def _collabs_queryset( + project_id: int, requested_id: int, leader_id: int + ) -> QuerySet: return Collaborator.objects.exclude( user__id=leader_id ).get( # чтоб случайно лидер сам себя не удалил @@ -648,3 +668,58 @@ def patch(self, request, project_pk: int, user_to_leader_pk: int) -> Response: project.leader = new_leader.user project.save() return Response(status=204) + + +class DuplicateProjectView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + request_body=ProjectDuplicateRequestSerializer, + responses={201: ProjectDuplicateRequestSerializer(), 400: "Validation error"}, + operation_description="Дублирует проект и привязывает его к указанной партнёрской программе", + ) + def post(self, request): + serializer = ProjectDuplicateRequestSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + original_project = get_object_or_404(Project, id=data["project_id"]) + partner_program = get_object_or_404( + PartnerProgram, id=data["partner_program_id"] + ) + + with transaction.atomic(): + new_project = Project.objects.create( + name=original_project.name, + description=original_project.description, + region=original_project.region, + step=original_project.step, + hidden_score=original_project.hidden_score, + track=original_project.track, + direction=original_project.direction, + actuality=original_project.actuality, + goal=original_project.goal, + problem=original_project.problem, + industry=original_project.industry, + image_address=original_project.image_address, + leader=request.user, + draft=True, + is_company=original_project.is_company, + cover_image_address=original_project.cover_image_address, + cover=original_project.cover, + ) + + program_link = PartnerProgramProject.objects.create( + partner_program=partner_program, project=new_project + ) + + return Response( + { + "new_project_id": new_project.id, + "program_link_id": program_link.id, + "partner_program": partner_program.name, + }, + status=status.HTTP_201_CREATED, + )