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
15 changes: 15 additions & 0 deletions partner_programs/project_analytics_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from partner_programs.services.project_analytics import (
build_project_analytics,
participants_without_team_rows,
participants_without_visit_rows,
projects_awaiting_evaluation_rows,
projects_not_submitted_rows,
)
Expand Down Expand Up @@ -127,6 +128,20 @@ def get_queryset(self, search):
return queryset.order_by(F("registered_at").asc(nulls_last=True), "user_id")


class ProjectAnalyticsParticipantsWithoutVisitAPIView(
ProjectAnalyticsAttentionListAPIView
):
serializer_class = ProjectAnalyticsParticipantSerializer

def get_queryset(self, search):
queryset = participants_without_visit_rows(self.program.pk)
if search:
queryset = queryset.annotate(
search_name=Concat("user__first_name", Value(" "), "user__last_name")
).filter(search_name__icontains=search)
return queryset.order_by(F("registered_at").asc(nulls_last=True), "user_id")


class ProjectAnalyticsProjectsAwaitingEvaluationAPIView(
ProjectAnalyticsAttentionListAPIView
):
Expand Down
2 changes: 2 additions & 0 deletions partner_programs/serializers/project_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class ProjectAnalyticsSummarySerializer(serializers.Serializer):
class ProjectAnalyticsParticipantFunnelSerializer(serializers.Serializer):
registrations = serializers.IntegerField(min_value=0)
unique_participants = serializers.IntegerField(min_value=0)
visited_participants = serializers.IntegerField(min_value=0)
with_team = serializers.IntegerField(min_value=0)
project_creators = serializers.IntegerField(min_value=0)
submitted_project_creators = serializers.IntegerField(min_value=0)
Expand Down Expand Up @@ -69,6 +70,7 @@ class ProjectAnalyticsNotSubmittedCountSerializer(serializers.Serializer):


class ProjectAnalyticsAttentionSerializer(serializers.Serializer):
participants_without_visit = serializers.IntegerField(min_value=0)
participants_without_team = serializers.IntegerField(min_value=0)
projects_awaiting_evaluation = serializers.IntegerField(min_value=0)
projects_not_submitted = ProjectAnalyticsNotSubmittedCountSerializer()
Expand Down
4 changes: 2 additions & 2 deletions partner_programs/serializers/project_assignment_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ class ProjectDelayedExpertSerializer(ProjectAssignmentExpertSerializer):
pending = serializers.IntegerField(min_value=0)
overdue_24h = serializers.IntegerField(min_value=0)
overdue_48h = serializers.IntegerField(min_value=0)
oldest_waiting_since = serializers.DateTimeField()
oldest_waiting_seconds = serializers.IntegerField(min_value=0)
oldest_waiting_since = serializers.DateTimeField(allow_null=True)
oldest_waiting_seconds = serializers.IntegerField(min_value=0, allow_null=True)
severity = serializers.ChoiceField(choices=("critical", "warning"))


Expand Down
15 changes: 14 additions & 1 deletion partner_programs/serializers/project_case_drilldown.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ class ProjectCaseQuerySerializer(serializers.Serializer):
view = serializers.ChoiceField(choices=("case_analytics",))
case_scope = serializers.ChoiceField(choices=("all", "selected", "without_case"))
case_name = serializers.CharField(required=False, trim_whitespace=False)
search = serializers.CharField(allow_blank=True, default="", trim_whitespace=True)
submission = serializers.ChoiceField(
choices=("all", "submitted", "not_submitted"), default="all"
)
presentation = serializers.ChoiceField(
choices=("all", "present", "missing"), default="all"
)

def to_internal_value(self, data):
errors = {
Expand All @@ -34,6 +41,13 @@ def to_internal_value(self, data):
return super().to_internal_value({key: data[key] for key in data})

def validate(self, attrs):
if (
attrs["submission"] != "all"
and self.context.get("submission_applicable") is False
):
raise serializers.ValidationError(
{"submission": "В этой программе сдача решений не требуется."}
)
if attrs["case_scope"] == "selected":
if attrs.get("case_name") not in self.context["case_options"]:
raise serializers.ValidationError(
Expand All @@ -49,7 +63,6 @@ def validate(self, attrs):
class ProjectCaseListQuerySerializer(ProjectCaseQuerySerializer):
limit = serializers.IntegerField(min_value=1, max_value=100, default=25)
offset = serializers.IntegerField(min_value=0, default=0)
search = serializers.CharField(allow_blank=True, default="", trim_whitespace=True)


class CaseProjectSerializer(serializers.Serializer):
Expand Down
2 changes: 1 addition & 1 deletion partner_programs/services/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ def _literal_excel_cell(worksheet, value):


def build_case_projects_export_file(*, program, field, selection) -> ProgramExportFile:
"""Выгружаем весь bucket; поиск и пагинация не входят в этот контракт."""
"""Выгружаем все совпадения поиска и фильтров выбранного кейса без пагинации."""
workbook = Workbook(write_only=True)
worksheet = workbook.create_sheet(title="Проекты")
worksheet.append(CASE_PROJECT_COLUMNS)
Expand Down
56 changes: 53 additions & 3 deletions partner_programs/services/project_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,56 @@ def participants_without_team_rows(program_id):
)


def _visited_participant_filter():
"""Зарегистрированный пользователь с активностью или входом на платформу."""
return Q(user_id__isnull=False) & (
Q(user__last_activity__isnull=False) | Q(user__last_login__isnull=False)
)


def _without_visit_filter():
"""Оба свидетельства посещения отсутствуют; онбординг не учитывается."""
return Q(
user_id__isnull=False,
user__last_activity__isnull=True,
user__last_login__isnull=True,
)


def participants_without_visit_rows(program_id):
"""Тот же признак отсутствия посещения, что и в счётчике внимания."""
return _unique_participant_rows(
PartnerProgramUserProfile.objects.filter(partner_program_id=program_id).filter(
_without_visit_filter()
)
)


def _unique_participant_rows(profiles):
"""Публичные поля уникального пользователя и его первая регистрация в программе."""
return (
profiles.order_by()
.values(
"user_id",
"user__first_name",
"user__last_name",
"user__avatar",
"user__city",
)
.annotate(registered_at=Min("datetime_created"))
)


def _participant_metrics(program_id):
profiles = _participant_profiles(program_id)
participant = Q(user_id__isnull=False)
return profiles.aggregate(
registrations=Count("pk"),
unique_participants=Count("user_id", filter=participant, distinct=True),
visited_participants=Count(
"user_id", filter=_visited_participant_filter(), distinct=True
),
without_visit=Count("user_id", filter=_without_visit_filter(), distinct=True),
with_team=Count(
"user_id",
filter=participant & (Q(is_leader=True) | Q(is_collaborator=True)),
Expand Down Expand Up @@ -309,9 +353,10 @@ def _activity(program_id):
]


def build_project_analytics(program) -> dict:
def build_project_analytics(program, *, now=None) -> dict:
now = now if now is not None else timezone.now()
participants = _participant_metrics(program.pk)
assignment_details = build_assignments(program.pk)
assignment_details = build_assignments(program.pk, now=now)
assignments, by_project = _assignment_metrics(assignment_details)
solutions = _solution_metrics(program, by_project)
cases = build_project_case_analytics(program)
Expand Down Expand Up @@ -353,6 +398,7 @@ def build_project_analytics(program) -> dict:
},
},
"attention": {
"participants_without_visit": participants["without_visit"],
"participants_without_team": (
participants["unique_participants"] - participants["with_team"]
),
Expand All @@ -364,7 +410,11 @@ def build_project_analytics(program) -> dict:
"total": solutions["not_submitted"] if program.is_competitive else 0,
},
"delayed_experts": (
build_delayed_experts(assignment_details)
build_delayed_experts(
assignment_details,
evaluation_ends=program.datetime_evaluation_ends,
now=now,
)
if program.is_distributed_evaluation
else {"total": 0, "items": []}
),
Expand Down
30 changes: 18 additions & 12 deletions partner_programs/services/project_assignment_analytics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Read-only legacy Project assignments, independent of the Application domain."""

from datetime import timedelta

from django.db.models import (
BooleanField,
Case,
Expand Down Expand Up @@ -135,8 +137,8 @@ def build_assignment(row, *, now):
}


def build_assignments(program_id):
now = timezone.now()
def build_assignments(program_id, *, now=None):
now = now if now is not None else timezone.now()
return [build_assignment(row, now=now) for row in assignment_rows(program_id)]


Expand Down Expand Up @@ -164,9 +166,14 @@ def build_assignment_scores(program_id, assignment):
]


def build_delayed_experts(assignments):
"""Aggregate already loaded DTOs without SQL or synthetic assignments."""
def build_delayed_experts(assignments, *, evaluation_ends, now=None):
"""Flag unfinished submitted work from five days before evaluation closes."""
now = now if now is not None else timezone.now()
if evaluation_ends is None or now < evaluation_ends - timedelta(days=5):
return {"total": 0, "items": []}

experts = {}
unfinished_experts = set()
for assignment in assignments:
expert = assignment["expert"]
item = experts.setdefault(
Expand All @@ -184,12 +191,14 @@ def build_delayed_experts(assignments):
)
item["assignments_total"] += 1
item["completed" if assignment["status"] == "completed" else "pending"] += 1
if assignment["status"] in ("pending", "in_progress"):
unfinished_experts.add(expert["expert_id"])
seconds = assignment["waiting_seconds"]
if seconds is None:
continue
item["overdue_24h"] += int(seconds >= 24 * 3600)
item["overdue_48h"] += int(seconds >= 48 * 3600)
if seconds > 0 and (
if (
item["oldest_waiting_seconds"] is None
or seconds > item["oldest_waiting_seconds"]
):
Expand All @@ -198,17 +207,14 @@ def build_delayed_experts(assignments):

items = []
for item in experts.values():
if item["overdue_48h"] >= 1:
item["severity"] = "critical"
elif item["overdue_24h"] >= 2:
item["severity"] = "warning"
else:
if item["expert_id"] not in unfinished_experts:
continue
item["severity"] = "critical" if now >= evaluation_ends else "warning"
items.append(item)
items.sort(
key=lambda item: (
item["severity"] != "critical",
-item["oldest_waiting_seconds"],
item["oldest_waiting_seconds"] is None,
-(item["oldest_waiting_seconds"] or 0),
item["expert_id"],
)
)
Expand Down
11 changes: 11 additions & 0 deletions partner_programs/services/project_case_drilldown.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ def case_project_rows(program, *, field, selection):
elif selection["case_scope"] == "without_case":
links = links.filter(case_name__isnull=True)

if selection.get("search"):
links = links.filter(project__name__icontains=selection["search"])
submission = selection.get("submission", "all")
if submission != "all":
links = links.filter(submitted=submission == "submitted")
presentation = selection.get("presentation", "all")
if presentation == "present":
links = links.filter(project__presentation_address__regex=r"\S")
elif presentation == "missing":
links = links.exclude(project__presentation_address__regex=r"\S")

collaborators = (
Collaborator.objects.filter(project_id=OuterRef("project_id"))
.order_by()
Expand Down
3 changes: 3 additions & 0 deletions partner_programs/tests/test_project_analytics_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ def test_empty_contract_has_b5_fields_additive_delays_and_thirty_zero_days(self)
{
"registrations": 0,
"unique_participants": 0,
"visited_participants": 0,
"with_team": 0,
"project_creators": 0,
"submitted_project_creators": 0,
Expand All @@ -164,6 +165,7 @@ def test_empty_contract_has_b5_fields_additive_delays_and_thirty_zero_days(self)
payload["attention"],
{
"participants_without_team": 0,
"participants_without_visit": 0,
"projects_awaiting_evaluation": 0,
"projects_not_submitted": {"applicable": False, "total": 0},
"delayed_experts": {"total": 0, "items": []},
Expand Down Expand Up @@ -219,6 +221,7 @@ def test_participants_use_distinct_registered_leaders_and_collaborators(self):
{
"registrations": 5,
"unique_participants": 3,
"visited_participants": 0,
"with_team": 2,
"project_creators": 1,
"submitted_project_creators": 1,
Expand Down
Loading
Loading