diff --git a/docs/modules/project-activity-count.md b/docs/modules/project-activity-count.md new file mode 100644 index 00000000..19586093 --- /dev/null +++ b/docs/modules/project-activity-count.md @@ -0,0 +1,38 @@ +# Статистика активности пользователя по проектам + +Источник данных — legacy endpoint `GET /projects/count/`. Он возвращает +совместимые с прежним контрактом поля `all` и `my`, а также три показателя +жизненного цикла проектов пользователя: + +```json +{ + "all": 123, + "my": 7, + "my_leader": 4, + "my_in_program": 2, + "my_submitted": 1 +} +``` + +- `my` — число уникальных legacy `Project`, где пользователь является + лидером или участником `Collaborator`. +- `my_leader` — число проектов, где пользователь указан в `Project.leader`. +- `my_in_program` — число опубликованных проектов пользователя, у которых + каноническая связь с программой существует и ещё не сдана. +- `my_submitted` — число проектов пользователя, у которых каноническая связь + помечена как сданная. Сданный проект учитывается независимо от `draft`, + поскольку состояние «Сдано» имеет приоритет над «Черновиком». + +Канонической считается запись `PartnerProgramProject` с минимальным `pk` +для проекта. Это совпадает с правилом, которое используется legacy-карточками +и detail проекта при наличии нескольких связей с программами. + +Расчёт выполняется на backend одним агрегирующим SQL-запросом с условными +`Count` и `Subquery`. Его нельзя строить по данным dashboard: Angular +загружает там только первые 16 проектов, поэтому клиентский расчёт давал бы +неполную статистику. Проверка на PostgreSQL подтверждает один запрос как для +одного, так и для 31 проекта. + +Расчёт не обращается к React-домену и не меняет права, submit-логику или модели +`Application`, `Team`, `Submission`, `Evaluation` и +`SubmissionExpertAssignment`. diff --git a/projects/managers.py b/projects/managers.py index 3365570b..878a4992 100644 --- a/projects/managers.py +++ b/projects/managers.py @@ -1,5 +1,5 @@ from django.contrib.auth import get_user_model -from django.db.models import Manager +from django.db.models import Count, Manager, OuterRef, Q, Subquery User = get_user_model() @@ -48,6 +48,57 @@ def get_projects_for_detail_view(self): def get_projects_for_count_view(self): return self.get_queryset().only("id", "leader_id") + def get_user_activity_counts(self, user): + """Считает проектную активность пользователя одним SQL-запросом. + + Для lifecycle используется первая legacy-связь с программой по минимальному + ``PartnerProgramProject.pk`` — тот же canonical link, который возвращают + карточки и detail проекта. Агрегация выполняется на backend, потому что + dashboard загружает только первые 16 проектов пользователя. + """ + from partner_programs.models import PartnerProgramProject + + canonical_program_link = PartnerProgramProject.objects.filter( + project_id=OuterRef("pk") + ).order_by("pk") + user_projects = Q(leader_id=user.id) | Q(collaborator__user_id=user.id) + + return ( + self.get_queryset() + .annotate( + canonical_program_link_id=Subquery( + canonical_program_link.values("pk")[:1] + ), + canonical_program_link_submitted=Subquery( + canonical_program_link.values("submitted")[:1] + ), + ) + .aggregate( + all=Count( + "pk", + filter=Q(draft=False, is_public=True), + distinct=True, + ), + my=Count("pk", filter=user_projects, distinct=True), + my_leader=Count("pk", filter=Q(leader_id=user.id), distinct=True), + my_in_program=Count( + "pk", + filter=( + user_projects + & Q(canonical_program_link_id__isnull=False) + & Q(canonical_program_link_submitted=False) + & Q(draft=False) + ), + distinct=True, + ), + my_submitted=Count( + "pk", + filter=user_projects & Q(canonical_program_link_submitted=True), + distinct=True, + ), + ) + ) + def get_projects_from_list_of_ids(self, ids): return self.get_queryset().filter(id__in=ids) diff --git a/projects/tests/test_project_count.py b/projects/tests/test_project_count.py new file mode 100644 index 00000000..4c974cf9 --- /dev/null +++ b/projects/tests/test_project_count.py @@ -0,0 +1,114 @@ +from django.db import connection +from django.test.utils import CaptureQueriesContext +from rest_framework import status +from rest_framework.test import APITestCase + +from projects.tests.helpers import ( + create_collaborator, + create_partner_program, + create_project, + create_user, + link_project_to_program, +) + + +class ProjectCountViewTests(APITestCase): + endpoint = "/projects/count/" + + def setUp(self): + self.user = create_user(prefix="project-count-user") + self.client.force_authenticate(self.user) + + def get_count(self): + response = self.client.get(self.endpoint) + self.assertEqual(response.status_code, status.HTTP_200_OK) + return response.data + + def test_user_without_projects_has_zero_activity(self): + self.assertEqual( + self.get_count(), + { + "all": 0, + "my": 0, + "my_leader": 0, + "my_in_program": 0, + "my_submitted": 0, + }, + ) + + def test_counts_leader_collaborator_and_does_not_duplicate_project(self): + leader_project = create_project(leader=self.user, draft=False) + create_collaborator(leader_project, user=self.user) + collaborator_project = create_project(draft=False) + create_collaborator(collaborator_project, user=self.user) + create_project(draft=False) + + count = self.get_count() + + self.assertEqual(count["my"], 2) + self.assertEqual(count["my_leader"], 1) + self.assertEqual(count["all"], 3) + + def test_counts_project_lifecycle_by_canonical_program_link(self): + program = create_partner_program(name="Lifecycle") + second_program = create_partner_program(name="Lifecycle second") + + create_project(leader=self.user, draft=True) + create_project(leader=self.user, draft=False) + + in_program = create_project(leader=self.user, draft=False) + link_project_to_program(in_program, program, submitted=False) + + draft_in_program = create_project(leader=self.user, draft=True) + link_project_to_program(draft_in_program, program, submitted=False) + + submitted = create_project(leader=self.user, draft=False) + link_project_to_program(submitted, program, submitted=True) + + submitted_draft = create_project(leader=self.user, draft=True) + link_project_to_program(submitted_draft, program, submitted=True) + + canonical_not_submitted = create_project(leader=self.user, draft=False) + link_project_to_program(canonical_not_submitted, program, submitted=False) + link_project_to_program(canonical_not_submitted, second_program, submitted=True) + + canonical_submitted = create_project(leader=self.user, draft=False) + link_project_to_program(canonical_submitted, program, submitted=True) + link_project_to_program(canonical_submitted, second_program, submitted=False) + + count = self.get_count() + + self.assertEqual(count["my"], 8) + self.assertEqual(count["my_leader"], 8) + self.assertEqual(count["my_in_program"], 2) + self.assertEqual(count["my_submitted"], 3) + + def test_ignores_other_users_projects_and_counts_more_than_dashboard_page(self): + for index in range(17): + create_project(leader=self.user, name=f"Owned {index}", draft=False) + + collaborator_project = create_project(draft=False) + create_collaborator(collaborator_project, user=self.user) + create_project(draft=False) + + count = self.get_count() + + self.assertEqual(count["my"], 18) + self.assertEqual(count["my_leader"], 17) + + def test_query_count_does_not_grow_with_project_count(self): + create_project(leader=self.user, draft=False) + with CaptureQueriesContext(connection) as one_project_queries: + self.get_count() + + for index in range(30): + create_project(leader=self.user, name=f"Scale {index}", draft=False) + with CaptureQueriesContext(connection) as thirty_one_project_queries: + count = self.get_count() + + self.assertEqual(count["my"], 31) + self.assertEqual(len(one_project_queries), 1) + self.assertEqual( + len(thirty_one_project_queries), + len(one_project_queries), + ) diff --git a/projects/views.py b/projects/views.py index 9fbba1ea..af08166a 100644 --- a/projects/views.py +++ b/projects/views.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.db.models import Q, QuerySet +from django.db.models import QuerySet from django.shortcuts import get_object_or_404 from django_filters import rest_framework as filters from drf_yasg import openapi @@ -261,13 +261,7 @@ class ProjectCountView(generics.GenericAPIView): def get(self, request): return Response( - { - "all": self.get_queryset().filter(draft=False, is_public=True).count(), - "my": self.get_queryset() - .filter(Q(leader_id=request.user.id) | Q(collaborator__user=request.user)) - .distinct() - .count(), - }, + Project.objects.get_user_activity_counts(request.user), status=status.HTTP_200_OK, )