diff --git a/docs/qa/prod-member-search-2026-09-26.md b/docs/qa/prod-member-search-2026-09-26.md new file mode 100644 index 00000000..b14cdc7b --- /dev/null +++ b/docs/qa/prod-member-search-2026-09-26.md @@ -0,0 +1,27 @@ +# Перенос поиска участников в PROD + +База master: `24fb95169b59eea08ee3bd2e755010cd4d232211`. +Источник: DEV #754, `98e4e3bcf7149be1b0dc5d03840fd8f894a2b534` (merge `caea57a7c6f2869e7f2522e37c8fc395569b030c`). + +Перенесены только фильтр fullname и его API-тесты. До переноса оба файла master совпадали с родителем source-коммита. Runtime-реализация совпадает с source; дополнительно проверены обязательность обеих частей имени, фрагмент фамилии и буквальное совпадение имени с regex-символами. + +## Контракт + +`GET /auth/public-users/?fullname=...` ищет фрагмент имени или фамилии. Для нескольких слов проверяются границы между именем и фамилией в обоих порядках; обе стороны AND-условия обязательны. Пробелы нормализуются. Пустой ввод не ограничивает выдачу. Составные имена сохраняются. + +На PostgreSQL с ограниченным LC_CTYPE кириллический icontains не гарантирует поиск без учёта регистра. Фильтр строит буквальный regex из экранированных символов с явными вариантами Unicode-регистра. Пользователь не задаёт операторы регулярного выражения. Длина ограничивается суммой максимальных длин двух полей; более длинный запрос сразу даёт пустую выдачу. Фильтрация остаётся в SQL до пагинации. + +UserFilter является общим: его fullname также используется административным списком пользователей. Permissions, serializers, pagination, user_type и остальные фильтры не менялись. React, миграции, зависимости, workflows и DEV-ветка не изменялись. + +## Проверки + +Локально: Python 3.11, PostgreSQL 18, конфигурация `procollab.settings_ci` с отдельной локальной тестовой БД и без внешних сервисов. Пароли и локальные настройки не входят в PR. + +- `python manage.py test users.tests.test_user_lists_api --noinput --keepdb --verbosity 1`: 13 тестов, OK. +- `python manage.py check`: 0 issues. +- `python manage.py makemigrations --check --dry-run`: No changes detected. +- `python -m black --check users/filters.py users/tests/test_user_lists_api.py`: OK. +- `python -m flake8 users/filters.py users/tests/test_user_lists_api.py`: OK. +- `git diff --check`: OK. + +Результат полного PostgreSQL suite и нового GitHub PostgreSQL CI указан в описании PR. Предыдущий DEV-run не считается проверкой этого PROD-переноса. Live PROD до deploy не проверялся: безопасная авторизованная браузерная сессия отсутствует. 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..44fda124 100644 --- a/users/tests/test_user_lists_api.py +++ b/users/tests/test_user_lists_api.py @@ -35,6 +35,114 @@ 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_all_query_parts_are_required(self): + """Несовпавшая часть запроса исключает одноимённых пользователей целиком.""" + build_user(email="partial1@example.test", first_name="Иван", last_name="Иванов") + build_user(email="partial2@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"], 0) + + def test_regex_metacharacters_match_only_literal_name(self): + """Скобки и точки допустимы как текст, но не выполняются как regex.""" + wanted = build_user( + email="literal@example.test", first_name="A[bc].*", last_name="Smith" + ) + build_user(email="regex@example.test", first_name="Abbb", last_name="Smith") + response = self.client.get("/auth/public-users/", {"fullname": "a[bc].* SMITH"}) + self.assertEqual(response.status_code, 200) + 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")