diff --git a/backend/api/v1/commerce/viewsets.py b/backend/api/v1/commerce/viewsets.py index 5c1c54fc..0f0e6aad 100644 --- a/backend/api/v1/commerce/viewsets.py +++ b/backend/api/v1/commerce/viewsets.py @@ -29,6 +29,7 @@ from commerce.services import ( ) from commerce.services.credentials import CredentialError from commerce.integrations.moysklad import MoySkladError +from commerce.search import UnicodeCasefoldSearchFilter from commerce.tasks import enqueue_connection_sync from .permissions import CommerceAccessPermission @@ -190,11 +191,16 @@ class ProductViewSet(viewsets.ModelViewSet): permission_classes = [CommerceAccessPermission] filter_backends = [ DjangoFilterBackend, - filters.SearchFilter, + UnicodeCasefoldSearchFilter, filters.OrderingFilter, ] filterset_fields = ["kind", "is_active", "parent"] - search_fields = ["sku", "name", "barcode", "bindings__external_sku"] + search_fields = [ + "sku__unicode_casefold__contains", + "name__unicode_casefold__contains", + "barcode__unicode_casefold__contains", + "bindings__external_sku__unicode_casefold__contains", + ] ordering_fields = ["sku", "name", "created_at", "updated_at"] ordering = ["name", "sku"] diff --git a/backend/commerce/apps.py b/backend/commerce/apps.py index 1845bcdb..5af035be 100644 --- a/backend/commerce/apps.py +++ b/backend/commerce/apps.py @@ -7,4 +7,5 @@ class CommerceConfig(AppConfig): verbose_name = "Товарная аналитика" def ready(self): + from . import search # noqa: F401 from . import signals # noqa: F401 diff --git a/backend/commerce/search.py b/backend/commerce/search.py new file mode 100644 index 00000000..aa24c7ef --- /dev/null +++ b/backend/commerce/search.py @@ -0,0 +1,47 @@ +from django.db.backends.signals import connection_created +from django.db.models import CharField, Transform +from django.dispatch import receiver +from rest_framework.filters import SearchFilter + + +SQLITE_CASEFOLD_FUNCTION = "EUSRR_CASEFOLD" + + +def _casefold(value): + return None if value is None else str(value).casefold() + + +@receiver(connection_created, dispatch_uid="commerce.sqlite_unicode_casefold") +def register_sqlite_casefold(sender, connection, **kwargs): + if connection.vendor != "sqlite": + return + connection.connection.create_function( + SQLITE_CASEFOLD_FUNCTION, + 1, + _casefold, + deterministic=True, + ) + + +class UnicodeCasefold(Transform): + lookup_name = "unicode_casefold" + function = "LOWER" + output_field = CharField() + + def as_sqlite(self, compiler, connection, **extra_context): + return super().as_sql( + compiler, + connection, + function=SQLITE_CASEFOLD_FUNCTION, + **extra_context, + ) + + +CharField.register_lookup(UnicodeCasefold) + + +class UnicodeCasefoldSearchFilter(SearchFilter): + """DRF substring search with Unicode-aware case normalization.""" + + def get_search_terms(self, request): + return [term.casefold() for term in super().get_search_terms(request)] diff --git a/backend/tests/commerce/test_api.py b/backend/tests/commerce/test_api.py index 659013cc..6fda41c1 100644 --- a/backend/tests/commerce/test_api.py +++ b/backend/tests/commerce/test_api.py @@ -42,6 +42,22 @@ def test_employee_can_read_but_cannot_create_product(api_client, user_factory): assert create_response.status_code == status.HTTP_403_FORBIDDEN +def test_product_search_matches_unicode_substring_without_case(api_client, user_factory): + user = user_factory() + Product.objects.create(sku="SEA-001", name="Автомат «Бродяги морей»") + Product.objects.create(sku="OTHER-001", name="Другой товар") + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("api:v1:commerce:product-list"), + {"search": "бродяг"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["results"][0]["name"] == "Автомат «Бродяги морей»" + + def test_staff_can_create_product(api_client, user_factory): user = user_factory(staff=True) api_client.force_authenticate(user=user)