From 49b894ba2247b971e3b3757a32115b74ed321b5c Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 27 Sep 2026 22:54:19 +0300 Subject: [PATCH] =?UTF-8?q?PROD:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D1=83=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/member-statistics.md | 112 ++++++++++++++ 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, 368 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..e54669ac --- /dev/null +++ b/docs/member-statistics.md @@ -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 не изменён. diff --git a/users/serializers.py b/users/serializers.py index 047c7161..cc87dcc3 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -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 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 af545d5d..c33873ae 100644 --- a/users/urls.py +++ b/users/urls.py @@ -9,6 +9,7 @@ AcknowledgeVerificationNotice, CurrentUser, PublicUserListView, + PublicMemberStatisticsView, SpecialistsList, UserAdditionalRolesView, UserDetail, @@ -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()), diff --git a/users/views.py b/users/views.py index 7678d9c2..08caf456 100644 --- a/users/views.py +++ b/users/views.py @@ -50,6 +50,7 @@ AchievementDetailSerializer, AchievementListSerializer, PublicUserSerializer, + MemberStatisticsSerializer, ResendVerifyEmailSerializer, SpecializationSerializer, SpecializationsSerializer, @@ -68,6 +69,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() @@ -662,6 +664,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