From e2f8f24648f913301c601ff34a17461d82b193e8 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 17 Oct 2026 19:28:33 +0300 Subject: [PATCH] Port chat analytics visits, alerts and case filters to production --- partner_programs/project_analytics_views.py | 15 ++ .../serializers/project_analytics.py | 2 + .../project_assignment_analytics.py | 4 +- .../serializers/project_case_drilldown.py | 15 +- partner_programs/services/exports.py | 2 +- .../services/project_analytics.py | 56 ++++- .../services/project_assignment_analytics.py | 30 ++- .../services/project_case_drilldown.py | 11 + .../tests/test_project_analytics_api.py | 3 + .../test_project_analytics_deadline_alerts.py | 135 +++++++++++ .../tests/test_project_analytics_visits.py | 223 ++++++++++++++++++ .../test_project_assignment_analytics_api.py | 31 ++- .../tests/test_project_case_drilldown.py | 111 ++++++++- partner_programs/urls.py | 6 + partner_programs/views.py | 12 +- 15 files changed, 621 insertions(+), 35 deletions(-) create mode 100644 partner_programs/tests/test_project_analytics_deadline_alerts.py create mode 100644 partner_programs/tests/test_project_analytics_visits.py diff --git a/partner_programs/project_analytics_views.py b/partner_programs/project_analytics_views.py index 03b42cba..53ab6cbf 100644 --- a/partner_programs/project_analytics_views.py +++ b/partner_programs/project_analytics_views.py @@ -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, ) @@ -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 ): diff --git a/partner_programs/serializers/project_analytics.py b/partner_programs/serializers/project_analytics.py index 6ac5cb63..08bb0a6a 100644 --- a/partner_programs/serializers/project_analytics.py +++ b/partner_programs/serializers/project_analytics.py @@ -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) @@ -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() diff --git a/partner_programs/serializers/project_assignment_analytics.py b/partner_programs/serializers/project_assignment_analytics.py index 3f943858..6e6a134c 100644 --- a/partner_programs/serializers/project_assignment_analytics.py +++ b/partner_programs/serializers/project_assignment_analytics.py @@ -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")) diff --git a/partner_programs/serializers/project_case_drilldown.py b/partner_programs/serializers/project_case_drilldown.py index 84c2e84f..8532eafd 100644 --- a/partner_programs/serializers/project_case_drilldown.py +++ b/partner_programs/serializers/project_case_drilldown.py @@ -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 = { @@ -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( @@ -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): diff --git a/partner_programs/services/exports.py b/partner_programs/services/exports.py index ef9143cd..39a6d51d 100644 --- a/partner_programs/services/exports.py +++ b/partner_programs/services/exports.py @@ -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) diff --git a/partner_programs/services/project_analytics.py b/partner_programs/services/project_analytics.py index 0c2618ae..904adfa3 100644 --- a/partner_programs/services/project_analytics.py +++ b/partner_programs/services/project_analytics.py @@ -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)), @@ -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) @@ -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"] ), @@ -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": []} ), diff --git a/partner_programs/services/project_assignment_analytics.py b/partner_programs/services/project_assignment_analytics.py index 70df7a46..c65bbe5d 100644 --- a/partner_programs/services/project_assignment_analytics.py +++ b/partner_programs/services/project_assignment_analytics.py @@ -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, @@ -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)] @@ -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( @@ -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"] ): @@ -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"], ) ) diff --git a/partner_programs/services/project_case_drilldown.py b/partner_programs/services/project_case_drilldown.py index 7dd818c3..5083f4fc 100644 --- a/partner_programs/services/project_case_drilldown.py +++ b/partner_programs/services/project_case_drilldown.py @@ -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() diff --git a/partner_programs/tests/test_project_analytics_api.py b/partner_programs/tests/test_project_analytics_api.py index 24d15aa9..39d53cf2 100644 --- a/partner_programs/tests/test_project_analytics_api.py +++ b/partner_programs/tests/test_project_analytics_api.py @@ -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, @@ -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": []}, @@ -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, diff --git a/partner_programs/tests/test_project_analytics_deadline_alerts.py b/partner_programs/tests/test_project_analytics_deadline_alerts.py new file mode 100644 index 00000000..cfc8c2ef --- /dev/null +++ b/partner_programs/tests/test_project_analytics_deadline_alerts.py @@ -0,0 +1,135 @@ +from datetime import timedelta + +from django.test import TestCase + +from partner_programs.tests.test_project_assignment_analytics_api import ( + NOW, + ProjectAssignmentAnalyticsFixture, +) +from partner_programs.models import PartnerProgramProject +from partner_programs.tests.helpers import create_partner_program +from project_rates.tests.helpers import create_rate_expert +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore + + +class ProjectDeadlineAlertTests(ProjectAssignmentAnalyticsFixture, TestCase): + def test_evaluation_deadline_window_and_boundaries(self): + cases = ( + (timedelta(days=5, seconds=1), 100, None), + (timedelta(days=5), 0, "warning"), + (timedelta(days=5, seconds=-1), 49, "warning"), + (timedelta(days=1), 1, "warning"), + (timedelta(seconds=1), 1, "warning"), + (timedelta(0), 0, "critical"), + (timedelta(seconds=-1), 0, "critical"), + (timedelta(days=-10), 100, "critical"), + ) + for remaining, hours, severity in cases: + with self.subTest(remaining=remaining, hours=hours): + self.program.datetime_evaluation_ends = NOW + remaining + self.program.save(update_fields=["datetime_evaluation_ends"]) + ProjectExpertAssignment.objects.all().delete() + self.assignment(hours=hours) + delayed = self.get(self.overview_url)["attention"]["delayed_experts"] + self.assertEqual(delayed["total"], int(severity is not None)) + if severity is not None: + item = delayed["items"][0] + self.assertEqual(item["severity"], severity) + self.assertEqual(item["assignments_total"], 1) + self.assertEqual(item["pending"], 1) + self.assertEqual(item["completed"], 0) + self.assertEqual(item["overdue_24h"], int(hours >= 24)) + self.assertEqual(item["overdue_48h"], int(hours >= 48)) + self.assertEqual(item["oldest_waiting_seconds"], hours * 3600) + + def test_old_24h_and_48h_thresholds_do_not_trigger_before_deadline_window(self): + self.program.datetime_evaluation_ends = NOW + timedelta(days=6) + self.program.save(update_fields=["datetime_evaluation_ends"]) + self.assignment(hours=25) + self.assignment(hours=25) + self.assignment(hours=100) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"], + {"total": 0, "items": []}, + ) + + def test_missing_evaluation_deadline_does_not_use_other_program_dates(self): + self.program.datetime_evaluation_ends = None + self.program.datetime_finished = NOW + self.program.datetime_registration_ends = NOW + self.program.datetime_project_submission_ends = NOW + self.program.save() + self.assignment(hours=100) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"], + {"total": 0, "items": []}, + ) + + def test_completing_scores_removes_signal_and_moving_deadline_recalculates_it(self): + assignment = self.assignment(hours=0) + self.score(assignment, 1) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 1 + ) + self.program.datetime_evaluation_ends = NOW + timedelta(days=6) + self.program.save(update_fields=["datetime_evaluation_ends"]) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 0 + ) + self.program.datetime_evaluation_ends = NOW + self.program.save(update_fields=["datetime_evaluation_ends"]) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["items"][0][ + "severity" + ], + "critical", + ) + # Saving the program also ensures its default Comment criterion exists. + for criterion in Criteria.objects.filter(partner_program=self.program): + ProjectScore.objects.get_or_create( + criteria=criterion, + project_id=assignment.project_id, + user_id=assignment.expert.user_id, + defaults={"value": "0"}, + ) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 0 + ) + + def test_completed_and_not_ready_do_not_trigger_deadline_signal(self): + self.score(self.assignment(hours=100)) + self.assignment(hours=100, submitted=False) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"], + {"total": 0, "items": []}, + ) + + def test_other_program_assignments_do_not_leak_into_deadline_signal_or_list(self): + other = create_partner_program(is_distributed_evaluation=True) + self.expert.expert.programs.add(other) + self.assignment(hours=100, program=other) + self.assertEqual(self.get(), []) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 0 + ) + + def test_delayed_experts_sort_by_known_waiting_then_duration_and_id(self): + warning = create_rate_expert(program=self.program) + for _ in range(2): + self.assignment(expert=warning, hours=25) + self.assignment(hours=49) + oldest = create_rate_expert(program=self.program) + self.assignment(expert=oldest, hours=60) + tied = create_rate_expert(program=self.program) + self.assignment(expert=tied, hours=60) + unknown = create_rate_expert(program=self.program) + unknown_assignment = self.assignment(expert=unknown, hours=100) + PartnerProgramProject.objects.filter(project=unknown_assignment.project).update( + datetime_submitted=None + ) + items = self.get(self.overview_url)["attention"]["delayed_experts"]["items"] + self.assertEqual( + [item["user_id"] for item in items], + [oldest.pk, tied.pk, self.expert.pk, warning.pk, unknown.pk], + ) + self.assertTrue(all(item["severity"] == "warning" for item in items)) diff --git a/partner_programs/tests/test_project_analytics_visits.py b/partner_programs/tests/test_project_analytics_visits.py new file mode 100644 index 00000000..7fda2b85 --- /dev/null +++ b/partner_programs/tests/test_project_analytics_visits.py @@ -0,0 +1,223 @@ +from datetime import datetime, timedelta, timezone as datetime_timezone + +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import create_rate_expert + + +NOW = datetime(2026, 9, 5, 12, tzinfo=datetime_timezone.utc) +PARTICIPANTS = "project-analytics-participants-without-team" +ONBOARDING = "project-analytics-participants-without-onboarding" +VISITS = "project-analytics-participants-without-visit" +PROJECTS = "project-analytics-projects-awaiting-evaluation" + + +class AttentionAnalyticsTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.manager = create_user(prefix="attention-manager") + cls.program = create_partner_program( + is_distributed_evaluation=True, max_project_rates=3 + ) + cls.program.managers.add(cls.manager) + cls.other_program = create_partner_program(is_distributed_evaluation=True) + cls.expert = create_rate_expert(program=cls.program) + # Program creation adds a comment criterion. Isolate two explicit criteria. + Criteria.objects.filter(partner_program=cls.program).delete() + cls.criteria = [ + Criteria.objects.create( + partner_program=cls.program, + name=f"Attention criterion {index}", + type="int", + min_value=0, + max_value=10, + ) + for index in range(2) + ] + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def url(self, endpoint, program=None): + return reverse( + "partner_programs:" + endpoint, + kwargs={"program_id": (program or self.program).pk}, + ) + + def get(self, endpoint, **params): + response = self.client.get(self.url(endpoint), params) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def participant(self, *, program=None, user=None, hours=1, **user_fields): + user = user or create_user(prefix="attention-participant", **user_fields) + registration = create_program_member(program or self.program, user=user) + PartnerProgramUserProfile.objects.filter(pk=registration.pk).update( + datetime_created=NOW - timedelta(hours=hours) + ) + return registration + + def work(self, *, program=None, submitted=True, hours=1, **project_fields): + link = create_program_project( + program or self.program, + project=create_project(**project_fields), + submitted=submitted, + ) + PartnerProgramProject.objects.filter(pk=link.pk).update( + datetime_submitted=(NOW - timedelta(hours=hours)) if submitted else None + ) + return link + + def assignment(self, link, expert=None, program=None): + return ProjectExpertAssignment.objects.create( + partner_program=program or self.program, + project=link.project, + expert=(expert or self.expert).expert, + ) + + def score(self, link, *, expert=None, criteria=None): + for criterion in self.criteria if criteria is None else criteria: + ProjectScore.objects.get_or_create( + project=link.project, + user=expert or self.expert, + criteria=criterion, + defaults={"value": "0"}, + ) + + def open_mode(self): + self.program.is_distributed_evaluation = False + self.program.save(update_fields=["is_distributed_evaluation"]) + + def assert_matches_overview(self, endpoint, metric): + self.assertEqual( + self.get(endpoint)["count"], + self.get("project-analytics")["attention"][metric], + ) + + +class ParticipantsWithoutVisitTests(AttentionAnalyticsTestCase): + def test_new_endpoint_keeps_manager_permissions_and_program_scope(self): + self.participant() + self.participant(program=self.other_program) + denied = [None, create_user(), self.expert] + other_manager = create_user() + self.other_program.managers.add(other_manager) + denied.append(other_manager) + for user in denied: + with self.subTest(user=user): + self.client.force_authenticate(user) + response = self.client.get(self.url(VISITS)) + self.assertEqual(response.status_code, 401 if user is None else 403) + for user in (self.manager, create_user(is_staff=True), create_user(is_superuser=True)): + self.client.force_authenticate(user) + response = self.client.get(self.url(VISITS)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.client.force_authenticate(self.manager) + self.assertEqual(self.client.post(self.url(VISITS), {}).status_code, 405) + + def test_only_both_missing_timestamps_are_included(self): + missing = self.participant(onboarding_stage=None, is_active=True) + also_missing = self.participant(onboarding_stage=0, is_active=False) + self.participant(last_activity=NOW) + self.participant(last_login=NOW) + self.participant(last_activity=NOW, last_login=NOW) + page = self.get(VISITS) + self.assertEqual(page["count"], 2) + self.assertEqual( + {row["user_id"] for row in page["results"]}, + {missing.user_id, also_missing.user_id}, + ) + self.assert_matches_overview(VISITS, "participants_without_visit") + + def test_either_recorded_timestamp_removes_participant_from_attention(self): + for field in ("last_activity", "last_login"): + with self.subTest(field=field): + registration = self.participant(onboarding_stage=0) + self.assertEqual(self.get(VISITS)["count"], 1) + setattr(registration.user, field, NOW) + registration.user.save(update_fields=[field]) + self.assertEqual(self.get(VISITS)["count"], 0) + overview = self.get("project-analytics") + self.assertEqual(overview["attention"]["participants_without_visit"], 0) + self.assertEqual( + overview["participant_funnel"]["visited_participants"], + overview["participant_funnel"]["unique_participants"], + ) + + def test_real_login_updates_both_funnel_and_attention_without_onboarding(self): + registration = self.participant(onboarding_stage=0, is_active=True) + self.assertEqual(self.get(VISITS)["count"], 1) + login = APIClient().post( + reverse("token_obtain_pair"), + {"email": registration.user.email, "password": "pass"}, + format="json", + ) + self.assertEqual(login.status_code, 200) + registration.user.refresh_from_db() + self.assertIsNone(registration.user.last_activity) + self.assertIsNotNone(registration.user.last_login) + self.assertEqual(registration.user.onboarding_stage, 0) + self.assertEqual(self.get(VISITS)["count"], 0) + overview = self.get("project-analytics") + self.assertEqual(overview["participant_funnel"]["visited_participants"], 1) + self.assertEqual(overview["attention"]["participants_without_visit"], 0) + + def test_activity_before_program_registration_still_counts_as_a_visit(self): + self.participant(last_activity=NOW - timedelta(days=100)) + self.participant(last_login=NOW - timedelta(days=100)) + self.assertEqual(self.get(VISITS)["count"], 0) + self.assertEqual( + self.get("project-analytics")["participant_funnel"]["visited_participants"], + 2, + ) + + def test_team_membership_does_not_invent_a_visit(self): + registration = self.participant(onboarding_stage=None) + self.work(leader=registration.user) + self.assertEqual(self.get(VISITS)["count"], 1) + self.assertEqual(self.get(PARTICIPANTS)["count"], 0) + + def test_other_program_unregistered_and_deleted_users_are_excluded(self): + included = self.participant() + self.participant(program=self.other_program) + create_user() + deleted = self.participant() + deleted.user.delete() + page = self.get(VISITS) + self.assertEqual(page["count"], 1) + self.assertEqual(page["results"][0]["user_id"], included.user_id) + self.assert_matches_overview(VISITS, "participants_without_visit") + + def test_search_and_pagination_exclude_visited_users_before_counting(self): + oldest = self.participant(hours=20, first_name="Alice") + newest = self.participant(hours=1, first_name="Alice") + self.participant(hours=30, first_name="Bob") + self.participant(hours=40, first_name="Alice", last_activity=NOW) + self.participant(hours=50, first_name="Alice", last_login=NOW) + first = self.get(VISITS, search=" alice ", limit=1) + second = self.get(VISITS, search="alice", limit=1, offset=1) + self.assertEqual(first["count"], 2) + self.assertEqual(first["results"][0]["user_id"], oldest.user_id) + self.assertEqual(second["results"][0]["user_id"], newest.user_id) + self.assertIsNotNone(first["next"]) + self.assertIsNone(second["next"]) + + def test_list_returns_only_safe_public_participant_fields(self): + self.participant(first_name="", last_name="") + row = self.get(VISITS)["results"][0] + self.assertEqual( + set(row), {"user_id", "full_name", "avatar", "city", "registered_at"} + ) diff --git a/partner_programs/tests/test_project_assignment_analytics_api.py b/partner_programs/tests/test_project_assignment_analytics_api.py index 43ad1713..81829c0b 100644 --- a/partner_programs/tests/test_project_assignment_analytics_api.py +++ b/partner_programs/tests/test_project_assignment_analytics_api.py @@ -57,7 +57,9 @@ def setUpTestData(cls): super().setUpTestData() cls.manager = create_user(password=None) cls.program = create_partner_program( - is_distributed_evaluation=True, max_project_rates=3 + is_distributed_evaluation=True, + max_project_rates=3, + datetime_evaluation_ends=NOW + timedelta(days=5), ) cls.program.managers.add(cls.manager) cls.expert = create_rate_expert(program=cls.program) @@ -411,7 +413,7 @@ def test_later_of_submission_and_assignment_is_waiting_start(self): item["waiting_seconds"], min(assigned_hours, submitted_hours) * 3600 ) - def test_missing_timestamp_completed_and_not_ready_do_not_trigger_waiting_or_sla( + def test_missing_timestamp_does_not_invent_waiting_or_hide_pending_work( self, ): self.score(self.assignment(hours=100)) @@ -423,7 +425,7 @@ def test_missing_timestamp_completed_and_not_ready_do_not_trigger_waiting_or_sla for item in self.get(): self.assertIsNone(item["waiting_since"]) self.assertIsNone(item["waiting_seconds"]) - self.assertEqual(self.delayed(), {"total": 0, "items": []}) + self.assertEqual(self.delayed()["total"], 1) def test_future_waiting_is_clamped_to_zero(self): self.assignment(hours=-5) @@ -432,15 +434,15 @@ def test_future_waiting_is_clamped_to_zero(self): self.assertEqual( datetime.fromisoformat(item["waiting_since"]), NOW + timedelta(hours=5) ) - self.assertEqual(self.delayed(), {"total": 0, "items": []}) + self.assertEqual(self.delayed()["total"], 1) - def test_exact_sla_thresholds(self): + def test_waiting_durations_are_diagnostics_within_the_deadline_window(self): cases = ( - (1, 24, None), + (1, 24, "warning"), (2, 24, "warning"), - (1, 48, "critical"), - (2, 24 - 1 / 3600, None), - (1, 48 - 1 / 3600, None), + (1, 48, "warning"), + (2, 24 - 1 / 3600, "warning"), + (1, 48 - 1 / 3600, "warning"), ) for count, hours, severity in cases: expert = create_rate_expert(program=self.program) @@ -459,7 +461,7 @@ def test_exact_sla_thresholds(self): self.assertEqual(item["assignments_total"], count) self.assertEqual(item["pending"], count) self.assertEqual(item["completed"], 0) - self.assertEqual(item["overdue_24h"], count) + self.assertEqual(item["overdue_24h"], count if hours >= 24 else 0) self.assertEqual(item["overdue_48h"], count if hours >= 48 else 0) self.assertEqual(item["oldest_waiting_seconds"], int(hours * 3600)) @@ -657,6 +659,8 @@ def test_all_b5_b6a_fields_are_preserved_apart_from_b6b_additions(self): self.assertIn("delayed_experts", payload["attention"]) del payload["attention"]["delayed_experts"] del payload["attention"]["projects_not_submitted"] + del payload["attention"]["participants_without_visit"] + del payload["participant_funnel"]["visited_participants"] del payload["cases"] evaluated = 1 if distributed else 2 self.assertEqual( @@ -776,7 +780,12 @@ def test_explicit_safe_contract_and_sql_free_serializers(self): self.assertNotIn("criteria_total", public) self.assertNotIn("criteria_scored", public) with self.assertNumQueries(0): - self.assertEqual(build_delayed_experts(assignments)["total"], 1) + self.assertEqual( + build_delayed_experts( + assignments, evaluation_ends=self.program.datetime_evaluation_ends + )["total"], + 1, + ) def test_strict_status_severity_and_nonnegative_counts(self): self.assignment(hours=49) diff --git a/partner_programs/tests/test_project_case_drilldown.py b/partner_programs/tests/test_project_case_drilldown.py index 499f2648..91e28ad9 100644 --- a/partner_programs/tests/test_project_case_drilldown.py +++ b/partner_programs/tests/test_project_case_drilldown.py @@ -413,11 +413,120 @@ def test_query_validation_rejects_invalid_and_ambiguous_scope_without_fallback(s {"offset": ""}, ): self.assertEqual(self.get(**params).status_code, 400) - for key in ("search", "limit", "offset", "only_submitted"): + for key in ("limit", "offset", "only_submitted"): for value in ("", "1"): self.assertEqual(self.get(export=True, **{key: value}).status_code, 400) self.assertEqual(self.page(limit=100)["count"], 1) + def test_combined_filters_search_pagination_and_export_use_same_selection(self): + links = [] + for submitted in (False, True): + for address in (None, "", " \t\n", "https://example.org/deck.pdf"): + project = create_project( + leader=self.manager, + name=f"Keep project {len(links)}", + presentation_address=address, + ) + links.append(self.link("A", project=project, submitted=submitted)) + self.link( + "B", + project=create_project( + leader=self.manager, + name="Keep foreign case", + presentation_address="https://example.org/deck.pdf", + ), + submitted=True, + ) + self.link("A", project=create_project(leader=self.manager, name="Other name")) + foreign = create_partner_program(is_competitive=True) + foreign_field = create_case_field(foreign, options=["A"]) + self.link("A", project=links[0].project, program=foreign, field=foreign_field) + + for submission in ("all", "submitted", "not_submitted"): + for presentation in ("all", "present", "missing"): + with self.subTest(submission=submission, presentation=presentation): + expected = [ + link + for link in links + if ( + submission == "all" + or link.submitted == (submission == "submitted") + ) + and ( + presentation == "all" + or bool((link.project.presentation_address or "").strip()) + == (presentation == "present") + ) + ] + query = { + "submission": submission, + "presentation": presentation, + "search": " kEeP ", + } + page = self.page(limit=1, offset=1, **query) + self.assertEqual(page["count"], len(expected)) + self.assertEqual(page["case_metrics"]["projects_total"], 9) + self.assertEqual( + [row["program_project_id"] for row in page["results"]], + [link.pk for link in expected[1:2]], + ) + _, rows, _ = self.workbook(**query) + self.assertEqual( + [row[1] for row in rows], [link.project.name for link in expected] + ) + + def test_filters_work_for_without_case_and_all_scopes(self): + selected = self.link("A", submitted=True) + missing = self.link(None, submitted=True) + self.link(None, submitted=False) + for scope, expected in ( + ("without_case", [missing]), + ("all", [selected, missing]), + ): + page = self.page(scope, submission="submitted", presentation="missing") + self.assertEqual( + [row["program_project_id"] for row in page["results"]], + [link.pk for link in expected], + ) + _, rows, _ = self.workbook( + scope, submission="submitted", presentation="missing" + ) + self.assertEqual( + [row[1] for row in rows], [link.project.name for link in expected] + ) + + def test_invalid_filters_and_duplicate_values_are_rejected_by_list_and_export(self): + for export in (False, True): + for key in ("submission", "presentation"): + for value in ("", "true", "unknown"): + self.assertEqual( + self.get(export=export, **{key: value}).status_code, 400 + ) + query = f"?view=case_analytics&case_scope=all&{key}=all&{key}=all" + self.assertEqual( + self.client.get(self.url(export) + query).status_code, 400 + ) + + def test_noncompetitive_submission_filter_is_not_applicable(self): + program = create_partner_program(is_competitive=False) + program.managers.add(self.manager) + self.link(program=program) + self.assertEqual(self.page("without_case", program=program)["count"], 1) + for export in (False, True): + for submission in ("submitted", "not_submitted"): + self.assertEqual( + self.get( + "without_case", + export=export, + program=program, + submission=submission, + ).status_code, + 400, + ) + self.assertEqual( + self.page("without_case", program=program, presentation="missing")["count"], 1 + ) + def test_permission_matrix_missing_program_and_read_only_methods(self): self.link( "A", project=create_project(leader=self.manager, name="Private case project") diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 6d724e48..f1546f80 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -21,6 +21,7 @@ ProjectAnalyticsAssignmentsAPIView, ProjectAnalyticsAssignmentScoresAPIView, ProjectAnalyticsParticipantsWithoutTeamAPIView, + ProjectAnalyticsParticipantsWithoutVisitAPIView, ProjectAnalyticsProjectsAwaitingEvaluationAPIView, ProjectAnalyticsProjectsNotSubmittedAPIView, ) @@ -72,6 +73,11 @@ ProjectAnalyticsAssignmentScoresAPIView.as_view(), name="project-analytics-assignment-scores", ), + path( + "/project-analytics/participants-without-visit/", + ProjectAnalyticsParticipantsWithoutVisitAPIView.as_view(), + name="project-analytics-participants-without-visit", + ), path( "/project-analytics/participants-without-team/", ProjectAnalyticsParticipantsWithoutTeamAPIView.as_view(), diff --git a/partner_programs/views.py b/partner_programs/views.py index 9b47b22d..f7c2a6ec 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -431,13 +431,14 @@ def list(self, request, *args, **kwargs): field = get_program_case_field(program) query = ProjectCaseListQuerySerializer( data=request.query_params, - context={"case_options": field.get_options_list() if field else []}, + context={ + "case_options": field.get_options_list() if field else [], + "submission_applicable": program.is_competitive, + }, ) query.is_valid(raise_exception=True) selection = query.validated_data rows = case_project_rows(program, field=field, selection=selection) - if selection["search"]: - rows = rows.filter(project__name__icontains=selection["search"]) paginator = ProjectAnalyticsAttentionPagination(selection) page = paginator.paginate_queryset(rows, request, view=self) response = paginator.get_paginated_response( @@ -519,7 +520,10 @@ def get(self, request, pk: int): field = get_program_case_field(program) query = ProjectCaseQuerySerializer( data=request.query_params, - context={"case_options": field.get_options_list() if field else []}, + context={ + "case_options": field.get_options_list() if field else [], + "submission_applicable": program.is_competitive, + }, ) query.is_valid(raise_exception=True) export_file = build_case_projects_export_file(