diff --git a/docs/project-analytics-api.md b/docs/project-analytics-api.md index 2ebe1cdd..21bc3b48 100644 --- a/docs/project-analytics-api.md +++ b/docs/project-analytics-api.md @@ -1,9 +1,11 @@ -# Legacy Project Analytics (B5 + B6a) +# Legacy Project Analytics (B5 + B6a + B6b) `GET /programs//project-analytics/` is the manager-facing overview for the legacy Angular `Project` flow. It is a semantic production port of DEV #721 and #723. B6a ports assignment drilldowns and delayed experts from DEV #724 -into `/project-analytics/`, not the DEV `/manager-overview/` namespace. +into `/project-analytics/`. B6b ports attention drilldowns, not-submitted work +and case analytics from DEV #725, #726 and #730. None of these contracts uses +the DEV `/manager-overview/` namespace. ## Two independent domains @@ -20,9 +22,9 @@ creating an Application or Submission alone does not change legacy metrics. ## Access -All three endpoints reuse production `ProgramPermissionMixin`, `IsAuthenticated` and -`IsAdminOrManagerOfProgram`. A manager of the requested program, staff or -superuser can read it. Participant-only, expert-only and managers of other +All six endpoints reuse production `ProgramPermissionMixin`, `IsAuthenticated` +and `IsAdminOrManagerOfProgram`. A manager of the requested program, staff or +superuser can read them. Participant-only, expert-only and managers of other programs receive 403; anonymous receives 401; an unknown program receives 404. POST, PUT, PATCH and DELETE are not supported (405 for an authorized caller). GET has no analytics/lifecycle writes. Standard HEAD/OPTIONS are supported. @@ -67,11 +69,23 @@ Example with an empty program (the real `activity` array always has 30 entries): "attention": { "participants_without_team": 0, "projects_awaiting_evaluation": 0, + "projects_not_submitted": {"applicable": false, "total": 0}, "delayed_experts": {"total": 0, "items": []} }, "activity": [ {"date": "2026-06-15", "registrations": 0, "submitted_solutions": 0} - ] + ], + "cases": { + "configured": false, + "submission_applicable": false, + "items": [], + "without_case": { + "participants_total": 0, + "projects_total": 0, + "not_submitted": 0, + "submitted": 0 + } + } } ``` @@ -117,7 +131,12 @@ Only submitted links can be `evaluated`. `participants_without_team` is unique participants minus `with_team`. `projects_awaiting_evaluation` includes awaiting plus partially evaluated links. -These are counters only, without attention detail endpoints. +Both counters have matching detail endpoints described below. + +`attention.projects_not_submitted` is applicable only to competitive programs. +Its total reuses `solution_funnel.not_submitted`, so overview performs no extra +query for that counter. A noncompetitive program reports +`{"applicable": false, "total": 0}` without changing the raw solution funnel. ## Evaluation semantics @@ -155,9 +174,11 @@ today, ascending, including zero days. Registrations use profile and other programs are excluded. Filtering and grouping use Django's active timezone/local-date semantics. -The service uses eight SELECTs with correlated subqueries and aggregates, +The service uses twelve SELECTs with correlated subqueries and aggregates, without per-participant/project/assignment queries. The manager HTTP endpoint -uses ten queries including production access checks. Regression fixtures with +uses fourteen queries including production access checks. Four of those SELECTs +are the bounded case definition, grouped-link, registered-leader and +registered-collaborator queries. Regression fixtures with 0, 1 and 31 participants/projects/regions/assignments have the same query count in open and distributed modes. Returned region cardinality and in-memory row processing can still grow with program size; fixed SQL count does not mean @@ -169,6 +190,136 @@ overview counters, by-project completion and delayed experts. B5 has no second completion algorithm or duplicate assignment SELECT. Regions, funnels, activity and the existing attention counters keep their B5 semantics. +## Attention drilldowns + +The three attention lists use limit/offset pagination. `limit` defaults to 25 +and must be from 1 through 100; `offset` defaults to 0 and must be nonnegative. +Invalid values return 400 before count/page SQL. `search` is trimmed and applied +in SQL before count and pagination. An offset beyond the result set returns 200, +the filtered count and an empty `results` array. `next` and `previous` preserve +the search value. Each endpoint has a fixed four-query manager-request budget +for one or 31 rows, and its explicit allow-list serializer performs no SQL. + +### Participants without a team + +`GET /programs//project-analytics/participants-without-team/` + +One result is one distinct non-null user registered in this program who is +neither a Project leader nor a Collaborator on a Project linked to this program. +`PartnerProgramUserProfile.project`, production Team/TeamMember, Invite and +projects linked only to another program do not count as a team. Historical +duplicate registrations collapse to the minimum registration timestamp. + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [{ + "user_id": 123, + "full_name": "Anna Petrova", + "avatar": null, + "city": "Moscow", + "registered_at": "2026-09-01T12:00:00Z" + }] +} +``` + +Search covers first name, last name and their full-name combination, but not +email, city or private questionnaire fields. Blank names use the localized +fallback `Участник №`. Results order by +`registered_at`, then `user_id`. Without search, `count` equals +`attention.participants_without_team` on the same database snapshot. + +### Projects awaiting evaluation + +`GET /programs//project-analytics/projects-awaiting-evaluation/` + +The unit is one submitted current-program `PartnerProgramProject`, not an +assignment. The top-level `mode` is `open` or `distributed`. Results contain +only project ID/name and leader ID/name/avatar, plus the link's submission time: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [{ + "program_project_id": 70, + "project": {"id": 55, "name": "Project A"}, + "leader": {"user_id": 123, "full_name": "Anna Petrova", "avatar": null}, + "submitted_at": "2026-09-01T12:00:00Z", + "status": "awaiting_evaluation", + "reason": "no_assignments", + "reason_label": "Эксперты не назначены", + "assignments_total": 0, + "assignments_completed": 0 + }], + "mode": "distributed" +} +``` + +The actual reason labels are localized. Controlled reasons are +`no_assignments`, `no_completed_evaluations`, `partially_evaluated` and +`awaiting_first_evaluation`. In distributed mode, no assignments, zero complete +assignments and partial completion are included; a link whose every real +assignment is complete is excluded. Completion comes from the same annotated +queryset as the B6a assignment list: submitted current-program link, nonempty +current criteria, and a score from the assigned expert for every criterion. +In open mode a submitted link remains until its Project has the first score for +a current-program criterion; assignment totals are null. Search covers only +Project name. Results order by submission time, then link PK. + +### Projects not submitted + +`GET /programs//project-analytics/projects-not-submitted/` + +For a competitive program, one result is one current-program link whose raw +`submitted` flag is false. Project draft/public state, team, assignments and +scores do not alter membership. Search covers only Project name; results order +by link creation time, then link PK. `linked_at` is the link timestamp. + +The standard pagination envelope also includes: + +```json +{ + "applicable": true, + "submission_deadline": "2026-09-30T20:59:59Z", + "submission_open": true +} +``` + +Deadline and open state come directly from +`get_project_submission_deadline()` and `is_project_submission_open()`. For a +noncompetitive program the list is empty, `applicable` and `submission_open` +are false, and the deadline is null. This does not rewrite the raw solution +funnel. + +## Case analytics + +Overview `cases` groups current-program `PartnerProgramProject` links using +only the exact system field selected by `get_program_case_field()` +(`name="case"`). Labels, field type, filter visibility and case-insensitive +names are not identity heuristics. Current options retain configuration order +and zero-count rows. If the field is absent, `configured` is false, `items` is +empty and all links belong to `without_case`. + +Each bucket contains `participants_total`, `projects_total`, `not_submitted` +and `submitted`. Project counts use raw link submission state even for a +noncompetitive program; `submission_applicable` only tells the client whether +submission is a meaningful program workflow. Missing, blank, whitespace, +obsolete or otherwise non-current values belong to `without_case`. + +Participants are distinct users registered in the current program who lead or +collaborate on a Project in that bucket. Orphan profiles, Invite, +Team/TeamMember and users registered only elsewhere are excluded. A user is +deduplicated within a bucket but may appear in more than one bucket. + +Every link belongs to exactly one project bucket, so item totals plus +`without_case` reconcile to `solution_funnel.created`, `submitted` and +`not_submitted`. A Project linked to programs A and B has independent link, +submission and case values in each program. + ## Assignment list `GET /programs//project-analytics/assignments/` @@ -265,15 +416,15 @@ SLA. Items sort critical first, then oldest waiting seconds descending, then expert ID ascending. All counts and seconds are nonnegative; assignment waiting fields are nullable. Serializers have strict status/severity choices and no SQL. -SQL budgets (manager requests): list **3**, scores **5**, overview **10**. +SQL budgets (manager requests): assignment list **3**, scores **5**, overview +**14**, and each attention detail list **4**. List/overview counts are unchanged for 1 versus 31 assignments; scores are -unchanged for 1 versus 20 criteria. The overview service remains **8 SELECTs**. +unchanged for 1 versus 20 criteria. The overview service uses **12 SELECTs**. Delayed aggregation and serialization add no queries. ## Scope No models, migrations, scoring writes, deadline or lifecycle/permission changes. -No B6b attention-list endpoints, projects-not-submitted object or case analytics. Foundation #732-#735, production `/manager-overview/`, `/submission-assignments/` and `/evaluations/`, frontend, dependencies and deployment remain untouched. Production Application/Team/Submission/SubmissionExpertAssignment/Evaluation diff --git a/partner_programs/pagination.py b/partner_programs/pagination.py index 77252350..de44aa4b 100644 --- a/partner_programs/pagination.py +++ b/partner_programs/pagination.py @@ -13,3 +13,19 @@ class PartnerProgramPagination(pagination.LimitOffsetPagination): default_limit = 10 limit_query_param = "limit" offset_query_param = "offset" + + +class ProjectAnalyticsAttentionPagination(pagination.LimitOffsetPagination): + """Pagination with parameters validated before count and page queries.""" + + default_limit = 25 + max_limit = 100 + + def __init__(self, query): + self.query = query + + def get_limit(self, request): + return self.query["limit"] + + def get_offset(self, request): + return self.query["offset"] diff --git a/partner_programs/project_analytics_views.py b/partner_programs/project_analytics_views.py index 72cc3bc9..03b42cba 100644 --- a/partner_programs/project_analytics_views.py +++ b/partner_programs/project_analytics_views.py @@ -1,18 +1,33 @@ from django.shortcuts import get_object_or_404 from django.utils import timezone +from django.db.models import F, Value +from django.db.models.functions import Concat from drf_yasg.utils import swagger_auto_schema from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from partner_programs.permissions import IsAdminOrManagerOfProgram +from partner_programs.pagination import ProjectAnalyticsAttentionPagination +from partner_programs.serializers.project_analytics_attention import ( + ProjectAnalyticsAttentionQuerySerializer, + ProjectAnalyticsAwaitingProjectSerializer, + ProjectAnalyticsNotSubmittedMetadataSerializer, + ProjectAnalyticsNotSubmittedProjectSerializer, + ProjectAnalyticsParticipantSerializer, +) from partner_programs.serializers.project_analytics import ProjectAnalyticsSerializer from partner_programs.serializers.project_assignment_analytics import ( ProjectAssignmentAnalyticsSerializer, ProjectAssignmentScopeSerializer, ProjectAssignmentScoresSerializer, ) -from partner_programs.services.project_analytics import build_project_analytics +from partner_programs.services.project_analytics import ( + build_project_analytics, + participants_without_team_rows, + projects_awaiting_evaluation_rows, + projects_not_submitted_rows, +) from partner_programs.services.project_assignment_analytics import ( assignment_rows, build_assignment, @@ -71,3 +86,79 @@ def get(self, request, program_id, assignment_id): assignment = build_assignment(row, now=timezone.now()) assignment["scores"] = build_assignment_scores(self.program.pk, assignment) return Response(ProjectAssignmentScoresSerializer(assignment).data) + + +class ProjectAnalyticsAttentionListAPIView(ProgramPermissionMixin, APIView): + permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] + http_method_names = ["get", "head", "options"] + include_mode = False + + def get(self, request, program_id): + query = ProjectAnalyticsAttentionQuerySerializer(data=request.query_params.dict()) + query.is_valid(raise_exception=True) + paginator = ProjectAnalyticsAttentionPagination(query.validated_data) + queryset = self.get_queryset(query.validated_data["search"]) + page = paginator.paginate_queryset(queryset, request, view=self) + response = paginator.get_paginated_response( + self.serializer_class(page, many=True).data + ) + if self.include_mode: + response.data["mode"] = ( + "distributed" if self.program.is_distributed_evaluation else "open" + ) + response.data.update(self.get_metadata()) + return response + + def get_metadata(self): + return {} + + +class ProjectAnalyticsParticipantsWithoutTeamAPIView( + ProjectAnalyticsAttentionListAPIView +): + serializer_class = ProjectAnalyticsParticipantSerializer + + def get_queryset(self, search): + queryset = participants_without_team_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 +): + serializer_class = ProjectAnalyticsAwaitingProjectSerializer + include_mode = True + + def get_queryset(self, search): + queryset = projects_awaiting_evaluation_rows(self.program) + if search: + queryset = queryset.filter(project__name__icontains=search) + return queryset.order_by(F("datetime_submitted").asc(nulls_last=True), "pk") + + +class ProjectAnalyticsProjectsNotSubmittedAPIView(ProjectAnalyticsAttentionListAPIView): + serializer_class = ProjectAnalyticsNotSubmittedProjectSerializer + + def get_queryset(self, search): + queryset = projects_not_submitted_rows(self.program) + if search: + queryset = queryset.filter(project__name__icontains=search) + return queryset.order_by("datetime_created", "pk") + + def get_metadata(self): + applicable = self.program.is_competitive + return ProjectAnalyticsNotSubmittedMetadataSerializer( + { + "applicable": applicable, + "submission_deadline": ( + self.program.get_project_submission_deadline() if applicable else None + ), + "submission_open": ( + self.program.is_project_submission_open() if applicable else False + ), + } + ).data diff --git a/partner_programs/serializers/project_analytics.py b/partner_programs/serializers/project_analytics.py index c72efe14..6ac5cb63 100644 --- a/partner_programs/serializers/project_analytics.py +++ b/partner_programs/serializers/project_analytics.py @@ -63,9 +63,15 @@ class ProjectAnalyticsEvaluationSerializer(serializers.Serializer): projects = ProjectAnalyticsProjectsSerializer() +class ProjectAnalyticsNotSubmittedCountSerializer(serializers.Serializer): + applicable = serializers.BooleanField() + total = serializers.IntegerField(min_value=0) + + class ProjectAnalyticsAttentionSerializer(serializers.Serializer): participants_without_team = serializers.IntegerField(min_value=0) projects_awaiting_evaluation = serializers.IntegerField(min_value=0) + projects_not_submitted = ProjectAnalyticsNotSubmittedCountSerializer() delayed_experts = ProjectDelayedExpertsSerializer() @@ -75,6 +81,24 @@ class ProjectAnalyticsActivitySerializer(serializers.Serializer): submitted_solutions = serializers.IntegerField(min_value=0) +class ProjectCaseAnalyticsMetricsSerializer(serializers.Serializer): + participants_total = serializers.IntegerField(min_value=0) + projects_total = serializers.IntegerField(min_value=0) + not_submitted = serializers.IntegerField(min_value=0) + submitted = serializers.IntegerField(min_value=0) + + +class ProjectCaseAnalyticsItemSerializer(ProjectCaseAnalyticsMetricsSerializer): + name = serializers.CharField(trim_whitespace=False) + + +class ProjectCaseAnalyticsSerializer(serializers.Serializer): + configured = serializers.BooleanField() + submission_applicable = serializers.BooleanField() + items = ProjectCaseAnalyticsItemSerializer(many=True) + without_case = ProjectCaseAnalyticsMetricsSerializer() + + class ProjectAnalyticsSerializer(serializers.Serializer): summary = ProjectAnalyticsSummarySerializer() participant_funnel = ProjectAnalyticsParticipantFunnelSerializer() @@ -82,3 +106,4 @@ class ProjectAnalyticsSerializer(serializers.Serializer): evaluation_status = ProjectAnalyticsEvaluationSerializer() attention = ProjectAnalyticsAttentionSerializer() activity = ProjectAnalyticsActivitySerializer(many=True) + cases = ProjectCaseAnalyticsSerializer() diff --git a/partner_programs/serializers/project_analytics_attention.py b/partner_programs/serializers/project_analytics_attention.py new file mode 100644 index 00000000..954e7405 --- /dev/null +++ b/partner_programs/serializers/project_analytics_attention.py @@ -0,0 +1,90 @@ +"""Explicit SQL-free contracts for legacy Project analytics attention lists.""" + +from rest_framework import serializers + + +def participant_name(user_id, first_name, last_name): + name = " ".join( + part.strip() for part in (first_name, last_name) if part and part.strip() + ) + return name or f"Участник №{user_id}" + + +class ProjectAnalyticsAttentionQuerySerializer(serializers.Serializer): + 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 ProjectAnalyticsParticipantSerializer(serializers.Serializer): + user_id = serializers.IntegerField() + full_name = serializers.SerializerMethodField() + avatar = serializers.SerializerMethodField() + city = serializers.SerializerMethodField() + registered_at = serializers.DateTimeField(allow_null=True) + + def get_full_name(self, row): + return participant_name( + row["user_id"], row["user__first_name"], row["user__last_name"] + ) + + def get_avatar(self, row): + return row["user__avatar"] or None + + def get_city(self, row): + return row["user__city"] or None + + +class ProjectAnalyticsProjectSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField(allow_blank=True) + + +class ProjectAnalyticsLeaderSerializer(serializers.Serializer): + user_id = serializers.IntegerField(source="id") + full_name = serializers.SerializerMethodField() + avatar = serializers.SerializerMethodField() + + def get_full_name(self, user): + return participant_name(user.pk, user.first_name, user.last_name) + + def get_avatar(self, user): + return user.avatar or None + + +WAITING_REASONS = { + "no_assignments": "Эксперты не назначены", + "no_completed_evaluations": "Нет завершённых оценок", + "partially_evaluated": "Частично оценено", + "awaiting_first_evaluation": "Ожидает первой оценки", +} + + +class ProjectAnalyticsAwaitingProjectSerializer(serializers.Serializer): + program_project_id = serializers.IntegerField(source="pk") + project = ProjectAnalyticsProjectSerializer() + leader = ProjectAnalyticsLeaderSerializer(source="project.leader", allow_null=True) + submitted_at = serializers.DateTimeField(source="datetime_submitted", allow_null=True) + status = serializers.ChoiceField( + choices=("awaiting_evaluation", "partially_evaluated") + ) + reason = serializers.ChoiceField(choices=tuple(WAITING_REASONS)) + reason_label = serializers.SerializerMethodField() + assignments_total = serializers.IntegerField(min_value=0, allow_null=True) + assignments_completed = serializers.IntegerField(min_value=0, allow_null=True) + + def get_reason_label(self, link): + return WAITING_REASONS[link.reason] + + +class ProjectAnalyticsNotSubmittedProjectSerializer(serializers.Serializer): + program_project_id = serializers.IntegerField(source="pk") + project = ProjectAnalyticsProjectSerializer() + leader = ProjectAnalyticsLeaderSerializer(source="project.leader", allow_null=True) + linked_at = serializers.DateTimeField(source="datetime_created") + + +class ProjectAnalyticsNotSubmittedMetadataSerializer(serializers.Serializer): + applicable = serializers.BooleanField() + submission_deadline = serializers.DateTimeField(allow_null=True) + submission_open = serializers.BooleanField() diff --git a/partner_programs/services/project_analytics.py b/partner_programs/services/project_analytics.py index 8924fb5e..0c2618ae 100644 --- a/partner_programs/services/project_analytics.py +++ b/partner_programs/services/project_analytics.py @@ -3,27 +3,44 @@ from collections import Counter, defaultdict from datetime import timedelta -from django.db.models import Count, Exists, OuterRef, Q -from django.db.models.functions import TruncDate +from django.db.models import ( + Case, + CharField, + Count, + Exists, + F, + IntegerField, + Min, + OuterRef, + Q, + Subquery, + Value, + When, +) +from django.db.models.functions import Coalesce, TruncDate from django.utils import timezone from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile from partner_programs.services.project_assignment_analytics import ( + annotated_assignment_queryset, build_assignments, build_delayed_experts, ) +from partner_programs.services.project_case_analytics import ( + build_project_case_analytics, +) from project_rates.models import ProjectScore from projects.models import Collaborator ACTIVITY_DAYS = 30 -def _participant_metrics(program_id): +def _participant_profiles(program_id): links = PartnerProgramProject.objects.filter( partner_program_id=program_id, project__leader_id=OuterRef("user_id"), ) - profiles = PartnerProgramUserProfile.objects.filter( + return PartnerProgramUserProfile.objects.filter( partner_program_id=program_id ).annotate( is_leader=Exists(links), @@ -35,6 +52,30 @@ def _participant_metrics(program_id): ) ), ) + + +def _without_team_filter(): + return Q(user_id__isnull=False, is_leader=False, is_collaborator=False) + + +def participants_without_team_rows(program_id): + return ( + _participant_profiles(program_id) + .filter(_without_team_filter()) + .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"), @@ -85,6 +126,114 @@ def _assignment_metrics(assignments): return metrics, by_project +def _annotated_solution_rows(program): + """SQL classification for paginated attention, using shared completion.""" + rows = PartnerProgramProject.objects.filter(partner_program_id=program.pk) + if program.is_distributed_evaluation: + assignment_totals = ( + annotated_assignment_queryset(program.pk) + .filter(project_id=OuterRef("project_id")) + .order_by() + .values("project_id") + .annotate( + total=Count("pk"), + completed=Count("pk", filter=Q(is_completed=True)), + ) + ) + rows = rows.annotate( + assignments_total=Coalesce( + Subquery(assignment_totals.values("total")[:1]), 0 + ), + assignments_completed=Coalesce( + Subquery(assignment_totals.values("completed")[:1]), 0 + ), + ) + evaluated_status = Case( + When( + Q(assignments_total=0) | Q(assignments_completed=0), + then=Value("awaiting_evaluation"), + ), + When( + assignments_completed__lt=F("assignments_total"), + then=Value("partially_evaluated"), + ), + default=Value("evaluated"), + output_field=CharField(), + ) + waiting_reason = Case( + When(assignments_total=0, then=Value("no_assignments")), + When( + assignments_completed=0, + then=Value("no_completed_evaluations"), + ), + default=Value("partially_evaluated"), + output_field=CharField(), + ) + else: + rows = rows.annotate( + assignments_total=Value(None, output_field=IntegerField()), + assignments_completed=Value(None, output_field=IntegerField()), + has_program_score=Exists( + ProjectScore.objects.filter( + project_id=OuterRef("project_id"), + criteria__partner_program_id=program.pk, + ) + ), + ) + evaluated_status = Case( + When(has_program_score=True, then=Value("evaluated")), + default=Value("awaiting_evaluation"), + output_field=CharField(), + ) + waiting_reason = Value("awaiting_first_evaluation", output_field=CharField()) + return rows.annotate( + status=Case( + When(submitted=False, then=Value("not_submitted")), + default=evaluated_status, + output_field=CharField(), + ), + reason=waiting_reason, + ) + + +def projects_awaiting_evaluation_rows(program): + return ( + _annotated_solution_rows(program) + .filter(status__in=("awaiting_evaluation", "partially_evaluated")) + .select_related("project", "project__leader") + .only( + "id", + "project_id", + "datetime_submitted", + "project__name", + "project__leader_id", + "project__leader__id", + "project__leader__first_name", + "project__leader__last_name", + "project__leader__avatar", + ) + ) + + +def projects_not_submitted_rows(program): + rows = PartnerProgramProject.objects.filter( + partner_program_id=program.pk, submitted=False + ) + if not program.is_competitive: + return rows.none() + return rows.select_related("project", "project__leader").only( + "id", + "project_id", + "datetime_created", + "project__name", + "project__leader_id", + "project__leader__id", + "project__leader__first_name", + "project__leader__last_name", + "project__leader__avatar", + ) + + def _solution_metrics(program, assignments): rows = ( PartnerProgramProject.objects.filter(partner_program_id=program.pk) @@ -165,6 +314,7 @@ def build_project_analytics(program) -> dict: assignment_details = build_assignments(program.pk) assignments, by_project = _assignment_metrics(assignment_details) solutions = _solution_metrics(program, by_project) + cases = build_project_case_analytics(program) return { "summary": { "participants": {"total": participants["unique_participants"]}, @@ -209,6 +359,10 @@ def build_project_analytics(program) -> dict: "projects_awaiting_evaluation": ( solutions["awaiting_evaluation"] + solutions["partially_evaluated"] ), + "projects_not_submitted": { + "applicable": program.is_competitive, + "total": solutions["not_submitted"] if program.is_competitive else 0, + }, "delayed_experts": ( build_delayed_experts(assignment_details) if program.is_distributed_evaluation @@ -216,4 +370,5 @@ def build_project_analytics(program) -> dict: ), }, "activity": _activity(program.pk), + "cases": cases, } diff --git a/partner_programs/services/project_assignment_analytics.py b/partner_programs/services/project_assignment_analytics.py index c1042e10..5b476858 100644 --- a/partner_programs/services/project_assignment_analytics.py +++ b/partner_programs/services/project_assignment_analytics.py @@ -1,6 +1,15 @@ """Read-only legacy Project assignments, independent of the Application domain.""" -from django.db.models import BooleanField, Count, OuterRef, Subquery, Value +from django.db.models import ( + BooleanField, + Case, + Count, + F, + OuterRef, + Subquery, + Value, + When, +) from django.db.models.functions import Coalesce from django.utils import timezone @@ -8,8 +17,8 @@ from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore -def assignment_rows(program_id): - """One SELECT with program-scoped subqueries and only safe DTO fields.""" +def annotated_assignment_queryset(program_id): + """Authoritative program-scoped completion annotations for legacy assignments.""" criteria = ( Criteria.objects.filter(partner_program_id=program_id) .order_by() @@ -41,6 +50,25 @@ def assignment_rows(program_id): ), project_submitted_at=Subquery(link.values("datetime_submitted")[:1]), ) + .annotate( + is_completed=Case( + When( + project_submitted=True, + criteria_total__gt=0, + criteria_scored__gte=F("criteria_total"), + then=Value(True), + ), + default=Value(False), + output_field=BooleanField(), + ) + ) + ) + + +def assignment_rows(program_id): + """One SELECT with shared completion and only safe assignment DTO fields.""" + return ( + annotated_assignment_queryset(program_id) .order_by("pk") .values( "id", @@ -56,6 +84,7 @@ def assignment_rows(program_id): "criteria_scored", "project_submitted", "project_submitted_at", + "is_completed", ) ) @@ -65,7 +94,7 @@ def build_assignment(row, *, now): total, scored = row["criteria_total"], row["criteria_scored"] if not row["project_submitted"]: status = "not_ready" - elif total > 0 and scored >= total: + elif row["is_completed"]: status = "completed" elif total > 0 and scored > 0: status = "in_progress" diff --git a/partner_programs/services/project_case_analytics.py b/partner_programs/services/project_case_analytics.py new file mode 100644 index 00000000..979f5ebc --- /dev/null +++ b/partner_programs/services/project_case_analytics.py @@ -0,0 +1,76 @@ +"""Read-only case analytics for legacy Project links in one program.""" + +from django.db.models import Count, OuterRef, Subquery, TextField, Value + +from partner_programs.models import ( + PartnerProgramFieldValue, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from partner_programs.services.case_fields import get_program_case_field + + +def _empty_metrics(): + return { + "participants_total": 0, + "projects_total": 0, + "not_submitted": 0, + "submitted": 0, + } + + +def build_project_case_analytics(program) -> dict: + """Group current-program links by exact current system case options. + + Four bounded SELECTs load the case definition, grouped link counts, + registered leaders and registered collaborators. Every link belongs to one + project bucket; participants are unique inside each bucket. + """ + field = get_program_case_field(program) + options = field.get_options_list() if field else [] + buckets = {name: _empty_metrics() for name in options} + without_case = _empty_metrics() + participant_ids = {name: set() for name in buckets} + participant_ids[None] = set() + + case_value = ( + Subquery( + PartnerProgramFieldValue.objects.filter( + program_project_id=OuterRef("pk"), field_id=field.pk + ).values("value_text")[:1] + ) + if field + else Value(None, output_field=TextField()) + ) + links = ( + PartnerProgramProject.objects.filter(partner_program_id=program.pk) + .order_by() + .annotate(case_value=case_value) + ) + for row in links.values("case_value", "submitted").annotate(total=Count("pk")): + metrics = buckets.get(row["case_value"], without_case) + metrics["projects_total"] += row["total"] + metrics["submitted" if row["submitted"] else "not_submitted"] += row["total"] + + registered_users = PartnerProgramUserProfile.objects.filter( + partner_program_id=program.pk, user_id__isnull=False + ).values("user_id") + for user_path in ("project__leader_id", "project__collaborator__user_id"): + pairs = ( + links.filter(**{f"{user_path}__in": registered_users}) + .values_list("case_value", user_path) + .distinct() + ) + for value, user_id in pairs: + bucket = value if value in buckets else None + participant_ids[bucket].add(user_id) + + for name, metrics in buckets.items(): + metrics["participants_total"] = len(participant_ids[name]) + without_case["participants_total"] = len(participant_ids[None]) + return { + "configured": field is not None, + "submission_applicable": program.is_competitive, + "items": [{"name": name, **metrics} for name, metrics in buckets.items()], + "without_case": without_case, + } diff --git a/partner_programs/tests/test_project_analytics_api.py b/partner_programs/tests/test_project_analytics_api.py index 6ac00bdd..24d15aa9 100644 --- a/partner_programs/tests/test_project_analytics_api.py +++ b/partner_programs/tests/test_project_analytics_api.py @@ -133,6 +133,7 @@ def test_empty_contract_has_b5_fields_additive_delays_and_thirty_zero_days(self) "evaluation_status", "attention", "activity", + "cases", }, ) self.assertEqual( @@ -164,9 +165,24 @@ def test_empty_contract_has_b5_fields_additive_delays_and_thirty_zero_days(self) { "participants_without_team": 0, "projects_awaiting_evaluation": 0, + "projects_not_submitted": {"applicable": False, "total": 0}, "delayed_experts": {"total": 0, "items": []}, }, ) + self.assertEqual( + payload["cases"], + { + "configured": False, + "submission_applicable": False, + "items": [], + "without_case": { + "participants_total": 0, + "projects_total": 0, + "not_submitted": 0, + "submitted": 0, + }, + }, + ) today = timezone.localdate() self.assertEqual( payload["activity"], @@ -682,6 +698,6 @@ def test_query_budget_is_fixed_for_empty_small_and_large_programs_in_both_modes( self.assertEqual( payload["evaluation_status"]["assignments"]["total"], size ) - self.assertEqual(set(counts.values()), {10}, counts) - with self.assertNumQueries(8): + self.assertEqual(set(counts.values()), {14}, counts) + with self.assertNumQueries(12): build_project_analytics(self.program) diff --git a/partner_programs/tests/test_project_analytics_attention_api.py b/partner_programs/tests/test_project_analytics_attention_api.py new file mode 100644 index 00000000..d72758fe --- /dev/null +++ b/partner_programs/tests/test_project_analytics_attention_api.py @@ -0,0 +1,706 @@ +from datetime import datetime, timedelta, timezone as datetime_timezone +from types import SimpleNamespace +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +from django.core.cache import cache +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from django.utils.dateparse import parse_datetime +from rest_framework.test import APIClient + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from partner_programs.serializers.project_analytics_attention import ( + ProjectAnalyticsAwaitingProjectSerializer, + ProjectAnalyticsNotSubmittedProjectSerializer, + ProjectAnalyticsParticipantSerializer, +) +from partner_programs.services.project_analytics import ( + participants_without_team_rows, + projects_awaiting_evaluation_rows, + projects_not_submitted_rows, +) +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 +from projects.models import Collaborator, Project + +NOW = datetime(2026, 9, 10, 12, tzinfo=datetime_timezone.utc) +PARTICIPANTS = "project-analytics-participants-without-team" +AWAITING = "project-analytics-projects-awaiting-evaluation" +NOT_SUBMITTED = "project-analytics-projects-not-submitted" +ENDPOINTS = (PARTICIPANTS, AWAITING, NOT_SUBMITTED) + + +class ProjectAnalyticsAttentionFixture: + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.manager = create_user(password=None) + cls.program = create_partner_program( + is_competitive=True, + is_distributed_evaluation=True, + max_project_rates=3, + ) + cls.program.managers.add(cls.manager) + cls.other_program = create_partner_program( + is_competitive=True, is_distributed_evaluation=True + ) + cls.expert = create_rate_expert(program=cls.program) + cls.program.criterias.all().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): + super().setUp() + cache.clear() + self.addCleanup(cache.clear) + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def url(self, endpoint, program=None): + return reverse( + f"partner_programs:{endpoint}", + kwargs={"program_id": (program or self.program).pk}, + ) + + def get(self, endpoint, program=None, **params): + response = self.client.get(self.url(endpoint, program), params) + self.assertEqual(response.status_code, 200, response.data) + return response.json() + + def overview(self, program=None): + response = self.client.get( + reverse( + "partner_programs:project-analytics", + kwargs={"program_id": (program or self.program).pk}, + ) + ) + self.assertEqual(response.status_code, 200, response.data) + return response.json() + + def participant(self, *, program=None, user=None, hours=1, **user_fields): + user = user or create_user(password=None, **user_fields) + profile = create_program_member(program or self.program, user=user) + PartnerProgramUserProfile.objects.filter(pk=profile.pk).update( + datetime_created=NOW - timedelta(hours=hours) + ) + return profile + + def work( + self, + *, + program=None, + submitted=True, + linked_hours=2, + submitted_hours=1, + project=None, + **project_fields, + ): + link = create_program_project( + program or self.program, + project=project or create_project(**project_fields), + submitted=submitted, + ) + PartnerProgramProject.objects.filter(pk=link.pk).update( + datetime_created=NOW - timedelta(hours=linked_hours), + datetime_submitted=( + NOW - timedelta(hours=submitted_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 set_open_mode(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + is_distributed_evaluation=False + ) + self.program.is_distributed_evaluation = False + + +class ProjectAnalyticsAttentionAccessTests(ProjectAnalyticsAttentionFixture, TestCase): + def test_manager_staff_and_superuser_can_read_all_lists(self): + for user in ( + self.manager, + create_user(password=None, is_staff=True), + create_user(password=None, is_superuser=True, is_staff=False), + ): + self.client.force_authenticate(user) + for endpoint in ENDPOINTS: + with self.subTest(user=user.pk, endpoint=endpoint): + self.assertEqual(self.client.get(self.url(endpoint)).status_code, 200) + + def test_participant_expert_other_manager_and_unrelated_user_get_403(self): + participant = self.participant().user + other_manager = create_user(password=None) + self.other_program.managers.add(other_manager) + for user in ( + participant, + self.expert, + other_manager, + create_user(password=None), + ): + self.client.force_authenticate(user) + for endpoint in ENDPOINTS: + with self.subTest(user=user.pk, endpoint=endpoint): + self.assertEqual( + self.client.get( + self.url(endpoint), {"search": "Private"} + ).status_code, + 403, + ) + + def test_anonymous_gets_401_and_unknown_program_gets_404(self): + self.client.force_authenticate(None) + for endpoint in ENDPOINTS: + self.assertEqual(self.client.get(self.url(endpoint)).status_code, 401) + self.client.force_authenticate(self.manager) + for endpoint in ENDPOINTS: + url = reverse(f"partner_programs:{endpoint}", kwargs={"program_id": 99999999}) + self.assertEqual(self.client.get(url).status_code, 404) + + def test_mutating_methods_are_405_and_do_not_change_links_or_profiles(self): + profile = self.participant() + link = self.work(submitted=False) + for endpoint in ENDPOINTS: + for method in ("post", "put", "patch", "delete"): + with self.subTest(endpoint=endpoint, method=method): + response = getattr(self.client, method)( + self.url(endpoint), {"submitted": True}, format="json" + ) + self.assertEqual(response.status_code, 405) + self.assertTrue(PartnerProgramUserProfile.objects.filter(pk=profile.pk).exists()) + link.refresh_from_db() + self.assertFalse(link.submitted) + + def test_invalid_pagination_and_valid_empty_search_on_all_lists(self): + for endpoint in ENDPOINTS: + for field, values in ( + ("limit", (0, -1, 101, "abc", "1.5", "")), + ("offset", (-1, "abc", "1.5", "")), + ): + for value in values: + with self.subTest(endpoint=endpoint, field=field, value=value): + response = self.client.get(self.url(endpoint), {field: value}) + self.assertEqual(response.status_code, 400) + self.assertIn(field, response.data) + self.assertEqual( + self.get(endpoint, search=" ")["results"], + self.get(endpoint)["results"], + ) + + def test_offset_beyond_count_preserves_count_and_returns_empty_page(self): + self.participant() + self.work() + self.work(submitted=False) + for endpoint in ENDPOINTS: + data = self.get(endpoint, offset=999) + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"], []) + + +class ParticipantsWithoutTeamTests(ProjectAnalyticsAttentionFixture, TestCase): + def test_only_unique_registered_users_without_current_program_team_are_listed(self): + included = self.participant(first_name="Анна", last_name="Петрова") + leader = self.participant().user + collaborator = self.participant().user + leader_link = self.work(leader=leader) + Collaborator.objects.create(project=leader_link.project, user=collaborator) + profile_only = self.participant() + PartnerProgramUserProfile.objects.filter(pk=profile_only.pk).update( + project=leader_link.project + ) + other_only = self.participant().user + self.participant(program=self.other_program, user=other_only) + other_link = self.work(program=self.other_program) + Collaborator.objects.create(project=other_link.project, user=other_only) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, user=None, partner_program_data={} + ) + data = self.get(PARTICIPANTS) + self.assertEqual(data["count"], 3) + self.assertEqual( + {item["user_id"] for item in data["results"]}, + {included.user_id, profile_only.user_id, other_only.pk}, + ) + self.assertEqual( + data["count"], self.overview()["attention"]["participants_without_team"] + ) + + def test_leader_counts_as_team_without_collaborator_row(self): + profile = self.participant() + link = self.work(leader=profile.user) + Collaborator.objects.filter(project=link.project, user=profile.user).delete() + self.assertEqual(self.get(PARTICIPANTS)["count"], 0) + + def test_safe_fields_fallback_name_and_current_program_registration_time(self): + profile = self.participant( + first_name=" ", last_name="", city=None, avatar=None, hours=10 + ) + profile.partner_program_data = { + "email": "secret@example.com", + "phone": "private", + } + profile.save(update_fields=["partner_program_data"]) + self.participant(program=self.other_program, user=profile.user, hours=100) + type(profile.user).objects.filter(pk=profile.user_id).update( + datetime_created=NOW - timedelta(days=365) + ) + item = self.get(PARTICIPANTS)["results"][0] + self.assertEqual( + set(item), {"user_id", "full_name", "avatar", "city", "registered_at"} + ) + self.assertEqual(item["full_name"], f"Участник №{profile.user_id}") + self.assertIsNone(item["avatar"]) + self.assertIsNone(item["city"]) + self.assertEqual(parse_datetime(item["registered_at"]), NOW - timedelta(hours=10)) + + def test_search_uses_first_last_and_full_name_but_not_private_fields(self): + target = self.participant( + first_name="Alice", last_name="UniqueSmith", city="SecretCity" + ) + target.partner_program_data = {"answer": "PrivateNeedle"} + target.save(update_fields=["partner_program_data"]) + self.participant(first_name="Bob", last_name="Jones") + for search in (" alice ", "uniquesmith", "Alice UniqueSmith", "LIC"): + data = self.get(PARTICIPANTS, search=search) + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["user_id"], target.user_id) + for search in (target.user.email, "SecretCity", "PrivateNeedle"): + self.assertEqual(self.get(PARTICIPANTS, search=search)["count"], 0) + + def test_stable_order_pagination_links_and_default_limit(self): + profiles = [ + self.participant(first_name="Needle", hours=hours) for hours in (1, 2, 2) + ] + expected = [profiles[1].user_id, profiles[2].user_id, profiles[0].user_id] + first = self.get(PARTICIPANTS, search=" Needle ", limit=2) + self.assertEqual([item["user_id"] for item in first["results"]], expected[:2]) + self.assertEqual( + parse_qs(urlparse(first["next"]).query), + {"search": [" Needle "], "limit": ["2"], "offset": ["2"]}, + ) + second = self.get(PARTICIPANTS, search="Needle", limit=2, offset=2) + self.assertEqual(second["results"][0]["user_id"], expected[2]) + for _ in range(23): + self.participant() + page = self.get(PARTICIPANTS) + self.assertEqual(page["count"], 26) + self.assertEqual(len(page["results"]), 25) + + def test_deleted_user_registration_is_not_disclosed(self): + profile = self.participant() + profile.user.delete() + self.assertEqual(self.get(PARTICIPANTS)["count"], 0) + + +class ProjectsAwaitingEvaluationTests(ProjectAnalyticsAttentionFixture, TestCase): + def test_distributed_reasons_and_completion_parity_with_assignment_list(self): + no_assignments = self.work(name="No assignments") + none_complete = self.work(name="None complete") + self.assignment(none_complete) + partial_score = self.work(name="Partial score") + partial_assignment = self.assignment(partial_score) + self.score(partial_score, criteria=self.criteria[:1]) + partly_evaluated = self.work(name="Partly evaluated") + completed_assignment = self.assignment(partly_evaluated) + self.score(partly_evaluated) + self.assignment(partly_evaluated, expert=create_rate_expert(program=self.program)) + fully_evaluated = self.work(name="Fully evaluated") + final_assignment = self.assignment(fully_evaluated) + self.score(fully_evaluated) + + data = self.get(AWAITING, limit=100) + self.assertEqual(data["mode"], "distributed") + by_id = {item["program_project_id"]: item for item in data["results"]} + self.assertEqual( + set(by_id), + {no_assignments.pk, none_complete.pk, partial_score.pk, partly_evaluated.pk}, + ) + self.assertEqual( + ( + by_id[no_assignments.pk]["reason"], + by_id[no_assignments.pk]["reason_label"], + ), + ("no_assignments", "Эксперты не назначены"), + ) + for link in (none_complete, partial_score): + self.assertEqual(by_id[link.pk]["status"], "awaiting_evaluation") + self.assertEqual(by_id[link.pk]["reason"], "no_completed_evaluations") + self.assertEqual(by_id[link.pk]["reason_label"], "Нет завершённых оценок") + self.assertEqual(by_id[partly_evaluated.pk]["status"], "partially_evaluated") + self.assertEqual(by_id[partly_evaluated.pk]["reason"], "partially_evaluated") + self.assertEqual(by_id[partly_evaluated.pk]["reason_label"], "Частично оценено") + + assignments = self.client.get( + reverse( + "partner_programs:project-analytics-assignments", + kwargs={"program_id": self.program.pk}, + ) + ).json() + status_by_assignment = { + item["assignment_id"]: item["status"] for item in assignments + } + self.assertEqual(status_by_assignment[partial_assignment.pk], "in_progress") + self.assertEqual(status_by_assignment[completed_assignment.pk], "completed") + self.assertEqual(status_by_assignment[final_assignment.pk], "completed") + self.assertEqual( + by_id[partly_evaluated.pk]["assignments_completed"], + sum( + status_by_assignment[item.pk] == "completed" + for item in ProjectExpertAssignment.objects.filter( + partner_program=self.program, project=partly_evaluated.project + ) + ), + ) + self.assertEqual( + data["count"], self.overview()["attention"]["projects_awaiting_evaluation"] + ) + + def test_zero_criteria_foreign_scores_and_maximum_do_not_complete(self): + link = self.work() + self.assignment(link) + other_expert = create_rate_expert(program=self.program) + self.score(link, expert=other_expert) + foreign = Criteria.objects.create( + partner_program=self.other_program, name="Foreign", type="str" + ) + self.score(link, criteria=[foreign]) + PartnerProgram.objects.filter(pk=self.program.pk).update(max_project_rates=1) + self.assertEqual(self.get(AWAITING)["results"][0]["assignments_completed"], 0) + self.program.criterias.all().delete() + self.assertEqual( + self.get(AWAITING)["results"][0]["reason"], + "no_completed_evaluations", + ) + + def test_open_mode_uses_first_current_program_score_and_null_progress(self): + self.set_open_mode() + waiting = self.work(name="Waiting") + evaluated = self.work(name="Evaluated") + self.score(evaluated, criteria=self.criteria[:1]) + foreign_score = self.work(name="Foreign score") + foreign = Criteria.objects.create( + partner_program=self.other_program, name="Foreign", type="str" + ) + self.score(foreign_score, criteria=[foreign]) + self.assignment(waiting) + data = self.get(AWAITING) + self.assertEqual(data["mode"], "open") + by_id = {item["program_project_id"]: item for item in data["results"]} + self.assertEqual(set(by_id), {waiting.pk, foreign_score.pk}) + for item in by_id.values(): + self.assertEqual(item["status"], "awaiting_evaluation") + self.assertEqual(item["reason"], "awaiting_first_evaluation") + self.assertEqual(item["reason_label"], "Ожидает первой оценки") + self.assertIsNone(item["assignments_total"]) + self.assertIsNone(item["assignments_completed"]) + + def test_unsubmitted_and_not_ready_are_excluded(self): + link = self.work(submitted=False) + self.assignment(link) + self.score(link) + self.assertEqual(self.get(AWAITING)["count"], 0) + + def test_safe_contract_search_and_order_use_project_and_submission_only(self): + leader = create_user( + password=None, first_name="Анна", last_name="Иванова", avatar=None + ) + newest = self.work(leader=leader, name="Unique Project", submitted_hours=1) + older = self.work(name="Other", description="Unique Project", submitted_hours=2) + tied = self.work(name="Third", submitted_hours=2) + unknown = self.work(name="Unknown", submitted_hours=3) + PartnerProgramProject.objects.filter(pk=unknown.pk).update( + datetime_submitted=None + ) + data = self.get(AWAITING, search=" UNIQUE ") + self.assertEqual(data["count"], 1) + item = data["results"][0] + self.assertEqual( + set(item), + { + "program_project_id", + "project", + "leader", + "submitted_at", + "status", + "reason", + "reason_label", + "assignments_total", + "assignments_completed", + }, + ) + self.assertEqual( + item["project"], {"id": newest.project_id, "name": "Unique Project"} + ) + self.assertEqual( + item["leader"], + {"user_id": leader.pk, "full_name": "Анна Иванова", "avatar": None}, + ) + self.assertEqual(self.get(AWAITING, search=leader.email)["count"], 0) + self.assertEqual( + [item["program_project_id"] for item in self.get(AWAITING)["results"]], + [older.pk, tied.pk, newest.pk, unknown.pk], + ) + + def test_multi_program_submission_assignment_criteria_and_score_are_independent(self): + shared = create_project() + link_a = self.work(project=shared, submitted=False) + link_b = self.work(program=self.other_program, project=shared, submitted=True) + other_expert = create_rate_expert(program=self.other_program) + ProjectExpertAssignment.objects.create( + partner_program=self.other_program, + project=shared, + expert=other_expert.expert, + ) + other_criterion = self.other_program.criterias.order_by("pk").first() + ProjectScore.objects.create( + project=shared, + user=other_expert, + criteria=other_criterion, + value="done", + ) + self.assertEqual(self.get(AWAITING)["count"], 0) + self.other_program.managers.add(self.manager) + other_data = self.get(AWAITING, program=self.other_program) + self.assertEqual(other_data["count"], 0) + PartnerProgramProject.objects.filter(pk=link_a.pk).update(submitted=True) + self.assertEqual( + self.get(AWAITING)["results"][0]["program_project_id"], link_a.pk + ) + self.assertNotEqual(link_a.pk, link_b.pk) + + def test_defensive_null_leader_serialization_is_sql_free(self): + link = SimpleNamespace( + pk=1, + project=SimpleNamespace(id=2, name="Legacy", leader=None), + datetime_submitted=None, + status="awaiting_evaluation", + reason="no_assignments", + assignments_total=0, + assignments_completed=0, + ) + with self.assertNumQueries(0): + payload = ProjectAnalyticsAwaitingProjectSerializer(link).data + self.assertIsNone(payload["leader"]) + self.assertIsNone(payload["submitted_at"]) + + +class ProjectsNotSubmittedTests(ProjectAnalyticsAttentionFixture, TestCase): + def test_competitive_list_uses_raw_current_program_links_and_matches_overview(self): + included = self.work(submitted=False, draft=True, is_public=False) + self.work(submitted=True) + self.work(program=self.other_program, submitted=False, name="Foreign") + create_program_project( + self.other_program, project=included.project, submitted=True + ) + data = self.get(NOT_SUBMITTED) + self.assertTrue(data["applicable"]) + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["program_project_id"], included.pk) + overview = self.overview() + self.assertEqual(data["count"], overview["solution_funnel"]["not_submitted"]) + self.assertEqual( + overview["attention"]["projects_not_submitted"], + {"applicable": True, "total": data["count"]}, + ) + + def test_noncompetitive_is_not_applicable_without_changing_raw_funnel(self): + self.work(submitted=False) + PartnerProgram.objects.filter(pk=self.program.pk).update(is_competitive=False) + self.program.is_competitive = False + self.assertEqual( + self.get(NOT_SUBMITTED), + { + "count": 0, + "next": None, + "previous": None, + "results": [], + "applicable": False, + "submission_deadline": None, + "submission_open": False, + }, + ) + overview = self.overview() + self.assertEqual(overview["solution_funnel"]["not_submitted"], 1) + self.assertEqual( + overview["attention"]["projects_not_submitted"], + {"applicable": False, "total": 0}, + ) + + def test_deadline_and_open_metadata_use_program_methods(self): + for explicit, registration, expected, is_open in ( + ( + NOW + timedelta(days=1), + NOW - timedelta(days=1), + NOW + timedelta(days=1), + True, + ), + ( + NOW - timedelta(seconds=1), + NOW + timedelta(days=1), + NOW - timedelta(seconds=1), + False, + ), + (None, NOW, NOW, True), + ): + self.program.datetime_project_submission_ends = explicit + self.program.datetime_registration_ends = registration + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_project_submission_ends=explicit, + datetime_registration_ends=registration, + ) + with self.subTest(explicit=explicit): + with patch("partner_programs.models.timezone.now", return_value=NOW): + data = self.get(NOT_SUBMITTED) + self.assertEqual(parse_datetime(data["submission_deadline"]), expected) + self.assertEqual(data["submission_open"], is_open) + + def test_safe_contract_link_date_search_scope_and_stable_order(self): + leader = create_user( + password=None, first_name="Анна", last_name="Петрова", avatar=None + ) + newest = self.work( + submitted=False, leader=leader, name="Unique Project", linked_hours=1 + ) + older = self.work( + submitted=False, + name="Other", + description="Unique Project", + linked_hours=2, + ) + tied = self.work(submitted=False, name="Third", linked_hours=2) + data = self.get(NOT_SUBMITTED, search=" UNIQUE ") + self.assertEqual(data["count"], 1) + item = data["results"][0] + self.assertEqual( + set(item), {"program_project_id", "project", "leader", "linked_at"} + ) + self.assertEqual( + item["project"], {"id": newest.project_id, "name": "Unique Project"} + ) + self.assertEqual( + item["leader"], + {"user_id": leader.pk, "full_name": "Анна Петрова", "avatar": None}, + ) + project_created = NOW - timedelta(days=100) + Project.objects.filter(pk=newest.project_id).update( + datetime_created=project_created + ) + self.assertEqual(parse_datetime(item["linked_at"]), NOW - timedelta(hours=1)) + for search in (leader.first_name, leader.email, "Unique Project"): + count = self.get(NOT_SUBMITTED, search=search)["count"] + self.assertEqual(count, int(search == "Unique Project")) + self.assertEqual( + [item["program_project_id"] for item in self.get(NOT_SUBMITTED)["results"]], + [older.pk, tied.pk, newest.pk], + ) + + def test_pagination_preserves_search_and_supports_limit_100(self): + links = [ + self.work(submitted=False, name="Needle", linked_hours=hours) + for hours in range(31, 0, -1) + ] + first = self.get(NOT_SUBMITTED, search=" Needle ", limit=25) + self.assertEqual(first["count"], 31) + self.assertEqual(len(first["results"]), 25) + self.assertEqual( + parse_qs(urlparse(first["next"]).query), + {"search": [" Needle "], "limit": ["25"], "offset": ["25"]}, + ) + self.assertEqual(len(self.get(NOT_SUBMITTED, limit=100)["results"]), 31) + self.assertEqual(first["results"][0]["program_project_id"], links[0].pk) + + def test_defensive_null_leader_serializer_does_no_sql(self): + link = SimpleNamespace( + pk=1, + project=SimpleNamespace(id=2, name="Legacy", leader=None), + datetime_created=NOW, + ) + with self.assertNumQueries(0): + payload = ProjectAnalyticsNotSubmittedProjectSerializer(link).data + self.assertIsNone(payload["leader"]) + + +class ProjectAnalyticsAttentionQueryTests(ProjectAnalyticsAttentionFixture, TestCase): + def test_serializers_do_no_sql_for_selected_rows(self): + self.participant() + submitted = self.work() + draft = self.work(submitted=False) + fixtures = ( + ( + participants_without_team_rows(self.program.pk), + ProjectAnalyticsParticipantSerializer, + ), + ( + projects_awaiting_evaluation_rows(self.program), + ProjectAnalyticsAwaitingProjectSerializer, + ), + ( + projects_not_submitted_rows(self.program), + ProjectAnalyticsNotSubmittedProjectSerializer, + ), + ) + self.assertTrue(submitted.pk and draft.pk) + for queryset, serializer_class in fixtures: + rows = list(queryset) + with self.subTest( + serializer=serializer_class.__name__ + ), self.assertNumQueries(0): + self.assertEqual(len(serializer_class(rows, many=True).data), 1) + + def test_each_endpoint_has_fixed_four_queries_for_one_and_thirty_one_rows(self): + self.participant(first_name="Scale") + self.work(name="Scale") + self.work(submitted=False, name="Scale") + small = {} + for endpoint in ENDPOINTS: + with CaptureQueriesContext(connection) as queries: + self.get(endpoint, limit=100, search="Scale") + small[endpoint] = len(queries) + for _ in range(30): + self.participant(first_name="Scale") + self.work(name="Scale") + self.work(submitted=False, name="Scale") + for endpoint in ENDPOINTS: + with CaptureQueriesContext(connection) as queries: + data = self.get(endpoint, limit=100, search="Scale") + self.assertEqual(data["count"], 31) + self.assertEqual(len(data["results"]), 31) + self.assertEqual(len(queries), small[endpoint]) + self.assertEqual(len(queries), 4) diff --git a/partner_programs/tests/test_project_assignment_analytics_api.py b/partner_programs/tests/test_project_assignment_analytics_api.py index 40a4a9d3..2a0267a5 100644 --- a/partner_programs/tests/test_project_assignment_analytics_api.py +++ b/partner_programs/tests/test_project_assignment_analytics_api.py @@ -622,7 +622,7 @@ def test_legacy_data_does_not_change_production_manager_assignment_or_evaluation class ProjectAssignmentContractTests(ProjectAssignmentAnalyticsFixture, TestCase): - def test_all_b5_fields_are_preserved_apart_from_additive_delayed_experts(self): + def test_all_b5_b6a_fields_are_preserved_apart_from_b6b_additions(self): assignments = [ self.assignment(submitted=False), self.assignment(), @@ -651,6 +651,8 @@ def test_all_b5_fields_are_preserved_apart_from_additive_delayed_experts(self): payload = self.get(self.overview_url) self.assertIn("delayed_experts", payload["attention"]) del payload["attention"]["delayed_experts"] + del payload["attention"]["projects_not_submitted"] + del payload["cases"] evaluated = 1 if distributed else 2 self.assertEqual( payload, @@ -805,7 +807,7 @@ def test_fixed_query_budgets_one_vs_thirty_one_assignments(self): ) for url, budget in ( (self.url, 3), - (self.overview_url, 10), + (self.overview_url, 14), (self.scores_url(first.pk), 5), ): with self.subTest(size=size, url=url), CaptureQueriesContext( @@ -813,7 +815,7 @@ def test_fixed_query_budgets_one_vs_thirty_one_assignments(self): ) as queries: self.get(url) self.assertEqual(len(queries), budget) - with self.assertNumQueries(8): + with self.assertNumQueries(12): build_project_analytics(self.program) def test_scores_query_budget_one_vs_twenty_criteria(self): diff --git a/partner_programs/tests/test_project_case_analytics.py b/partner_programs/tests/test_project_case_analytics.py new file mode 100644 index 00000000..b382d6e1 --- /dev/null +++ b/partner_programs/tests/test_project_case_analytics.py @@ -0,0 +1,433 @@ +from copy import deepcopy + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from rest_framework.test import APIClient + +from invites.models import Invite +from partner_programs.models import ( + Application, + Evaluation, + PartnerProgram, + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, + PartnerProgramUserProfile, + Submission, + SubmissionExpertAssignment, + Team, + TeamMember, +) +from partner_programs.serializers.project_analytics import ( + ProjectAnalyticsSerializer, + ProjectCaseAnalyticsSerializer, +) +from partner_programs.services.project_case_analytics import ( + build_project_case_analytics, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_member, + create_program_project, + create_project, + create_user, +) +from partner_programs.tests.test_case_fields import create_case_field +from project_rates.models import ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator + + +class ProjectCaseAnalyticsFixture: + def setUp(self): + super().setUp() + self.program = create_partner_program(is_competitive=True) + self.manager = create_user(password=None) + self.program.managers.add(self.manager) + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def overview(self, program=None): + response = self.client.get( + reverse( + "partner_programs:project-analytics", + kwargs={"program_id": (program or self.program).pk}, + ) + ) + self.assertEqual(response.status_code, 200, response.data) + self.assert_reconciled(response.json()) + return response.json() + + def link( + self, + field=None, + value=None, + *, + program=None, + submitted=False, + project=None, + ): + program = program or self.program + link = create_program_project( + program, project=project or create_project(), submitted=submitted + ) + if field is not None and value is not None: + # Seed historical invalid values without weakening current write guards. + PartnerProgramFieldValue.objects.bulk_create( + [ + PartnerProgramFieldValue( + program_project=link, field=field, value_text=value + ) + ] + ) + return link + + @staticmethod + def metrics(projects, submitted, participants=0): + return { + "participants_total": participants, + "projects_total": projects, + "not_submitted": projects - submitted, + "submitted": submitted, + } + + def assert_reconciled(self, overview): + buckets = [*overview["cases"]["items"], overview["cases"]["without_case"]] + for bucket in buckets: + self.assertEqual( + bucket["projects_total"], + bucket["not_submitted"] + bucket["submitted"], + ) + for metric, funnel_metric in ( + ("projects_total", "created"), + ("submitted", "submitted"), + ("not_submitted", "not_submitted"), + ): + self.assertEqual( + sum(bucket[metric] for bucket in buckets), + overview["solution_funnel"][funnel_metric], + ) + + +class ProjectCaseAnalyticsTests(ProjectCaseAnalyticsFixture, TestCase): + def test_options_keep_configuration_order_zero_rows_and_reconcile(self): + field = create_case_field(self.program, options=["Case C", "Case A", "Case B"]) + self.link(field, "Case A", submitted=True) + self.link(field, "Case A") + self.link(field, "Case B", submitted=True) + self.link() + self.link(field, "Obsolete") + cases = self.overview()["cases"] + self.assertEqual( + cases, + { + "configured": True, + "submission_applicable": True, + "items": [ + {"name": "Case C", **self.metrics(0, 0)}, + {"name": "Case A", **self.metrics(2, 1)}, + {"name": "Case B", **self.metrics(1, 1)}, + ], + "without_case": self.metrics(2, 0), + }, + ) + + def test_only_exact_system_name_identifies_case_without_flag_heuristics(self): + for name in ("Case", "CASE", "Кейс", "case_name", "cases"): + program = create_partner_program(is_competitive=True) + program.managers.add(self.manager) + field = create_program_field( + program, + name=name, + label="Кейс", + field_type="select", + options=["A"], + show_filter=True, + ) + self.link(field, "A", program=program) + with self.subTest(name=name): + cases = self.overview(program)["cases"] + self.assertFalse(cases["configured"]) + self.assertEqual(cases["items"], []) + self.assertEqual(cases["without_case"], self.metrics(1, 0)) + + field = create_case_field(self.program, label="Unrelated label") + self.link(field, "A") + PartnerProgramField.objects.filter(pk=field.pk).update( + field_type="text", is_required=False, show_filter=False + ) + cases = self.overview()["cases"] + self.assertTrue(cases["configured"]) + self.assertEqual(cases["items"][0]["projects_total"], 1) + + def test_missing_blank_whitespace_obsolete_and_nonexact_values_are_without_case(self): + field = create_case_field(self.program) + for index, value in enumerate((None, "", " ", "Legacy", "a", " A ")): + self.link(field, value, submitted=index % 2 == 0) + cases = self.overview()["cases"] + self.assertEqual(cases["without_case"], self.metrics(6, 3)) + self.assertTrue(all(item["projects_total"] == 0 for item in cases["items"])) + + def test_absent_definition_places_every_link_and_team_participant_without_case(self): + user = create_user(password=None) + create_program_member(self.program, user=user) + for index in range(4): + self.link(project=create_project(leader=user), submitted=index < 2) + self.assertEqual( + self.overview()["cases"], + { + "configured": False, + "submission_applicable": True, + "items": [], + "without_case": self.metrics(4, 2, 1), + }, + ) + + def test_participants_are_registered_team_users_deduplicated_per_bucket(self): + field = create_case_field(self.program) + leader, collaborator, outsider, invited, profile_only = [ + create_user(password=None) for _ in range(5) + ] + for user in (leader, collaborator, invited, profile_only): + create_program_member(self.program, user=user) + create_program_member(create_partner_program(), user=outsider) + first = self.link(field, "A", project=create_project(leader=leader)) + second = self.link(field, "A", project=create_project(leader=leader)) + third = self.link(field, "B", project=create_project(leader=leader)) + without = self.link(project=create_project(leader=leader)) + for project in (first.project, second.project): + Collaborator.objects.create(project=project, user=collaborator) + Collaborator.objects.bulk_create( + [Collaborator(project=first.project, user=outsider)] + ) + Collaborator.objects.filter(project=second.project, user=leader).delete() + Invite.objects.create(project=first.project, user=invited) + PartnerProgramUserProfile.objects.filter( + partner_program=self.program, user=profile_only + ).update(project=first.project) + cases = self.overview()["cases"] + self.assertEqual(cases["items"][0], {"name": "A", **self.metrics(2, 0, 2)}) + self.assertEqual(cases["items"][1], {"name": "B", **self.metrics(1, 0, 1)}) + self.assertEqual(cases["without_case"], self.metrics(1, 0, 1)) + self.assertTrue(third.pk and without.pk) + + def test_one_registered_user_can_count_once_in_multiple_case_buckets(self): + field = create_case_field(self.program) + user = create_user(password=None) + create_program_member(self.program, user=user) + for value in ("A", "A", "B", None, "old", ""): + self.link(field, value, project=create_project(leader=user)) + cases = self.overview()["cases"] + self.assertEqual( + [item["participants_total"] for item in cases["items"]], [1, 1, 0] + ) + self.assertEqual(cases["without_case"], self.metrics(3, 0, 1)) + self.assertEqual(self.overview()["summary"]["participants"]["total"], 1) + + def test_noncompetitive_keeps_raw_case_and_funnel_counts(self): + PartnerProgram.objects.filter(pk=self.program.pk).update(is_competitive=False) + self.program.is_competitive = False + field = create_case_field(self.program) + self.link( + field, + "A", + submitted=True, + project=create_project(draft=True, is_public=False), + ) + draft = self.link( + field, + "A", + project=create_project(draft=False, is_public=True), + ) + PartnerProgramProject.objects.filter(pk=draft.pk).update( + datetime_submitted=self.program.datetime_started + ) + overview = self.overview() + self.assertFalse(overview["cases"]["submission_applicable"]) + self.assertEqual( + overview["cases"]["items"][0], {"name": "A", **self.metrics(2, 1)} + ) + self.assertEqual(overview["solution_funnel"]["not_submitted"], 1) + self.assertEqual( + overview["attention"]["projects_not_submitted"], + {"applicable": False, "total": 0}, + ) + + def test_same_project_in_two_programs_has_independent_values_and_submitted_state( + self, + ): + field_a = create_case_field(self.program, options=["A", "B"]) + other = create_partner_program(is_competitive=True) + other.managers.add(self.manager) + field_b = create_case_field(other, options=["A", "B"]) + user = create_user(password=None) + for program in (self.program, other): + create_program_member(program, user=user) + shared = create_project(leader=user) + self.link(field_a, "A", project=shared, submitted=True) + self.link(field_b, "B", program=other, project=shared, submitted=False) + case_a, case_b = self.overview()["cases"], self.overview(other)["cases"] + self.assertEqual(case_a["items"][0], {"name": "A", **self.metrics(1, 1, 1)}) + self.assertEqual(case_a["items"][1], {"name": "B", **self.metrics(0, 0)}) + self.assertEqual(case_b["items"][0], {"name": "A", **self.metrics(0, 0)}) + self.assertEqual(case_b["items"][1], {"name": "B", **self.metrics(1, 0, 1)}) + + def test_case_service_is_exactly_four_queries_for_one_and_twenty_options(self): + field = create_case_field(self.program, options=["A"]) + user = create_user(password=None) + create_program_member(self.program, user=user) + self.link(field, "A", project=create_project(leader=user)) + counts = [] + for options in (["A"], ["A", *[f"Case {index}" for index in range(19)]]): + PartnerProgramField.objects.filter(pk=field.pk).update( + options="|".join(options) + ) + with CaptureQueriesContext(connection) as queries: + cases = build_project_case_analytics(self.program) + counts.append(len(queries)) + self.assertEqual(len(cases["items"]), len(options)) + self.assertEqual(counts, [4, 4]) + + def test_case_and_root_serializers_are_sql_free_and_reject_negative_counts(self): + create_case_field(self.program) + payload = self.overview() + with self.assertNumQueries(0): + root = ProjectAnalyticsSerializer(data=payload) + self.assertTrue(root.is_valid(), root.errors) + self.assertEqual(root.data, payload) + for bucket_index, bucket in enumerate( + [*payload["cases"]["items"], payload["cases"]["without_case"]] + ): + for metric in self.metrics(0, 0): + invalid = deepcopy(payload["cases"]) + target = ( + invalid["items"][bucket_index] + if bucket_index < len(invalid["items"]) + else invalid["without_case"] + ) + target[metric] = -1 + with self.subTest( + bucket=bucket_index, metric=metric + ), self.assertNumQueries(0): + self.assertFalse( + ProjectCaseAnalyticsSerializer(data=invalid).is_valid() + ) + + +class ProjectAnalyticsCrossDomainTests(ProjectCaseAnalyticsFixture, TestCase): + def attention_urls(self): + return [ + reverse( + f"partner_programs:project-analytics-{suffix}", + kwargs={"program_id": self.program.pk}, + ) + for suffix in ( + "participants-without-team", + "projects-awaiting-evaluation", + "projects-not-submitted", + ) + ] + + def create_production_domain(self): + user = create_user(password=None) + expert_user = create_rate_expert(program=self.program) + application = Application.objects.create( + program=self.program, + user=user, + created_by=user, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ) + team = Team.objects.create(application=application, captain=user) + TeamMember.objects.create( + team=team, + user=user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + submission = Submission.objects.create( + application=application, + program=self.program, + submitted_by=user, + title="Production only", + status=Submission.STATUS_SUBMITTED, + ) + assignment = SubmissionExpertAssignment.objects.create( + submission=submission, + expert=expert_user.expert, + assigned_by=self.manager, + ) + evaluation = Evaluation.objects.create( + submission=submission, expert=expert_user.expert + ) + return expert_user, assignment, evaluation + + def test_production_entities_do_not_change_attention_lists_or_cases(self): + before_lists = [self.client.get(url).content for url in self.attention_urls()] + before_cases = self.overview()["cases"] + self.create_production_domain() + self.assertEqual( + [self.client.get(url).content for url in self.attention_urls()], + before_lists, + ) + self.assertEqual(self.overview()["cases"], before_cases) + + def test_legacy_entities_do_not_change_production_apis(self): + expert_user, assignment, evaluation = self.create_production_domain() + urls = [ + reverse( + "partner_programs:manager-overview", + kwargs={"program_id": self.program.pk}, + ), + reverse( + "partner_programs:submission-assignment-list-create", + kwargs={"program_id": self.program.pk}, + ), + reverse( + "partner_programs:evaluation-list", + kwargs={"program_id": self.program.pk}, + ), + reverse( + "partner_programs:evaluation-detail", + kwargs={ + "program_id": self.program.pk, + "evaluation_id": evaluation.pk, + }, + ), + ] + before = [self.client.get(url).content for url in urls] + field = create_case_field(self.program) + link = self.link(field, "A", submitted=True) + legacy_assignment = ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=link.project, + expert=expert_user.expert, + ) + criterion = self.program.criterias.order_by("pk").first() + ProjectScore.objects.create( + project=link.project, + user=expert_user, + criteria=criterion, + value="legacy", + ) + self.assertEqual([self.client.get(url).content for url in urls], before) + self.assertTrue(legacy_assignment.pk and assignment.pk) + + def test_legacy_services_never_query_production_domain_tables(self): + self.create_production_domain() + with CaptureQueriesContext(connection) as queries: + build_project_case_analytics(self.program) + for url in self.attention_urls(): + self.client.get(url) + for model in ( + Application, + Team, + TeamMember, + Submission, + SubmissionExpertAssignment, + Evaluation, + ): + for query in queries: + self.assertNotIn(f'"{model._meta.db_table}"', query["sql"]) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 135b5765..5802bb3b 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -19,6 +19,9 @@ ProjectAnalyticsAPIView, ProjectAnalyticsAssignmentsAPIView, ProjectAnalyticsAssignmentScoresAPIView, + ProjectAnalyticsParticipantsWithoutTeamAPIView, + ProjectAnalyticsProjectsAwaitingEvaluationAPIView, + ProjectAnalyticsProjectsNotSubmittedAPIView, ) from partner_programs.submission_assignment_views import ( ProgramSubmissionAssignmentListCreateView, @@ -71,6 +74,21 @@ ProjectAnalyticsAssignmentScoresAPIView.as_view(), name="project-analytics-assignment-scores", ), + path( + "/project-analytics/participants-without-team/", + ProjectAnalyticsParticipantsWithoutTeamAPIView.as_view(), + name="project-analytics-participants-without-team", + ), + path( + "/project-analytics/projects-awaiting-evaluation/", + ProjectAnalyticsProjectsAwaitingEvaluationAPIView.as_view(), + name="project-analytics-projects-awaiting-evaluation", + ), + path( + "/project-analytics/projects-not-submitted/", + ProjectAnalyticsProjectsNotSubmittedAPIView.as_view(), + name="project-analytics-projects-not-submitted", + ), path( "/evaluations/", ProgramEvaluationListView.as_view(),