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
5 changes: 3 additions & 2 deletions .github/workflows/dev-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:
description: "DEV operation"
type: choice
default: deploy
options: [deploy, verify_solution_rollback, prepare_solution_browser, finalize_solution_browser, verify_program_publication, grant_program_creator_11, dependency_backup, dependency_verify, prepare_pr517_closeout]
options: [deploy, verify_solution_rollback, prepare_solution_browser, finalize_solution_browser, verify_program_publication, grant_program_creator_11, dependency_backup, dependency_verify, prepare_pr517_closeout, prepare_expert_rating]
dependency_backup_key:
description: "Ключ резервной копии для проверки сохранности данных dev"
type: string
Expand Down Expand Up @@ -420,11 +420,12 @@ jobs:
const manager = process.env.VERIFY_MANAGER_ID;
const applyFixture = process.env.VERIFY_FIXTURE_APPLY;
const phases = { verify_solution_rollback: 'verify_rollback', prepare_solution_browser: 'prepare_browser', finalize_solution_browser: 'finalize_browser', verify_program_publication: 'verify_publication', grant_program_creator_11: 'grant_creator_11', prepare_pr517_closeout: 'prepare_pr517_closeout' };
phases.prepare_expert_rating = 'prepare_expert_rating';
if (!/^[0-9a-f]{40}$/.test(sha) || !phases[operation] || !/^[a-z0-9-]{8,50}$/.test(key) || !/^[1-9][0-9]{0,8}$/.test(manager)) throw new Error('Invalid acceptance arguments');
if (!['true', 'false'].includes(applyFixture)) throw new Error('Invalid fixture apply flag');
const phase = phases[operation];
const file = phase === 'prepare_pr517_closeout' ? 'scripts/prepare_pr517_closeout.py' : phase === 'grant_creator_11' ? 'scripts/grant_dev_program_creation_20261027.py' : phase === 'verify_publication' ? 'scripts/verify_program_publication_dev.py' : phase === 'verify_rollback' ? 'scripts/verify_program_solutions_dev.py' : 'scripts/prepare_program_solutions_browser.py';
const source = fs.readFileSync(file, 'utf8');
const source = fs.readFileSync(phase === 'prepare_expert_rating' ? 'scripts/prepare_expert_rating_dev.py' : file, 'utf8');
const script = [
'set +x',
'set -eu',
Expand Down
7 changes: 3 additions & 4 deletions partner_programs/serializers/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def get_is_user_expert(self, program):
return can_evaluate_program(user, program.pk)

def get_pending_expert_invitation_id(self, program):
from django.utils import timezone
from project_rates.workspace import pending_expert_invitations

user = self.context.get("user") or getattr(
self.context.get("request"), "user", None
Expand All @@ -160,9 +160,8 @@ def get_pending_expert_invitation_id(self, program):
if program.experts.filter(user_id=user.pk).exists():
return None
return (
program.expert_invitations.filter(
email__iexact=user.email, status="pending", expires_at__gt=timezone.now()
)
pending_expert_invitations(user.email)
.filter(partner_program=program)
.values_list("pk", flat=True)
.first()
)
Expand Down
39 changes: 31 additions & 8 deletions partner_programs/services/assignment_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@

from datetime import timedelta

from django.db.models import BooleanField, Case, Count, F, OuterRef, Subquery, Value, When
from django.db.models import (
BooleanField,
Case,
Count,
F,
OuterRef,
Q,
Subquery,
Value,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone

Expand All @@ -13,16 +23,19 @@
def annotated_assignment_queryset(program_id=None):
"""Общая SQL-проверка завершённости реального назначения в программе.

Завершённое назначение требует сданного решения и оценок по всем критериям
этой программы; отсутствие критериев никогда не означает завершённость.
Завершенное назначение требует сданного проекта и оценок по обязательным
критериям программы. Если есть только комментарии, нужна сохраненная оценка.
Аннотации пригодны и для списка назначений, и для агрегации работ без N+1.
"""
scope = program_id if program_id is not None else OuterRef("partner_program_id")
criteria = (
Criteria.objects.filter(partner_program_id=scope)
.order_by()
.values("partner_program_id")
.annotate(total=Count("pk"))
.annotate(
total=Count("pk"),
required=Count("pk", filter=~Q(type="str")),
)
)
scores = (
ProjectScore.objects.filter(
Expand All @@ -32,7 +45,10 @@ def annotated_assignment_queryset(program_id=None):
)
.order_by()
.values("project_id", "user_id")
.annotate(total=Count("criteria_id", distinct=True))
.annotate(
total=Count("criteria_id", distinct=True),
required=Count("criteria_id", distinct=True, filter=~Q(criteria__type="str")),
)
)
link = PartnerProgramProject.objects.filter(
partner_program_id=scope, project_id=OuterRef("project_id")
Expand All @@ -46,6 +62,8 @@ def annotated_assignment_queryset(program_id=None):
.annotate(
criteria_total=Coalesce(Subquery(criteria.values("total")[:1]), 0),
criteria_scored=Coalesce(Subquery(scores.values("total")[:1]), 0),
required_criteria=Coalesce(Subquery(criteria.values("required")[:1]), 0),
required_scored=Coalesce(Subquery(scores.values("required")[:1]), 0),
project_submitted=Coalesce(
Subquery(link.values("submitted")[:1]),
Value(False),
Expand All @@ -56,9 +74,14 @@ def annotated_assignment_queryset(program_id=None):
.annotate(
is_completed=Case(
When(
project_submitted=True,
criteria_total__gt=0,
criteria_scored__gte=F("criteria_total"),
Q(project_submitted=True)
& (
Q(
required_criteria__gt=0,
required_scored__gte=F("required_criteria"),
)
| Q(required_criteria=0, criteria_scored__gt=0)
),
then=Value(True),
),
default=Value(False),
Expand Down
62 changes: 38 additions & 24 deletions project_rates/expert_cabinet.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
"""Read projection of the current expert's actual assignments; no new scoring states."""

from django.db.models import Count, Exists, F, OuterRef, Subquery
from django.db.models import Count, Exists, F, OuterRef, Q, Subquery
from django.utils import timezone

from partner_programs.models import PartnerProgramProject
from partner_programs.models import PartnerProgram, PartnerProgramProject
from partner_programs.services.assignment_analytics import annotated_assignment_queryset
from partner_programs.services.case_analytics import project_case_links
from partner_programs.services.case_fields import get_program_case_field
from project_rates.models import ProjectScore
from project_rates.program_experts import evaluation_programs
from project_rates.project_scoring import project_scoring_blocker
from projects.models import Collaborator


def cabinet_projects(user, query, program_ids, *, paginate=True, as_applications=False):
own = annotated_assignment_queryset().filter(
expert__user=user,
partner_program_id__in=program_ids,
).filter(
partner_program_id__in=evaluation_programs(user).values("pk"),
def cabinet_projects(
user,
query,
program_ids,
*,
paginate=True,
as_applications=False,
project_filters: Q | None = None,
):
own = (
annotated_assignment_queryset()
.filter(
expert__user=user,
partner_program_id__in=program_ids,
)
.filter(
partner_program_id__in=evaluation_programs(user).values("pk"),
)
)
linked = PartnerProgramProject.objects.filter(
partner_program_id=OuterRef("partner_program_id"),
Expand All @@ -32,6 +45,8 @@ def cabinet_projects(user, query, program_ids, *, paginate=True, as_applications
selected = selected.filter(partner_program_id=query["program_id"])
if query.get("q"):
selected = selected.filter(project__name__icontains=query["q"])
if project_filters is not None:
selected = selected.filter(project_filters)
count = selected.count()
scores = (
ProjectScore.objects.filter(
Expand All @@ -56,6 +71,11 @@ def cabinet_projects(user, query, program_ids, *, paginate=True, as_applications
project_id=OuterRef("project_id"), user_id=user.pk
)
),
cabinet_organizer=Exists(
PartnerProgram.managers.through.objects.filter(
partnerprogram_id=OuterRef("partner_program_id"), customuser_id=user.pk
)
),
).select_related("partner_program", "project")
offset, limit = query["offset"], query["limit"]
end = offset + limit
Expand Down Expand Up @@ -83,22 +103,16 @@ def cabinet_projects(user, query, program_ids, *, paginate=True, as_applications
rows = []
for assignment in assignments:
program, project = assignment.partner_program, assignment.project
reason = None
if program.datetime_evaluation_ends and now > program.datetime_evaluation_ends:
reason = "Срок оценивания завершен"
elif not assignment.criteria_total:
reason = "Критерии не настроены"
elif program.required_evaluators is not None:
if not assignment.project_submitted:
reason = "Решение еще не сдано"
elif project.leader_id == user.pk or assignment.cabinet_own_team:
reason = "Нельзя оценивать проект своей команды"
elif (
program.max_project_rates
and (assignment.cabinet_rated_count or 0) >= program.max_project_rates
and not assignment.cabinet_has_scores
):
reason = "Лимит оценок достигнут"
reason = project_scoring_blocker(
program,
now=now,
has_criteria=assignment.criteria_total > 0,
submitted=assignment.project_submitted,
own_team=project.leader_id == user.pk or assignment.cabinet_own_team,
is_organizer=assignment.cabinet_organizer,
rated_count=assignment.cabinet_rated_count or 0,
has_own_scores=assignment.cabinet_has_scores,
)
rows.append(
{
"assignment_id": assignment.pk,
Expand Down
29 changes: 29 additions & 0 deletions project_rates/project_scoring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
def project_scoring_blocker(
program,
*,
now,
has_criteria,
submitted,
own_team,
is_organizer,
rated_count,
has_own_scores,
):
"""Explain why an expert cannot submit or change an assessment in the workspace."""
managed = program.required_evaluators is not None
if program.datetime_evaluation_ends and now > program.datetime_evaluation_ends:
return "Срок оценивания завершен"
if not has_criteria:
return "Критерии не настроены"
if (managed or program.is_competitive) and not submitted:
return "Проект еще не сдан"
if (managed or is_organizer) and own_team:
return "Нельзя оценивать проект своей команды"
if (
not managed
and program.max_project_rates
and rated_count >= program.max_project_rates
):
if not has_own_scores:
return "Лимит оценок достигнут"
return None
51 changes: 37 additions & 14 deletions project_rates/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from core.services import get_views_count
from projects.models import Project
from .models import Criteria, ProjectScore
from .project_scoring import project_scoring_blocker
from .typing import CriteriasResponse, ProjectScoresResponse
from .validators import ProjectScoreValidator

Expand Down Expand Up @@ -114,28 +115,54 @@ def _get_user_scores(self, obj):
return [score for score in scores if score.user_id == request.user.id]
return []

def get_criterias(self, obj) -> CriteriasResponse | ProjectScoresResponse:
def get_criterias(self, obj) -> list[CriteriasResponse | ProjectScoresResponse]:
user_scores = self._get_user_scores(obj)
if self.context.get("managed_evaluation"):
program_id = self.context["view"].kwargs.get("program_id")
criteria = self.context["program_criteria"]
if self.context.get("managed_evaluation") or "assigned_program" in self.context:
scores = {score.criteria_id: score for score in user_scores}
return [
{
**CriteriaSerializer(c).data,
**({"value": scores[c.pk].value} if c.pk in scores else {}),
}
for c in Criteria.objects.filter(partner_program_id=program_id).order_by(
"pk"
)
for c in criteria
]
if user_scores:
serializer = ProjectScoreSerializer(user_scores, many=True)
return serializer.data
program_id = self.context["view"].kwargs.get("program_id")
criterias = Criteria.objects.filter(partner_program__id=program_id)
serializer = CriteriaSerializer(criterias, many=True)
serializer = CriteriaSerializer(criteria, many=True)
return serializer.data

def to_representation(self, instance):
data = super().to_representation(instance)
program = self.context.get("assigned_program")
if program is None:
return data

user = self.context["request"].user
reason = project_scoring_blocker(
program,
now=self.context["now"],
has_criteria=bool(self.context["program_criteria"]),
submitted=instance._program_submitted,
own_team=instance.leader_id == user.pk or instance._own_team,
is_organizer=self.context["is_organizer"],
rated_count=instance.rated_count,
has_own_scores=instance._own_score_count > 0,
)
data.update(
program_link_id=instance._program_link_id,
can_rate=reason is None,
blocked_reason=reason,
)
if self.context["include_program_fields"]:
from partner_programs.services.project_fields import program_link_fields

data["program_fields"] = program_link_fields(instance._program_link_id, user)[
"fields"
]
return data

def get_scored(self, obj) -> bool:
if hasattr(obj, "_own_completed"):
return obj._own_completed
Expand All @@ -157,11 +184,7 @@ def get_rated_experts(self, obj) -> list[int]:
if self.context.get("managed_evaluation"):
from collections import defaultdict

required = set(
Criteria.objects.filter(
partner_program_id=self.context["view"].kwargs["program_id"]
).values_list("pk", flat=True)
)
required = {criterion.pk for criterion in self.context["program_criteria"]}
scored = defaultdict(set)
for score in program_scores:
scored[score.user_id].add(score.criteria_id)
Expand Down
Loading
Loading