Skip to content

Commit 7dd8a68

Browse files
committed
feat(projects): add user activity statistics
1 parent 19458b6 commit 7dd8a68

4 files changed

Lines changed: 206 additions & 9 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Статистика активности пользователя по проектам
2+
3+
Источник данных — legacy endpoint `GET /projects/count/`. Он возвращает
4+
совместимые с прежним контрактом поля `all` и `my`, а также три показателя
5+
жизненного цикла проектов пользователя:
6+
7+
```json
8+
{
9+
"all": 123,
10+
"my": 7,
11+
"my_leader": 4,
12+
"my_in_program": 2,
13+
"my_submitted": 1
14+
}
15+
```
16+
17+
- `my` — число уникальных legacy `Project`, где пользователь является
18+
лидером или участником `Collaborator`.
19+
- `my_leader` — число проектов, где пользователь указан в `Project.leader`.
20+
- `my_in_program` — число опубликованных проектов пользователя, у которых
21+
каноническая связь с программой существует и ещё не сдана.
22+
- `my_submitted` — число проектов пользователя, у которых каноническая связь
23+
помечена как сданная. Сданный проект учитывается независимо от `draft`,
24+
поскольку состояние «Сдано» имеет приоритет над «Черновиком».
25+
26+
Канонической считается запись `PartnerProgramProject` с минимальным `pk`
27+
для проекта. Это совпадает с правилом, которое используется legacy-карточками
28+
и detail проекта при наличии нескольких связей с программами.
29+
30+
Расчёт выполняется на backend одним агрегирующим SQL-запросом с условными
31+
`Count` и `Subquery`. Его нельзя строить по данным dashboard: Angular
32+
загружает там только первые 16 проектов, поэтому клиентский расчёт давал бы
33+
неполную статистику. Проверка на PostgreSQL подтверждает один запрос как для
34+
одного, так и для 31 проекта.
35+
36+
Расчёт не обращается к React-домену и не меняет права, submit-логику или модели
37+
`Application`, `Team`, `Submission`, `Evaluation` и
38+
`SubmissionExpertAssignment`.

‎projects/managers.py‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from django.contrib.auth import get_user_model
2-
from django.db.models import Manager
2+
from django.db.models import Count, Manager, OuterRef, Q, Subquery
33

44
User = get_user_model()
55

@@ -48,6 +48,57 @@ def get_projects_for_detail_view(self):
4848
def get_projects_for_count_view(self):
4949
return self.get_queryset().only("id", "leader_id")
5050

51+
def get_user_activity_counts(self, user):
52+
"""Считает проектную активность пользователя одним SQL-запросом.
53+
54+
Для lifecycle используется первая legacy-связь с программой по минимальному
55+
``PartnerProgramProject.pk`` — тот же canonical link, который возвращают
56+
карточки и detail проекта. Агрегация выполняется на backend, потому что
57+
dashboard загружает только первые 16 проектов пользователя.
58+
"""
59+
from partner_programs.models import PartnerProgramProject
60+
61+
canonical_program_link = PartnerProgramProject.objects.filter(
62+
project_id=OuterRef("pk")
63+
).order_by("pk")
64+
user_projects = Q(leader_id=user.id) | Q(collaborator__user_id=user.id)
65+
66+
return (
67+
self.get_queryset()
68+
.annotate(
69+
canonical_program_link_id=Subquery(
70+
canonical_program_link.values("pk")[:1]
71+
),
72+
canonical_program_link_submitted=Subquery(
73+
canonical_program_link.values("submitted")[:1]
74+
),
75+
)
76+
.aggregate(
77+
all=Count(
78+
"pk",
79+
filter=Q(draft=False, is_public=True),
80+
distinct=True,
81+
),
82+
my=Count("pk", filter=user_projects, distinct=True),
83+
my_leader=Count("pk", filter=Q(leader_id=user.id), distinct=True),
84+
my_in_program=Count(
85+
"pk",
86+
filter=(
87+
user_projects
88+
& Q(canonical_program_link_id__isnull=False)
89+
& Q(canonical_program_link_submitted=False)
90+
& Q(draft=False)
91+
),
92+
distinct=True,
93+
),
94+
my_submitted=Count(
95+
"pk",
96+
filter=user_projects & Q(canonical_program_link_submitted=True),
97+
distinct=True,
98+
),
99+
)
100+
)
101+
51102
def get_projects_from_list_of_ids(self, ids):
52103
return self.get_queryset().filter(id__in=ids)
53104

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from django.db import connection
2+
from django.test.utils import CaptureQueriesContext
3+
from rest_framework import status
4+
from rest_framework.test import APITestCase
5+
6+
from projects.tests.helpers import (
7+
create_collaborator,
8+
create_partner_program,
9+
create_project,
10+
create_user,
11+
link_project_to_program,
12+
)
13+
14+
15+
class ProjectCountViewTests(APITestCase):
16+
endpoint = "/projects/count/"
17+
18+
def setUp(self):
19+
self.user = create_user(prefix="project-count-user")
20+
self.client.force_authenticate(self.user)
21+
22+
def get_count(self):
23+
response = self.client.get(self.endpoint)
24+
self.assertEqual(response.status_code, status.HTTP_200_OK)
25+
return response.data
26+
27+
def test_user_without_projects_has_zero_activity(self):
28+
self.assertEqual(
29+
self.get_count(),
30+
{
31+
"all": 0,
32+
"my": 0,
33+
"my_leader": 0,
34+
"my_in_program": 0,
35+
"my_submitted": 0,
36+
},
37+
)
38+
39+
def test_counts_leader_collaborator_and_does_not_duplicate_project(self):
40+
leader_project = create_project(leader=self.user, draft=False)
41+
create_collaborator(leader_project, user=self.user)
42+
collaborator_project = create_project(draft=False)
43+
create_collaborator(collaborator_project, user=self.user)
44+
create_project(draft=False)
45+
46+
count = self.get_count()
47+
48+
self.assertEqual(count["my"], 2)
49+
self.assertEqual(count["my_leader"], 1)
50+
self.assertEqual(count["all"], 3)
51+
52+
def test_counts_project_lifecycle_by_canonical_program_link(self):
53+
program = create_partner_program(name="Lifecycle")
54+
second_program = create_partner_program(name="Lifecycle second")
55+
56+
create_project(leader=self.user, draft=True)
57+
create_project(leader=self.user, draft=False)
58+
59+
in_program = create_project(leader=self.user, draft=False)
60+
link_project_to_program(in_program, program, submitted=False)
61+
62+
draft_in_program = create_project(leader=self.user, draft=True)
63+
link_project_to_program(draft_in_program, program, submitted=False)
64+
65+
submitted = create_project(leader=self.user, draft=False)
66+
link_project_to_program(submitted, program, submitted=True)
67+
68+
submitted_draft = create_project(leader=self.user, draft=True)
69+
link_project_to_program(submitted_draft, program, submitted=True)
70+
71+
canonical_not_submitted = create_project(leader=self.user, draft=False)
72+
link_project_to_program(canonical_not_submitted, program, submitted=False)
73+
link_project_to_program(canonical_not_submitted, second_program, submitted=True)
74+
75+
canonical_submitted = create_project(leader=self.user, draft=False)
76+
link_project_to_program(canonical_submitted, program, submitted=True)
77+
link_project_to_program(canonical_submitted, second_program, submitted=False)
78+
79+
count = self.get_count()
80+
81+
self.assertEqual(count["my"], 8)
82+
self.assertEqual(count["my_leader"], 8)
83+
self.assertEqual(count["my_in_program"], 2)
84+
self.assertEqual(count["my_submitted"], 3)
85+
86+
def test_ignores_other_users_projects_and_counts_more_than_dashboard_page(self):
87+
for index in range(17):
88+
create_project(leader=self.user, name=f"Owned {index}", draft=False)
89+
90+
collaborator_project = create_project(draft=False)
91+
create_collaborator(collaborator_project, user=self.user)
92+
create_project(draft=False)
93+
94+
count = self.get_count()
95+
96+
self.assertEqual(count["my"], 18)
97+
self.assertEqual(count["my_leader"], 17)
98+
99+
def test_query_count_does_not_grow_with_project_count(self):
100+
create_project(leader=self.user, draft=False)
101+
with CaptureQueriesContext(connection) as one_project_queries:
102+
self.get_count()
103+
104+
for index in range(30):
105+
create_project(leader=self.user, name=f"Scale {index}", draft=False)
106+
with CaptureQueriesContext(connection) as thirty_one_project_queries:
107+
count = self.get_count()
108+
109+
self.assertEqual(count["my"], 31)
110+
self.assertEqual(len(one_project_queries), 1)
111+
self.assertEqual(
112+
len(thirty_one_project_queries),
113+
len(one_project_queries),
114+
)

‎projects/views.py‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.contrib.auth import get_user_model
55
from django.core.exceptions import ObjectDoesNotExist
66
from django.db import transaction
7-
from django.db.models import Q, QuerySet
7+
from django.db.models import QuerySet
88
from django.shortcuts import get_object_or_404
99
from django_filters import rest_framework as filters
1010
from drf_yasg import openapi
@@ -261,13 +261,7 @@ class ProjectCountView(generics.GenericAPIView):
261261

262262
def get(self, request):
263263
return Response(
264-
{
265-
"all": self.get_queryset().filter(draft=False, is_public=True).count(),
266-
"my": self.get_queryset()
267-
.filter(Q(leader_id=request.user.id) | Q(collaborator__user=request.user))
268-
.distinct()
269-
.count(),
270-
},
264+
Project.objects.get_user_activity_counts(request.user),
271265
status=status.HTTP_200_OK,
272266
)
273267

0 commit comments

Comments
 (0)