diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 1e89666a..fc6016dc 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -80,6 +80,7 @@ class Meta: "city", "is_competitive", "projects_availability", + "max_project_rates", "draft", ( "datetime_started", diff --git a/partner_programs/migrations/0013_partnerprogram_max_project_rates.py b/partner_programs/migrations/0013_partnerprogram_max_project_rates.py new file mode 100644 index 00000000..e8718a0f --- /dev/null +++ b/partner_programs/migrations/0013_partnerprogram_max_project_rates.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.11 on 2025-12-03 08:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0012_partnerprogram_registration_link"), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="max_project_rates", + field=models.PositiveIntegerField( + blank=True, + default=1, + help_text="Ограничение на число экспертов, которые могут оценить один проект в программе", + null=True, + verbose_name="Максимальное количество оценок проектов", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 096dbca2..ed685390 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -79,6 +79,13 @@ class PartnerProgram(models.Model): verbose_name="Ссылка на регистрацию", help_text="Адрес страницы регистрации (например, на Tilda)", ) + max_project_rates = models.PositiveIntegerField( + null=True, + blank=True, + default=1, + verbose_name="Максимальное количество оценок проектов", + help_text="Ограничение на число экспертов, которые могут оценить один проект в программе", + ) data_schema = models.JSONField( verbose_name="Схема данных в формате JSON", help_text="Ключи - имена полей, значения - тип поля ввода", diff --git a/project_rates/serializers.py b/project_rates/serializers.py index 7cc166d0..b1b32294 100644 --- a/project_rates/serializers.py +++ b/project_rates/serializers.py @@ -42,11 +42,13 @@ class Meta: class ProjectScoreSerializer(serializers.ModelSerializer): criteria = CriteriaSerializer() + expert_id = serializers.IntegerField(source="user_id", read_only=True) class Meta: model = ProjectScore fields = [ "criteria", + "expert_id", "value", ] @@ -61,7 +63,9 @@ class ProjectListForRateSerializer(serializers.ModelSerializer): views_count = serializers.SerializerMethodField() criterias = serializers.SerializerMethodField() scored = serializers.SerializerMethodField() - scored_expert_id = serializers.IntegerField(read_only=True, allow_null=True) + rated_experts = serializers.SerializerMethodField() + rated_count = serializers.SerializerMethodField() + max_rates = serializers.SerializerMethodField() class Meta: model = Project @@ -76,22 +80,54 @@ class Meta: "region", "views_count", "scored", - "scored_expert_id", + "rated_experts", + "rated_count", + "max_rates", "criterias", ] def get_views_count(self, obj) -> int: return get_views_count(obj) + def _get_program_scores(self, obj): + if hasattr(obj, "_program_scores"): + return obj._program_scores + program_id = self.context["view"].kwargs.get("program_id") + return ProjectScore.objects.filter( + project=obj, criteria__partner_program_id=program_id + ).select_related("criteria", "user") + + def _get_user_scores(self, obj): + scores = self._get_program_scores(obj) + request = self.context.get("request") + if request and getattr(request.user, "is_authenticated", False): + return [score for score in scores if score.user_id == request.user.id] + return [] + def get_criterias(self, obj) -> CriteriasResponse | ProjectScoresResponse: + user_scores = self._get_user_scores(obj) + if user_scores: + serializer = ProjectScoreSerializer(user_scores, many=True) + return serializer.data program_id = self.context["view"].kwargs.get("program_id") - if obj.scored: - scores = ProjectScore.objects.filter(project=obj).select_related("criteria") - serializer = ProjectScoreSerializer(scores, many=True) - else: - cirterias = Criteria.objects.filter(partner_program__id=program_id) - serializer = CriteriaSerializer(cirterias, many=True) + criterias = Criteria.objects.filter(partner_program__id=program_id) + serializer = CriteriaSerializer(criterias, many=True) return serializer.data def get_scored(self, obj) -> bool: - return bool(obj.scored) + user_scores = self._get_user_scores(obj) + return bool(user_scores) + + def get_rated_experts(self, obj) -> list[int]: + program_scores = self._get_program_scores(obj) + return list({score.user_id for score in program_scores}) + + def get_rated_count(self, obj) -> int: + rated_attr = getattr(obj, "rated_count", None) + if rated_attr is not None: + return rated_attr + program_scores = self._get_program_scores(obj) + return len({score.user_id for score in program_scores}) + + def get_max_rates(self, obj): + return self.context.get("program_max_rates") diff --git a/project_rates/views.py b/project_rates/views.py index e77e9103..46309be3 100644 --- a/project_rates/views.py +++ b/project_rates/views.py @@ -1,15 +1,19 @@ from django.contrib.auth import get_user_model -from django.db.models import QuerySet, Count, OuterRef, Subquery, IntegerField +from django.db import transaction +from django.db.models import Count, Prefetch, Q, QuerySet from rest_framework import generics, status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from django_filters import rest_framework as filters -from partner_programs.models import PartnerProgramUserProfile +from partner_programs.models import PartnerProgram, PartnerProgramProject +from partner_programs.serializers import ProgramProjectFilterRequestSerializer +from partner_programs.utils import filter_program_projects_by_field_name from projects.models import Project from projects.filters import ProjectFilter -from project_rates.models import ProjectScore +from project_rates.models import Criteria, ProjectScore from project_rates.pagination import RateProjectsPagination from project_rates.serializers import ( ProjectScoreCreateSerializer, @@ -27,7 +31,7 @@ class RateProject(generics.CreateAPIView): serializer_class = ProjectScoreCreateSerializer permission_classes = [IsExpertPost] - def get_needed_data(self) -> tuple[dict, list[int], str]: + def get_needed_data(self) -> tuple[dict, list[int], PartnerProgram]: data = self.request.data user_id = self.request.user.id project_id = self.kwargs.get("project_id") @@ -36,34 +40,68 @@ def get_needed_data(self) -> tuple[dict, list[int], str]: criterion["criterion_id"] for criterion in data ] # is needed for validation later - expert = Expert.objects.get( - user__id=user_id, programs__criterias__id=criteria_to_get[0] + criteria_qs = Criteria.objects.filter(id__in=criteria_to_get).select_related( + "partner_program" ) + partner_program_ids = ( + criteria_qs.values_list("partner_program_id", flat=True).distinct() + ) + if not criteria_qs.exists(): + raise ValueError("Criteria not found") + if partner_program_ids.count() != 1: + raise ValueError("All criteria must belong to the same program") + program = criteria_qs.first().partner_program + + Expert.objects.get(user__id=user_id, programs=program) for criterion in data: criterion["user"] = user_id criterion["project"] = project_id criterion["criteria"] = criterion.pop("criterion_id") - return data, criteria_to_get, expert.programs.all().first().name + if not PartnerProgramProject.objects.filter( + partner_program=program, project_id=project_id + ).exists(): + raise ValueError("Project is not linked to the program") + + return data, criteria_to_get, program def create(self, request, *args, **kwargs) -> Response: try: - data, criteria_to_get, program_name = self.get_needed_data() + data, criteria_to_get, program = self.get_needed_data() + project_id = data[0]["project"] + user_id = request.user.id serializer = ProjectScoreCreateSerializer( data=data, criteria_to_get=criteria_to_get, many=True ) serializer.is_valid(raise_exception=True) - ProjectScore.objects.bulk_create( - [ProjectScore(**item) for item in serializer.validated_data], - update_conflicts=True, - update_fields=["value"], - unique_fields=["criteria", "user", "project"], + scores_qs = ProjectScore.objects.filter( + project_id=project_id, criteria__partner_program=program ) + user_has_scores = scores_qs.filter(user_id=user_id).exists() + + if program.max_project_rates: + distinct_raters = scores_qs.values("user_id").distinct().count() + if not user_has_scores and distinct_raters >= program.max_project_rates: + return Response( + { + "error": "max project rates reached for this program", + "max_project_rates": program.max_project_rates, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + ProjectScore.objects.bulk_create( + [ProjectScore(**item) for item in serializer.validated_data], + update_conflicts=True, + update_fields=["value"], + unique_fields=["criteria", "user", "project"], + ) - project = Project.objects.select_related("leader").get(id=data[0]["project"]) + project = Project.objects.select_related("leader").get(id=project_id) send_email.delay( ProjectRatedParams( @@ -72,7 +110,7 @@ def create(self, request, *args, **kwargs) -> Response: project_name=project.name, project_id=project.id, schema_id=2, - program_name=program_name, + program_name=program.name, ) ) @@ -82,6 +120,8 @@ def create(self, request, *args, **kwargs) -> Response: {"error": "you have no permission to rate this program"}, status=status.HTTP_403_FORBIDDEN, ) + except ValueError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -93,18 +133,63 @@ class ProjectListForRate(generics.ListAPIView): filterset_class = ProjectFilter pagination_class = RateProjectsPagination + def post(self, request, *args, **kwargs): + """Allow POST with filters in JSON body.""" + return self.list(request, *args, **kwargs) + + def _get_program(self) -> PartnerProgram: + return PartnerProgram.objects.get(pk=self.kwargs.get("program_id")) + + def _get_filters(self) -> dict: + """ + Accept filters from JSON body to mirror /partner_programs//projects/filter/: + {"filters": {"case": ["Кейс 1"]}} + """ + if self.request.method != "POST": + return {} + data = getattr(self.request, "data", None) + body_filters = data.get("filters") if isinstance(data, dict) else {} + return body_filters if isinstance(body_filters, dict) else {} + def get_queryset(self) -> QuerySet[Project]: - projects_ids = PartnerProgramUserProfile.objects.filter( - project__isnull=False, partner_program__id=self.kwargs.get("program_id") - ).values_list("project__id", flat=True) - # `Count` the easiest way to check for rate exist (0 -> does not exist). - scored_expert_subquery = ProjectScore.objects.filter( - project=OuterRef("pk") - ).values("user_id")[:1] - - return Project.objects.filter(draft=False, id__in=projects_ids).annotate( - scored=Count("scores"), - scored_expert_id=Subquery( - scored_expert_subquery, output_field=IntegerField() - ), + program = self._get_program() + + filters_serializer = ProgramProjectFilterRequestSerializer( + data={"filters": self._get_filters()} + ) + filters_serializer.is_valid(raise_exception=True) + field_filters = filters_serializer.validated_data.get("filters", {}) + + try: + program_projects_qs = filter_program_projects_by_field_name( + program, field_filters + ) + except ValueError as e: + raise ValidationError({"filters": str(e)}) + + project_ids = program_projects_qs.values_list("project_id", flat=True) + + scores_prefetch = Prefetch( + "scores", + queryset=ProjectScore.objects.filter( + criteria__partner_program=program + ).select_related("user"), + to_attr="_program_scores", ) + + return ( + Project.objects.filter(draft=False, id__in=project_ids) + .annotate( + rated_count=Count( + "scores__user", + filter=Q(scores__criteria__partner_program=program), + distinct=True, + ) + ) + .prefetch_related(scores_prefetch) + ) + + def get_serializer_context(self): + context = super().get_serializer_context() + context["program_max_rates"] = self._get_program().max_project_rates + return context diff --git a/users/permissions.py b/users/permissions.py index d5f4f976..ad6c9023 100644 --- a/users/permissions.py +++ b/users/permissions.py @@ -22,6 +22,8 @@ class IsExpert(BasePermission): def has_permission(self, request, view): user = request.user + if not getattr(user, "is_authenticated", False): + raise PermissionDenied("Authentication credentials were not provided.") program_id = view.kwargs.get("program_id") if not user.user_type == 3: @@ -37,7 +39,12 @@ class IsExpertPost(BasePermission): """ def has_permission(self, request, view): - return True if request.user.user_type == 3 else False + user = request.user + if not getattr(user, "is_authenticated", False): + raise PermissionDenied("Authentication credentials were not provided.") + if getattr(user, "user_type", None) != 3: + raise PermissionDenied("User is not an expert") + return True class CustomIsAuthenticated(BasePermission):