Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/modules/project-activity-count.md
Original file line number Diff line number Diff line change
@@ -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`.
53 changes: 52 additions & 1 deletion projects/managers.py
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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)

Expand Down
114 changes: 114 additions & 0 deletions projects/tests/test_project_count.py
Original file line number Diff line number Diff line change
@@ -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),
)
10 changes: 2 additions & 8 deletions projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)

Expand Down
Loading