diff --git a/docs/member-name-search.md b/docs/member-name-search.md new file mode 100644 index 00000000..521dbeaf --- /dev/null +++ b/docs/member-name-search.md @@ -0,0 +1,78 @@ +# Поиск участников по имени и фамилии + +Изменения для Angular DEV; база `da29b7b3b4f1ab09f51e3a23cdc40c7a7314a7f6`. + +## Причина и границы + +Публичный `/auth/public-users/` использует `UserFilter.fullname`. +До правки запрос «Иван Иванов» соединял совпадения частей через OR и находил +Ивана Петрова / Петра Иванова. На DEV и PROD запросы «Иван», «иван» и «ИВАН» +возвращали разные количества. Этот эффект воспроизведён локально с PostgreSQL +и C collation; точные настройки live-БД не читались. + +24.09.2026 read-only диагностика с `user_type=1&limit=1`: + +| Запрос | DEV | PROD | +| --- | --- | --- | +| Иван | 5 | 365 | +| иван | 0 | 10 | +| ИВАН | 0 | 0 | +| Иван Иванов | 5 | 365 | +| Иван НесуществующаяФамилияПроверка | 5 | 365 | + +Все ответы HTTP 200. Таким образом, это общая backend-проблема, а не +исключительно отставание PROD. Angular дополнительно исправляет пропуск +последнего ввода из-за throttle в отдельной ветке `fix/dev-member-cards-search`. + +## Правило фильтрации + +- Одно слово: фрагмент имени ИЛИ фамилии. +- Несколько слов: обе части должны совпасть с разными полями; учитывается + прямой и обратный порядок. Перебор границы позволяет искать составные имена. +- Пробельные символы по краям и между словами нормализуются через `split()`. + Пустой ввод не добавляет условие. +- Регистр Unicode задаётся явными буквальными вариантами символов в regex. + Это не требует изменения collation, данных или миграций. Ввод экранируется, + пользователь не может передать собственное регулярное выражение. +- Ввод длиннее суммарной вместимости полей модели не может совпасть и даёт + пустую выдачу, не создавая большой набор SQL-условий. + +Фильтрация целиком выполняется в SQL до штатной пагинации. Новых запросов, +N+1, API-полей и загрузки всех пользователей нет. Как и старый поиск по +подстроке, это не индексный полнотекстовый поиск; нагрузочное испытание на +PROD-объёме не проводилось. + +`UserFilter` также используется административным списком пользователей: +его `fullname` получает те же исправленные правила. Существующие permissions, +фильтр типа пользователя, остальные фильтры и serializer не изменены. +React-домен, `Application`/`Team`/`Submission`/`Evaluation`, статистика, +`current_application`, зависимости и workflows не затронуты. + +## Проверки + +Регрессионные тесты публичного endpoint покрывают имя, фамилию, полное имя, +фрагменты, прямой/обратный порядок, составные имена, кириллицу (включая Ё/ё), +латиницу, смешанный регистр, пробелы, пустой ввод, буквальные regex-символы, +невозможный длинный ввод и сочетание с `user_type`. +Отдельная проверка использует `Collate(..., "C")`. + +Live-проверка выполнена только до исправления, без изменения данных. +После исправления проверяется локальный PostgreSQL; merge/deploy не выполнялись. + +Локальное окружение: Python 3.11, PostgreSQL 18, существующие `settings_ci` +с отдельным именем тестовой БД и локальным подключением. Настройки проверки +не включены в репозиторий; production-настройки не использовались. + +- `python manage.py test users.tests.test_user_lists_api --noinput --keepdb --verbosity 1` + — **11/11**, exit 0. +- `python manage.py test --noinput --keepdb --verbosity 1` + — **887/887**, exit 0 (270,141 секунды). +- `python -m black --check users/filters.py users/tests/test_user_lists_api.py` + и `python -m flake8 users/filters.py users/tests/test_user_lists_api.py` — exit 0. +- `python manage.py makemigrations --check --dry-run` — `No changes detected`, exit 0. +- `git diff --check` — без ошибок. + +Первый полный запуск без `--keepdb` выполнил 886 тестов (`OK`), затем завершился +с exit 1 при удалении тестовой БД: остались соединения фоновых тестовых потоков. +Поэтому окончательный полный запуск выполнен с штатным `--keepdb`, без +подавления исключений или изменения test runner. Тесты не пропускались. diff --git a/users/filters.py b/users/filters.py index 3e7fe47e..d3401224 100644 --- a/users/filters.py +++ b/users/filters.py @@ -1,3 +1,5 @@ +import re + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db.models import Q @@ -74,23 +76,57 @@ def filter_age__gte(cls, queryset, name, value): def filter_age__lte(cls, queryset, name, value): return filter_age(queryset, MIN_AGE_VALUE, value) + @staticmethod + def fullname_literal_pattern(value): + """Буквальный поиск с регистром Unicode, независимый от LC_CTYPE БД. + + На существующих окружениях icontains не сворачивает регистр кириллицы. + Явные варианты букв обходятся без изменения collation/данных; каждый + символ экранируется, поэтому ввод пользователя не становится regex. + """ + return "".join( + "(?:" + + "|".join(re.escape(v) for v in sorted({c, c.lower(), c.upper()})) + + ")" + for c in value + ) + @classmethod def filter_by_fullname(cls, queryset, name, value): + """Ищет фрагменты имени и фамилии совместно, также в обратном порядке. + + split нормализует пробелы; пустая строка не фильтрует. Для составных + имён пробуем границу между двумя полями. Оба условия обязательны: + «Иван Иванов» не должен находить Петра Иванова по одной фамилии. + Вся фильтрация выполняется в SQL до пагинации. + """ words = value.split() - first_word = words[0] - if len(words) >= 2: - # if there are more than 2 words, we assume that the first two are first_name and last_name - first_word, second_word = words[0], words[1] - # we search for both first_name and last_name in both orders - return queryset.filter( - Q(first_name__icontains=first_word) - | Q(last_name__icontains=second_word) - | Q(first_name__icontains=second_word) - | Q(last_name__icontains=first_word) + if not words: + return queryset + # Два поля ограничены моделью. Более длинная строка не может совпасть; + # не строим для неё большой набор SQL-условий на публичном endpoint. + name_limit = ( + sum( + queryset.model._meta.get_field(field).max_length + for field in ("first_name", "last_name") ) - return queryset.filter( - Q(first_name__icontains=first_word) | Q(last_name__icontains=first_word) + + 1 ) + if len(" ".join(words)) > name_limit: + return queryset.none() + if len(words) == 1: + pattern = cls.fullname_literal_pattern(words[0]) + return queryset.filter( + Q(first_name__regex=pattern) | Q(last_name__regex=pattern) + ) + predicate = Q() + for boundary in range(1, len(words)): + first = cls.fullname_literal_pattern(" ".join(words[:boundary])) + last = cls.fullname_literal_pattern(" ".join(words[boundary:])) + predicate |= (Q(first_name__regex=first) & Q(last_name__regex=last)) | ( + Q(first_name__regex=last) & Q(last_name__regex=first) + ) + return queryset.filter(predicate) about_me__contains = filters.Filter(field_name="about_me", lookup_expr="contains") speciality__icontains = filters.Filter( diff --git a/users/tests/test_user_lists_api.py b/users/tests/test_user_lists_api.py index 80a66b66..e8efa3aa 100644 --- a/users/tests/test_user_lists_api.py +++ b/users/tests/test_user_lists_api.py @@ -35,6 +35,94 @@ def test_public_users_can_be_filtered_by_fullname(self): returned_ids = {item["id"] for item in response.data["results"]} self.assertEqual(returned_ids, {matched_user.id}) + def test_fullname_matches_case_spacing_and_both_parts(self): + """Регистр и пробелы не меняют выдачу; одно совпавшее слово недостаточно.""" + wanted = build_user( + email="fullname1@example.test", first_name="Иван", last_name="Иванов" + ) + build_user(email="fullname2@example.test", first_name="Иван", last_name="Петров") + build_user(email="fullname3@example.test", first_name="Пётр", last_name="Иванов") + for query in ( + "Иван Иванов", + "иван иванов", + "ИВАН ИВАНОВ", + "иВаН иВаНоВ", + " Иван Иванов ", + "Иванов Иван", + "ив иванов", + ): + with self.subTest(query=query): + response = self.client.get("/auth/public-users/", {"fullname": query}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual([u["id"] for u in response.data["results"]], [wanted.id]) + + def test_compound_names_and_query_longer_than_model_fields(self): + """Составные имена сохраняются; заведомо слишком длинный ввод безопасно пуст.""" + wanted = build_user( + email="compound@example.test", first_name="Анна Мария", last_name="Ван Дейк" + ) + for query in ("анна мария ван дейк", "ВАН ДЕЙК АННА МАРИЯ"): + response = self.client.get("/auth/public-users/", {"fullname": query}) + self.assertEqual([u["id"] for u in response.data["results"]], [wanted.id]) + response = self.client.get("/auth/public-users/", {"fullname": "Я " * 600}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 0) + + def test_single_name_surname_and_literal_characters(self): + """Одиночный фрагмент ищется в обоих полях, regex-символы буквальны.""" + wanted = build_user( + email="fullname4@example.test", first_name="Алёна", last_name="Иванова" + ) + build_user(email="fullname5@example.test", first_name="Пётр", last_name="Сидоров") + for query in ("алёна", "АЛЁНА", "лЁн", "иванова", "иВаНоВа"): + with self.subTest(query=query): + response = self.client.get("/auth/public-users/", {"fullname": query}) + self.assertEqual([u["id"] for u in response.data["results"]], [wanted.id]) + for query in (".*", "[", "Иванова Несуществующая", "(а+)+$"): + with self.subTest(query=query): + response = self.client.get("/auth/public-users/", {"fullname": query}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 0) + + def test_case_search_is_independent_of_postgres_c_collation(self): + """C collation не должна превращать кириллицу в регистрозависимый поиск.""" + from django.db.models.functions import Collate + from users.filters import UserFilter + from django.contrib.auth import get_user_model + + User = get_user_model() + + wanted = build_user( + email="fullname6@example.test", first_name="Иван", last_name="Иванов" + ) + found = User.objects.alias(c_name=Collate("first_name", "C")).filter( + c_name__regex=UserFilter.fullname_literal_pattern("иВаН") + ) + self.assertEqual(list(found.values_list("pk", flat=True)), [wanted.pk]) + + def test_empty_fullname_and_other_filters_are_preserved(self): + """Пустой поиск не меняет остальные фильтры и права публичной выдачи.""" + wanted = build_user( + email="fullname7@example.test", + first_name="John", + last_name="Smith", + user_type=1, + ) + build_user( + email="fullname8@example.test", + first_name="John", + last_name="Smith", + user_type=2, + ) + for query in (" ", "", "JOHN SMITH", "john", "sMiTh"): + with self.subTest(query=query): + response = self.client.get( + "/auth/public-users/", {"fullname": query, "user_type": 1} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual([u["id"] for u in response.data["results"]], [wanted.id]) + def test_public_users_can_be_filtered_by_skill(self): matched_user = build_user(email="skilled@example.com") other_user = build_user(email="unskilled@example.com")