Skip to content

Commit 2fbc722

Browse files
authored
Merge pull request #757 from PROCOLLAB-github/release/prod-member-statistics
PROD: добавить статистику участников
2 parents 1b20877 + 49b894b commit 2fbc722

6 files changed

Lines changed: 368 additions & 0 deletions

File tree

‎docs/member-statistics.md‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Статистика участников: селективный перенос в PROD
2+
3+
База: `master`, `1b2087787a1554733768a1984da12b0070c1ee05`.
4+
Источник: DEV [#756](https://github.com/PROCOLLAB-github/api/pull/756),
5+
merge `11f1e66a85e266d57fc6b0f8c080ee8cd0683f19`.
6+
7+
Перенесены только сериализатор, сервис агрегатов, публичный GET, маршрут и
8+
тесты статистики. Merge-коммит DEV не включён в историю PROD-ветки.
9+
Модели, миграции, React domain, зависимости и workflows не изменены.
10+
11+
## Контракт и доступ
12+
13+
`GET /auth/public-users/stats/` возвращает только четыре целых числа:
14+
15+
```json
16+
{
17+
"total": 1248,
18+
"in_projects": 684,
19+
"in_programs": 214,
20+
"new_last_30_days": 63
21+
}
22+
```
23+
24+
Это пример контракта, а не реальные показатели PROD. Пустой каталог даёт
25+
четыре нуля. Политика `AllowAny` сохранена из DEV #756. POST возвращает 405.
26+
Ответ не содержит ID, email, телефонов, дат рождения, данных проектов или программ.
27+
Поиск, skills, speciality и параметры пагинации игнорируются: агрегаты глобальные.
28+
29+
## Определения
30+
31+
Все метрики считают только активных legacy `CustomUser` с `user_type=MEMBER`.
32+
33+
| Поле | Условие |
34+
| --- | --- |
35+
| `total` | Все такие пользователи |
36+
| `in_projects` | Человек является `Project.leader` или `Collaborator.user` хотя бы в одном проекте с `draft=false`, `is_public=true` |
37+
| `in_programs` | Есть `PartnerProgramUserProfile` хотя бы одной программы с `draft=false` |
38+
| `new_last_30_days` | `datetime_created >= timezone.now() - timedelta(days=30)`, граница включительна |
39+
40+
Один человек учитывается один раз независимо от числа связей и совмещения ролей.
41+
Реализация использует `Exists` и `Count` внутри одного агрегирующего SQL-запроса.
42+
Python-обходов пользователей и N+1 нет.
43+
44+
## Производительность
45+
46+
Тест считает SQL всего анонимного GET через `assertNumQueries(1)`; создание
47+
данных выполняется за пределами замера.
48+
49+
| Активных MEMBER | SQL-запросов |
50+
| ---: | ---: |
51+
| 10 | 1 |
52+
| 100 | 1 |
53+
| 1000 | 1 |
54+
55+
Отдельный тест подтверждает один запрос при десяти проектах и десяти программах
56+
одного человека. Это проверка числа запросов, а не нагрузочный benchmark PROD.
57+
58+
## Проверка
59+
60+
Использованы Python 3.11.15, PostgreSQL 18.1 и конфигурация
61+
`procollab.settings_ci` с отдельной локальной тестовой базой
62+
`test_procollab_prod_member_statistics`. SQLite не использовался.
63+
64+
Целевой модуль `users.tests.test_member_statistics_api`: **14 тестов прошли**.
65+
Проверены пустой каталог, роли и активность, оба источника участия, дедупликация,
66+
черновики и приватные проекты, программы, граница 30 суток, только четыре числа,
67+
независимость от фильтров и постоянное число SQL-запросов.
68+
69+
С `NEXTGEN_SURFACE_ENABLED=False` выполнены статистика, quarantine и уведомления:
70+
**30 тестов, OK (2 ожидаемых skip)**. Это подтверждает доступность статистики
71+
в production-конфигурации с выключенной nextgen surface.
72+
73+
`manage.py check`, `makemigrations --check --dry-run`, Black пяти изменённых
74+
Python-файлов, Flake8 всех отслеживаемых Python-файлов и двух новых модулей,
75+
`git diff --check` прошли. Миграций нет.
76+
77+
### Полный PostgreSQL suite: ограничение проверки
78+
79+
Полный suite **не зелёный**. Оба запуска выполнили 1596 тестов:
80+
81+
| Запуск | Итог |
82+
| --- | --- |
83+
| `manage.py test --noinput --verbosity 2` | 2 ошибки, 3 skip; 880.988 s |
84+
| `manage.py test --keepdb --noinput --verbosity 1` (режим CI) | 1 ошибка, 3 skip; 833.717 s |
85+
86+
Все ошибки — `TimeoutError` при `WebsocketCommunicator.receive_json_from()`
87+
в неизменённых тестах чатов. В первом запуске:
88+
89+
- `chats.tests.test_project.DirectTests.test_delete_message_in_other_project_other_message`
90+
- `chats.tests.test_project.DirectTests.test_edit_message_in_my_project_my_message`
91+
92+
Во втором:
93+
94+
- `chats.tests.test_direct.DirectTests.test_edit_other_message_in_other_chat`
95+
96+
Тесты статистики прошли в обоих полных запусках. Код и тесты `chats/`
97+
совпадают с exact master. В первом запуске после тестов также не удалось удалить
98+
тестовую БД из-за 21 оставшегося соединения; повтор использовал штатный режим CI
99+
`--keepdb`. Исправления чатов или изменения таймаутов в этот перенос не включены.
100+
101+
Все три перечисленных теста затем прошли отдельно: **3/3 на exact master** и
102+
**3/3 в ветке переноса**. Это указывает на нестабильность при полном локальном
103+
прогоне, но не заменяет его зелёный результат. Ограничение сохранено в draft PR.
104+
105+
## Согласование с Angular
106+
107+
Источник интерфейса — DEV #381. Angular получает данные через adapter → repository
108+
→ use-case → facade. Desktop sidebar остаётся 157 px, мобильная статистика стоит
109+
после controls и перед списком. Скриншоты и точные измерения находятся в
110+
[Angular draft PR #382](https://github.com/PROCOLLAB-github/procollab/pull/382).
111+
В нём карточка на desktop измерена как 157 × 280 px, overflow равен нулю
112+
на 1440/1280/1024/768/414/390/375 px. React не изменён.

‎users/serializers.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@
3939
from users.validators import specialization_exists_validator
4040

4141

42+
class MemberStatisticsSerializer(serializers.Serializer):
43+
"""Публичная статистика содержит только четыре агрегата, без данных людей."""
44+
45+
total = serializers.IntegerField(min_value=0, read_only=True)
46+
in_projects = serializers.IntegerField(min_value=0, read_only=True)
47+
in_programs = serializers.IntegerField(min_value=0, read_only=True)
48+
new_last_30_days = serializers.IntegerField(min_value=0, read_only=True)
49+
50+
4251
class UserFileReadSerializer(serializers.ModelSerializer):
4352
class Meta:
4453
model = UserFile
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Глобальные счётчики legacy-каталога; фильтры выдачи здесь не применяются."""
2+
3+
from datetime import timedelta
4+
5+
from django.db.models import Count, Exists, OuterRef, Q
6+
from django.utils import timezone
7+
8+
from partner_programs.models import PartnerProgramUserProfile
9+
from projects.models import Collaborator, Project
10+
from users.models import CustomUser
11+
12+
13+
def get_member_statistics():
14+
"""Считает людей одним SQL-запросом, не размножая их по связям.
15+
16+
EXISTS проверяет участие, а не число проектов/регистраций. Поэтому лидер
17+
нескольких проектов, который также состоит в команде, учитывается один раз.
18+
Нижняя граница последних 30 суток включительна и вычисляется один раз.
19+
"""
20+
since = timezone.now() - timedelta(days=30)
21+
return (
22+
CustomUser.objects.filter(user_type=CustomUser.MEMBER, is_active=True)
23+
.annotate(
24+
statistics_leader=Exists(
25+
Project.objects.filter(
26+
leader_id=OuterRef("pk"), draft=False, is_public=True
27+
)
28+
),
29+
statistics_collaborator=Exists(
30+
Collaborator.objects.filter(
31+
user_id=OuterRef("pk"),
32+
project__draft=False,
33+
project__is_public=True,
34+
)
35+
),
36+
statistics_program=Exists(
37+
PartnerProgramUserProfile.objects.filter(
38+
user_id=OuterRef("pk"), partner_program__draft=False
39+
)
40+
),
41+
)
42+
.aggregate(
43+
total=Count("pk"),
44+
in_projects=Count(
45+
"pk", filter=Q(statistics_leader=True) | Q(statistics_collaborator=True)
46+
),
47+
in_programs=Count("pk", filter=Q(statistics_program=True)),
48+
new_last_30_days=Count("pk", filter=Q(datetime_created__gte=since)),
49+
)
50+
)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
from datetime import date, timedelta
2+
from unittest.mock import patch
3+
4+
from django.test import TestCase
5+
from django.utils import timezone
6+
from rest_framework.test import APIClient
7+
8+
from projects.models import Collaborator, Project
9+
from users.models import CustomUser
10+
11+
from .helpers import add_user_to_program, build_partner_program, build_user
12+
13+
14+
class MemberStatisticsAPITests(TestCase):
15+
"""Счётчики проверяются через публичный HTTP-контракт, включая число SQL."""
16+
17+
url = "/auth/public-users/stats/"
18+
19+
def setUp(self):
20+
self.client = APIClient()
21+
22+
def statistics(self, params=None):
23+
response = self.client.get(self.url, params or {})
24+
self.assertEqual(response.status_code, 200)
25+
self.assertEqual(
26+
set(response.data),
27+
{"total", "in_projects", "in_programs", "new_last_30_days"},
28+
)
29+
self.assertTrue(all(type(value) is int for value in response.data.values()))
30+
return response.data
31+
32+
def project(self, leader, **overrides):
33+
project = Project.objects.create(
34+
leader=leader, name="Синтетический проект", **{"draft": False, **overrides}
35+
)
36+
# Сигнал создаёт Collaborator лидера. Убираем его только в fixture,
37+
# чтобы независимо проверить оба источника участия и их пересечение.
38+
Collaborator.objects.filter(project=project, user=leader).delete()
39+
return project
40+
41+
def test_empty_catalog_is_zero_and_public(self):
42+
self.assertEqual(
43+
self.statistics(),
44+
{"total": 0, "in_projects": 0, "in_programs": 0, "new_last_30_days": 0},
45+
)
46+
47+
def test_only_active_members_enter_all_metrics(self):
48+
program = build_partner_program()
49+
for index, (kind, active) in enumerate(
50+
(
51+
(CustomUser.MEMBER, True),
52+
(CustomUser.MEMBER, False),
53+
(CustomUser.MENTOR, True),
54+
(CustomUser.EXPERT, True),
55+
(CustomUser.ADMIN, True),
56+
(CustomUser.INVESTOR, True),
57+
)
58+
):
59+
user = build_user(
60+
email=f"role-{index}@example.test", user_type=kind, is_active=active
61+
)
62+
self.project(user)
63+
add_user_to_program(user, program)
64+
self.assertEqual(
65+
self.statistics(),
66+
{"total": 1, "in_projects": 1, "in_programs": 1, "new_last_30_days": 1},
67+
)
68+
69+
def test_leader_counts_as_participant(self):
70+
self.project(build_user())
71+
self.assertEqual(self.statistics()["in_projects"], 1)
72+
73+
def test_collaborator_counts_without_leadership(self):
74+
leader = build_user(email="mentor@example.test", user_type=CustomUser.MENTOR)
75+
Collaborator.objects.create(user=build_user(), project=self.project(leader))
76+
self.assertEqual(self.statistics()["in_projects"], 1)
77+
78+
def test_multiple_projects_and_both_roles_count_a_person_once(self):
79+
leader = build_user()
80+
participant = build_user(email="participant@example.test")
81+
for _ in range(3):
82+
project = self.project(leader)
83+
Collaborator.objects.create(user=leader, project=project)
84+
Collaborator.objects.create(user=participant, project=project)
85+
self.assertEqual(self.statistics()["in_projects"], 2)
86+
87+
def test_draft_and_private_projects_do_not_count_either_role(self):
88+
leader = build_user()
89+
participant = build_user(email="participant@example.test")
90+
for overrides in ({"draft": True}, {"is_public": False}):
91+
with self.subTest(overrides=overrides):
92+
Collaborator.objects.create(
93+
user=participant, project=self.project(leader, **overrides)
94+
)
95+
self.assertEqual(self.statistics()["in_projects"], 0)
96+
97+
def test_program_registrations_count_people_not_rows(self):
98+
user = build_user()
99+
for index in range(5):
100+
add_user_to_program(user, build_partner_program(tag=f"program-{index}"))
101+
self.assertEqual(self.statistics()["in_programs"], 1)
102+
103+
def test_draft_program_does_not_count(self):
104+
add_user_to_program(build_user(), build_partner_program(draft=True))
105+
self.assertEqual(self.statistics()["in_programs"], 0)
106+
107+
def test_thirty_day_boundary_is_inclusive_and_timezone_aware(self):
108+
now = timezone.now()
109+
for index, age in enumerate(
110+
(timedelta(days=1), timedelta(days=30), timedelta(days=30, microseconds=1))
111+
):
112+
user = build_user(email=f"age-{index}@example.test")
113+
CustomUser.objects.filter(pk=user.pk).update(datetime_created=now - age)
114+
with patch("users.services.member_statistics.timezone.now", return_value=now):
115+
self.assertEqual(self.statistics()["new_last_30_days"], 2)
116+
117+
def test_search_filters_and_pagination_do_not_change_statistics(self):
118+
build_user()
119+
expected = self.statistics()
120+
self.assertEqual(
121+
self.statistics(
122+
{
123+
"fullname": "Несуществующий",
124+
"skills__contains": "Несуществующий",
125+
"speciality__icontains": "Несуществующий",
126+
"offset": 999,
127+
"limit": 0,
128+
"user_type": CustomUser.EXPERT,
129+
}
130+
),
131+
expected,
132+
)
133+
134+
def test_response_is_only_numbers_without_personal_or_relation_data(self):
135+
user = build_user(email="private@example.test", first_name="Секрет")
136+
self.project(user)
137+
add_user_to_program(user, build_partner_program())
138+
response = self.client.get(self.url)
139+
self.assertEqual(len(response.data), 4)
140+
self.assertTrue(all(type(value) is int for value in response.data.values()))
141+
for forbidden in ("email", "id", "first_name", "project", "partner_program"):
142+
self.assertNotIn(forbidden, response.data)
143+
144+
def test_non_read_methods_are_not_supported(self):
145+
self.assertEqual(self.client.post(self.url, {}).status_code, 405)
146+
147+
def test_one_select_for_ten_hundred_and_thousand_users(self):
148+
"""Подготовка данных вне замера: endpoint всегда делает один SELECT."""
149+
previous = 0
150+
for total in (10, 100, 1000):
151+
CustomUser.objects.bulk_create(
152+
[
153+
CustomUser(
154+
email=f"scale-{index}@example.test",
155+
first_name="Участник",
156+
last_name="Тестовый",
157+
birthday=date(2000, 1, 1),
158+
is_active=True,
159+
user_type=CustomUser.MEMBER,
160+
)
161+
for index in range(previous, total)
162+
]
163+
)
164+
previous = total
165+
with self.subTest(total=total), self.assertNumQueries(1):
166+
self.assertEqual(self.statistics()["total"], total)
167+
168+
def test_query_count_stays_one_with_many_relations(self):
169+
user = build_user()
170+
for index in range(10):
171+
Collaborator.objects.create(user=user, project=self.project(user))
172+
add_user_to_program(user, build_partner_program(tag=f"related-{index}"))
173+
with self.assertNumQueries(1):
174+
self.assertEqual(self.statistics()["in_projects"], 1)

‎users/urls.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
AcknowledgeVerificationNotice,
1010
CurrentUser,
1111
PublicUserListView,
12+
PublicMemberStatisticsView,
1213
SpecialistsList,
1314
UserAdditionalRolesView,
1415
UserDetail,
@@ -42,6 +43,11 @@
4243
), # this url actually returns mentors, experts and investors
4344
path("users/", UserList.as_view()),
4445
path("public-users/", PublicUserListView.as_view(), name="public-users"),
46+
path(
47+
"public-users/stats/",
48+
PublicMemberStatisticsView.as_view(),
49+
name="public-member-statistics",
50+
),
4551
path("users/projects/", UserProjectsList.as_view()),
4652
path("users/projects/leader/", UserLeaderProjectsList.as_view()),
4753
path("users/liked/", LikedProjectList.as_view()),

0 commit comments

Comments
 (0)