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

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

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

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

Доступ `AllowAny` совпадает с существующим `/auth/public-users/`.
Идентификаторы, персональные данные, проекты и программы не сериализуются.
GET не изменяет данные. Другие методы не добавлены.

Это глобальная статистика, независимая от выдачи каталога. Endpoint не поддерживает
фильтры: `fullname`, `skills__contains`, `speciality__icontains`, `offset`, `limit`
и прочие query-параметры игнорируются, а не передаются в ORM.

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

Общая выборка: legacy `CustomUser`, `user_type=MEMBER`, `is_active=True`.

| Поле | Правило |
| --- | --- |
| `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)`; граница включительна |

Несколько проектов, пересечение ролей leader/collaborator и несколько программ
не увеличивают счётчик одного человека. При пустой выборке все значения равны нулю.
Наличие `is_staff` само по себе не заменяет правило `user_type=MEMBER`.

## Реализация и стоимость

`users.services.member_statistics.get_member_statistics()` строит одну агрегирующую
SQL-команду с тремя коррелированными `EXISTS`. Основная таблица пользователей не
соединяется с коллекциями, поэтому размножения строк и циклов по людям нет.
Сериализатор получает уже вычисленные четыре числа.

Тесты подтверждают **1 SQL-запрос** для анонимного GET при 10, 100 и 1000 пользователях,
а также при множественных связях проектов и программ. Это контроль количества
запросов, не нагрузочный тест времени выполнения на production-объёмах.

## Проверки 27.09.2026

База DEV: `caea57a7c6f2869e7f2522e37c8fc395569b030c`.
Локальный PostgreSQL 18, отдельная тестовая БД; секреты настроек не входят в репозиторий.
Полный suite запускался с `NEXTGEN_SURFACE_ENABLED=True`, чтобы проверить сохранение
существующих контрактов React-домена, без изменения его кода.

- `manage.py test users.tests.test_member_statistics_api users.tests.test_user_lists_api --noinput --keepdb`: **25 OK**.
- `manage.py test --noinput --keepdb`: **901 OK**, 264.845 с.
- `manage.py check`: 0 issues.
- `manage.py makemigrations --check --dry-run`: No changes detected.
- `black --check` и `flake8` для пяти затронутых Python-файлов: exit 0.
- `git diff --check`: exit 0.

Миграций, зависимостей и изменений workflows нет. React, поиск fullname и правила
участия не изменены. Для будущего DEV-развёртывания endpoint нужен Angular-виджету;
старый frontend продолжает использовать прежний список. Merge/deploy не выполнялись.
9 changes: 9 additions & 0 deletions users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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 @@ -8,6 +8,7 @@
AcknowledgeVerificationNotice,
CurrentUser,
PublicUserListView,
PublicMemberStatisticsView,
SpecialistsList,
UserAdditionalRolesView,
UserDetail,
Expand Down Expand Up @@ -40,6 +41,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
17 changes: 17 additions & 0 deletions users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
AchievementDetailSerializer,
AchievementListSerializer,
PublicUserSerializer,
MemberStatisticsSerializer,
ResendVerifyEmailSerializer,
SpecializationSerializer,
SpecializationsSerializer,
Expand All @@ -71,6 +72,7 @@
from .pagination import UsersPagination
from .schema import SKILL_PK_PARAM, USER_PK_PARAM
from .services.cv_data_prepare import UserCVDataPreparerV2
from .services.member_statistics import get_member_statistics
from .tasks import send_mail_cv

User = get_user_model()
Expand Down Expand Up @@ -660,6 +662,21 @@ def get(self, request, *args, **kwargs):
return Response(data={"detail": "success"}, status=status.HTTP_200_OK)


class PublicMemberStatisticsView(APIView):
"""Глобальные метрики каталога с тем же публичным доступом, что у списка.

Параметры поиска, фильтров и пагинации намеренно не читаются. GET выполняет
только агрегирующий SELECT и не использует сериализацию пользователей.
"""

permission_classes = [AllowAny]

@swagger_auto_schema(responses={200: MemberStatisticsSerializer})
def get(self, request):
"""Возвращает четыре числа независимо от текущей выдачи участников."""
return Response(MemberStatisticsSerializer(get_member_statistics()).data)


class PublicUserListView(ListAPIView):
queryset = User.objects.get_active()
serializer_class = PublicUserSerializer
Expand Down
Loading