Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions partner_programs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class Meta:
"city",
"is_competitive",
"projects_availability",
"max_project_rates",
"draft",
(
"datetime_started",
Expand Down
Original file line number Diff line number Diff line change
@@ -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="Максимальное количество оценок проектов",
),
),
]
7 changes: 7 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="Ключи - имена полей, значения - тип поля ввода",
Expand Down
54 changes: 45 additions & 9 deletions project_rates/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand All @@ -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
Expand All @@ -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")
141 changes: 113 additions & 28 deletions project_rates/views.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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")
Expand All @@ -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(
Expand All @@ -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,
)
)

Expand All @@ -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)

Expand All @@ -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/<id>/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
9 changes: 8 additions & 1 deletion users/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down