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
112 changes: 112 additions & 0 deletions docs/member-statistics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Статистика участников: селективный перенос в PROD

База: `master`, `1b2087787a1554733768a1984da12b0070c1ee05`.
Источник: DEV [#756](https://github.com/PROCOLLAB-github/api/pull/756),
merge `11f1e66a85e266d57fc6b0f8c080ee8cd0683f19`.

Перенесены только сериализатор, сервис агрегатов, публичный GET, маршрут и
тесты статистики. Merge-коммит DEV не включён в историю PROD-ветки.
Модели, миграции, React domain, зависимости и workflows не изменены.

## Контракт и доступ

`GET /auth/public-users/stats/` возвращает только четыре целых числа:

```json
{
"total": 1248,
"in_projects": 684,
"in_programs": 214,
"new_last_30_days": 63
}
```

Это пример контракта, а не реальные показатели PROD. Пустой каталог даёт
четыре нуля. Политика `AllowAny` сохранена из DEV #756. POST возвращает 405.
Ответ не содержит ID, email, телефонов, дат рождения, данных проектов или программ.
Поиск, skills, speciality и параметры пагинации игнорируются: агрегаты глобальные.

## Определения

Все метрики считают только активных legacy `CustomUser` с `user_type=MEMBER`.

| Поле | Условие |
| --- | --- |
| `total` | Все такие пользователи |
| `in_projects` | Человек является `Project.leader` или `Collaborator.user` хотя бы в одном проекте с `draft=false`, `is_public=true` |
| `in_programs` | Есть `PartnerProgramUserProfile` хотя бы одной программы с `draft=false` |
| `new_last_30_days` | `datetime_created >= timezone.now() - timedelta(days=30)`, граница включительна |

Один человек учитывается один раз независимо от числа связей и совмещения ролей.
Реализация использует `Exists` и `Count` внутри одного агрегирующего SQL-запроса.
Python-обходов пользователей и N+1 нет.

## Производительность

Тест считает SQL всего анонимного GET через `assertNumQueries(1)`; создание
данных выполняется за пределами замера.

| Активных MEMBER | SQL-запросов |
| ---: | ---: |
| 10 | 1 |
| 100 | 1 |
| 1000 | 1 |

Отдельный тест подтверждает один запрос при десяти проектах и десяти программах
одного человека. Это проверка числа запросов, а не нагрузочный benchmark PROD.

## Проверка

Использованы Python 3.11.15, PostgreSQL 18.1 и конфигурация
`procollab.settings_ci` с отдельной локальной тестовой базой
`test_procollab_prod_member_statistics`. SQLite не использовался.

Целевой модуль `users.tests.test_member_statistics_api`: **14 тестов прошли**.
Проверены пустой каталог, роли и активность, оба источника участия, дедупликация,
черновики и приватные проекты, программы, граница 30 суток, только четыре числа,
независимость от фильтров и постоянное число SQL-запросов.

С `NEXTGEN_SURFACE_ENABLED=False` выполнены статистика, quarantine и уведомления:
**30 тестов, OK (2 ожидаемых skip)**. Это подтверждает доступность статистики
в production-конфигурации с выключенной nextgen surface.

`manage.py check`, `makemigrations --check --dry-run`, Black пяти изменённых
Python-файлов, Flake8 всех отслеживаемых Python-файлов и двух новых модулей,
`git diff --check` прошли. Миграций нет.

### Полный PostgreSQL suite: ограничение проверки

Полный suite **не зелёный**. Оба запуска выполнили 1596 тестов:

| Запуск | Итог |
| --- | --- |
| `manage.py test --noinput --verbosity 2` | 2 ошибки, 3 skip; 880.988 s |
| `manage.py test --keepdb --noinput --verbosity 1` (режим CI) | 1 ошибка, 3 skip; 833.717 s |

Все ошибки — `TimeoutError` при `WebsocketCommunicator.receive_json_from()`
в неизменённых тестах чатов. В первом запуске:

- `chats.tests.test_project.DirectTests.test_delete_message_in_other_project_other_message`
- `chats.tests.test_project.DirectTests.test_edit_message_in_my_project_my_message`

Во втором:

- `chats.tests.test_direct.DirectTests.test_edit_other_message_in_other_chat`

Тесты статистики прошли в обоих полных запусках. Код и тесты `chats/`
совпадают с exact master. В первом запуске после тестов также не удалось удалить
тестовую БД из-за 21 оставшегося соединения; повтор использовал штатный режим CI
`--keepdb`. Исправления чатов или изменения таймаутов в этот перенос не включены.

Все три перечисленных теста затем прошли отдельно: **3/3 на exact master** и
**3/3 в ветке переноса**. Это указывает на нестабильность при полном локальном
прогоне, но не заменяет его зелёный результат. Ограничение сохранено в draft PR.

## Согласование с Angular

Источник интерфейса — DEV #381. Angular получает данные через adapter → repository
→ use-case → facade. Desktop sidebar остаётся 157 px, мобильная статистика стоит
после controls и перед списком. Скриншоты и точные измерения находятся в
[Angular draft PR #382](https://github.com/PROCOLLAB-github/procollab/pull/382).
В нём карточка на desktop измерена как 157 × 280 px, overflow равен нулю
на 1440/1280/1024/768/414/390/375 px. React не изменён.
9 changes: 9 additions & 0 deletions users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@
from users.validators import specialization_exists_validator


class MemberStatisticsSerializer(serializers.Serializer):
"""Публичная статистика содержит только четыре агрегата, без данных людей."""

total = serializers.IntegerField(min_value=0, read_only=True)
in_projects = serializers.IntegerField(min_value=0, read_only=True)
in_programs = serializers.IntegerField(min_value=0, read_only=True)
new_last_30_days = serializers.IntegerField(min_value=0, read_only=True)


class UserFileReadSerializer(serializers.ModelSerializer):
class Meta:
model = UserFile
Expand Down
50 changes: 50 additions & 0 deletions users/services/member_statistics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Глобальные счётчики legacy-каталога; фильтры выдачи здесь не применяются."""

from datetime import timedelta

from django.db.models import Count, Exists, OuterRef, Q
from django.utils import timezone

from partner_programs.models import PartnerProgramUserProfile
from projects.models import Collaborator, Project
from users.models import CustomUser


def get_member_statistics():
"""Считает людей одним SQL-запросом, не размножая их по связям.

EXISTS проверяет участие, а не число проектов/регистраций. Поэтому лидер
нескольких проектов, который также состоит в команде, учитывается один раз.
Нижняя граница последних 30 суток включительна и вычисляется один раз.
"""
since = timezone.now() - timedelta(days=30)
return (
CustomUser.objects.filter(user_type=CustomUser.MEMBER, is_active=True)
.annotate(
statistics_leader=Exists(
Project.objects.filter(
leader_id=OuterRef("pk"), draft=False, is_public=True
)
),
statistics_collaborator=Exists(
Collaborator.objects.filter(
user_id=OuterRef("pk"),
project__draft=False,
project__is_public=True,
)
),
statistics_program=Exists(
PartnerProgramUserProfile.objects.filter(
user_id=OuterRef("pk"), partner_program__draft=False
)
),
)
.aggregate(
total=Count("pk"),
in_projects=Count(
"pk", filter=Q(statistics_leader=True) | Q(statistics_collaborator=True)
),
in_programs=Count("pk", filter=Q(statistics_program=True)),
new_last_30_days=Count("pk", filter=Q(datetime_created__gte=since)),
)
)
174 changes: 174 additions & 0 deletions users/tests/test_member_statistics_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
from datetime import date, timedelta
from unittest.mock import patch

from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient

from projects.models import Collaborator, Project
from users.models import CustomUser

from .helpers import add_user_to_program, build_partner_program, build_user


class MemberStatisticsAPITests(TestCase):
"""Счётчики проверяются через публичный HTTP-контракт, включая число SQL."""

url = "/auth/public-users/stats/"

def setUp(self):
self.client = APIClient()

def statistics(self, params=None):
response = self.client.get(self.url, params or {})
self.assertEqual(response.status_code, 200)
self.assertEqual(
set(response.data),
{"total", "in_projects", "in_programs", "new_last_30_days"},
)
self.assertTrue(all(type(value) is int for value in response.data.values()))
return response.data

def project(self, leader, **overrides):
project = Project.objects.create(
leader=leader, name="Синтетический проект", **{"draft": False, **overrides}
)
# Сигнал создаёт Collaborator лидера. Убираем его только в fixture,
# чтобы независимо проверить оба источника участия и их пересечение.
Collaborator.objects.filter(project=project, user=leader).delete()
return project

def test_empty_catalog_is_zero_and_public(self):
self.assertEqual(
self.statistics(),
{"total": 0, "in_projects": 0, "in_programs": 0, "new_last_30_days": 0},
)

def test_only_active_members_enter_all_metrics(self):
program = build_partner_program()
for index, (kind, active) in enumerate(
(
(CustomUser.MEMBER, True),
(CustomUser.MEMBER, False),
(CustomUser.MENTOR, True),
(CustomUser.EXPERT, True),
(CustomUser.ADMIN, True),
(CustomUser.INVESTOR, True),
)
):
user = build_user(
email=f"role-{index}@example.test", user_type=kind, is_active=active
)
self.project(user)
add_user_to_program(user, program)
self.assertEqual(
self.statistics(),
{"total": 1, "in_projects": 1, "in_programs": 1, "new_last_30_days": 1},
)

def test_leader_counts_as_participant(self):
self.project(build_user())
self.assertEqual(self.statistics()["in_projects"], 1)

def test_collaborator_counts_without_leadership(self):
leader = build_user(email="[email protected]", user_type=CustomUser.MENTOR)
Collaborator.objects.create(user=build_user(), project=self.project(leader))
self.assertEqual(self.statistics()["in_projects"], 1)

def test_multiple_projects_and_both_roles_count_a_person_once(self):
leader = build_user()
participant = build_user(email="[email protected]")
for _ in range(3):
project = self.project(leader)
Collaborator.objects.create(user=leader, project=project)
Collaborator.objects.create(user=participant, project=project)
self.assertEqual(self.statistics()["in_projects"], 2)

def test_draft_and_private_projects_do_not_count_either_role(self):
leader = build_user()
participant = build_user(email="[email protected]")
for overrides in ({"draft": True}, {"is_public": False}):
with self.subTest(overrides=overrides):
Collaborator.objects.create(
user=participant, project=self.project(leader, **overrides)
)
self.assertEqual(self.statistics()["in_projects"], 0)

def test_program_registrations_count_people_not_rows(self):
user = build_user()
for index in range(5):
add_user_to_program(user, build_partner_program(tag=f"program-{index}"))
self.assertEqual(self.statistics()["in_programs"], 1)

def test_draft_program_does_not_count(self):
add_user_to_program(build_user(), build_partner_program(draft=True))
self.assertEqual(self.statistics()["in_programs"], 0)

def test_thirty_day_boundary_is_inclusive_and_timezone_aware(self):
now = timezone.now()
for index, age in enumerate(
(timedelta(days=1), timedelta(days=30), timedelta(days=30, microseconds=1))
):
user = build_user(email=f"age-{index}@example.test")
CustomUser.objects.filter(pk=user.pk).update(datetime_created=now - age)
with patch("users.services.member_statistics.timezone.now", return_value=now):
self.assertEqual(self.statistics()["new_last_30_days"], 2)

def test_search_filters_and_pagination_do_not_change_statistics(self):
build_user()
expected = self.statistics()
self.assertEqual(
self.statistics(
{
"fullname": "Несуществующий",
"skills__contains": "Несуществующий",
"speciality__icontains": "Несуществующий",
"offset": 999,
"limit": 0,
"user_type": CustomUser.EXPERT,
}
),
expected,
)

def test_response_is_only_numbers_without_personal_or_relation_data(self):
user = build_user(email="[email protected]", first_name="Секрет")
self.project(user)
add_user_to_program(user, build_partner_program())
response = self.client.get(self.url)
self.assertEqual(len(response.data), 4)
self.assertTrue(all(type(value) is int for value in response.data.values()))
for forbidden in ("email", "id", "first_name", "project", "partner_program"):
self.assertNotIn(forbidden, response.data)

def test_non_read_methods_are_not_supported(self):
self.assertEqual(self.client.post(self.url, {}).status_code, 405)

def test_one_select_for_ten_hundred_and_thousand_users(self):
"""Подготовка данных вне замера: endpoint всегда делает один SELECT."""
previous = 0
for total in (10, 100, 1000):
CustomUser.objects.bulk_create(
[
CustomUser(
email=f"scale-{index}@example.test",
first_name="Участник",
last_name="Тестовый",
birthday=date(2000, 1, 1),
is_active=True,
user_type=CustomUser.MEMBER,
)
for index in range(previous, total)
]
)
previous = total
with self.subTest(total=total), self.assertNumQueries(1):
self.assertEqual(self.statistics()["total"], total)

def test_query_count_stays_one_with_many_relations(self):
user = build_user()
for index in range(10):
Collaborator.objects.create(user=user, project=self.project(user))
add_user_to_program(user, build_partner_program(tag=f"related-{index}"))
with self.assertNumQueries(1):
self.assertEqual(self.statistics()["in_projects"], 1)
6 changes: 6 additions & 0 deletions users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
AcknowledgeVerificationNotice,
CurrentUser,
PublicUserListView,
PublicMemberStatisticsView,
SpecialistsList,
UserAdditionalRolesView,
UserDetail,
Expand Down Expand Up @@ -42,6 +43,11 @@
), # this url actually returns mentors, experts and investors
path("users/", UserList.as_view()),
path("public-users/", PublicUserListView.as_view(), name="public-users"),
path(
"public-users/stats/",
PublicMemberStatisticsView.as_view(),
name="public-member-statistics",
),
path("users/projects/", UserProjectsList.as_view()),
path("users/projects/leader/", UserLeaderProjectsList.as_view()),
path("users/liked/", LikedProjectList.as_view()),
Expand Down
Loading
Loading