From 3c7c60df34b8842f6a92798a03799db1da3c5a6d Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 27 Sep 2026 02:26:48 +0300 Subject: [PATCH] feat(users): add global member statistics for Angular catalog --- docs/member-statistics.md | 66 ++++++++ users/serializers.py | 9 ++ users/services/member_statistics.py | 50 +++++++ users/tests/test_member_statistics_api.py | 174 ++++++++++++++++++++++ users/urls.py | 6 + users/views.py | 17 +++ 6 files changed, 322 insertions(+) create mode 100644 docs/member-statistics.md create mode 100644 users/services/member_statistics.py create mode 100644 users/tests/test_member_statistics_api.py diff --git a/docs/member-statistics.md b/docs/member-statistics.md new file mode 100644 index 00000000..401a4363 --- /dev/null +++ b/docs/member-statistics.md @@ -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 не выполнялись. diff --git a/users/serializers.py b/users/serializers.py index d88b73cd..f457e42c 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -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 diff --git a/users/services/member_statistics.py b/users/services/member_statistics.py new file mode 100644 index 00000000..0c11b66f --- /dev/null +++ b/users/services/member_statistics.py @@ -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)), + ) + ) diff --git a/users/tests/test_member_statistics_api.py b/users/tests/test_member_statistics_api.py new file mode 100644 index 00000000..31fc4cf1 --- /dev/null +++ b/users/tests/test_member_statistics_api.py @@ -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="mentor@example.test", 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="participant@example.test") + 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="participant@example.test") + 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="private@example.test", 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) diff --git a/users/urls.py b/users/urls.py index aa6332e5..7a29f876 100644 --- a/users/urls.py +++ b/users/urls.py @@ -8,6 +8,7 @@ AcknowledgeVerificationNotice, CurrentUser, PublicUserListView, + PublicMemberStatisticsView, SpecialistsList, UserAdditionalRolesView, UserDetail, @@ -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()), diff --git a/users/views.py b/users/views.py index e1cff0fb..56f3e0c9 100644 --- a/users/views.py +++ b/users/views.py @@ -53,6 +53,7 @@ AchievementDetailSerializer, AchievementListSerializer, PublicUserSerializer, + MemberStatisticsSerializer, ResendVerifyEmailSerializer, SpecializationSerializer, SpecializationsSerializer, @@ -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() @@ -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