From eb5eb38665dbcdbc761cbe46b6f62d89e2d8c4b8 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sat, 22 Aug 2026 17:31:01 +0330 Subject: [PATCH] Migrate manual query-param filtering to django-filter django-filter was already installed and set as DEFAULT_FILTER_BACKENDS but unused everywhere. Replace hand-rolled get_queryset filtering with FilterSet classes (stores/catalog/locations/reviews) and filterset_fields (orders status). drf-spectacular auto-documents these for swagger, so the manual OpenApiParameter declarations for the migrated fields are removed (stores keeps lat/lng manual since geo-distance isn't a plain filter). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 +++ apps/catalog/filters.py | 17 +++++++++++++++++ apps/catalog/views.py | 30 ++--------------------------- apps/locations/filters.py | 24 +++++++++++++++++++++++ apps/locations/views.py | 40 +++------------------------------------ apps/orders/views.py | 7 ++----- apps/reviews/filters.py | 11 +++++++++++ apps/reviews/views.py | 10 +++------- apps/stores/filters.py | 19 +++++++++++++++++++ apps/stores/views.py | 22 ++++----------------- 10 files changed, 88 insertions(+), 95 deletions(-) create mode 100644 apps/catalog/filters.py create mode 100644 apps/locations/filters.py create mode 100644 apps/reviews/filters.py create mode 100644 apps/stores/filters.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f8a94e..0205674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,6 @@ - Test coverage for `StoreViewSet` list filtering (`apps/stores/tests/test_store_list.py`) — previously untested. - Test coverage for `ProductViewSet` list filtering (`apps/catalog/tests/test_product_list.py`) — previously untested. - Test coverage for `CityViewSet`/`NeighborhoodViewSet` list filtering (`apps/locations/tests/test_location_list.py`) — previously untested. + +### Changed +- Replaced hand-rolled `get_queryset` filtering with `django-filter` `FilterSet` classes (`apps/{stores,catalog,locations,reviews}/filters.py`) and `filterset_fields` (`apps/orders` status). `django-filter` was already installed and set as `DEFAULT_FILTER_BACKENDS` but unused everywhere; params are now auto-documented in swagger by drf-spectacular's django-filter integration, so the manual `OpenApiParameter` declarations for those fields were removed (stores keeps `lat`/`lng` manual since geo-distance isn't a plain filter). diff --git a/apps/catalog/filters.py b/apps/catalog/filters.py new file mode 100644 index 0000000..0c5a812 --- /dev/null +++ b/apps/catalog/filters.py @@ -0,0 +1,17 @@ +from django.db.models import Q +from django_filters import rest_framework as filters + +from .models import Product + + +class ProductFilter(filters.FilterSet): + store = filters.UUIDFilter(field_name='store__uuid', help_text='Filter by store UUID.') + category = filters.UUIDFilter(field_name='category__uuid', help_text='Filter by product category UUID.') + search = filters.CharFilter(method='filter_search', help_text='Search by product name/description.') + + class Meta: + model = Product + fields = ['store', 'category', 'search'] + + def filter_search(self, queryset, name, value): + return queryset.filter(Q(name__icontains=value) | Q(description__icontains=value)) diff --git a/apps/catalog/views.py b/apps/catalog/views.py index 6d62518..4474c83 100644 --- a/apps/catalog/views.py +++ b/apps/catalog/views.py @@ -1,5 +1,3 @@ -from django.db.models import Q -from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import mixins, viewsets from rest_framework.decorators import action from rest_framework.permissions import AllowAny, IsAuthenticated @@ -10,6 +8,7 @@ from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRe from apps.core.permissions import IsStoreOwner from apps.stores.models import Store +from .filters import ProductFilter from .models import Product, ProductCategory from .serializers import ( ProductCategorySerializer, @@ -27,15 +26,6 @@ class ProductCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): queryset = ProductCategory.objects.all() -@extend_schema_view( - list=extend_schema( - parameters=[ - OpenApiParameter('store', str, description='Filter by store UUID.'), - OpenApiParameter('category', str, description='Filter by product category UUID.'), - OpenApiParameter('search', str, description='Search by product name/description.'), - ], - ), -) class ProductViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): """Customer-facing product browsing (store page, product detail, search).""" @@ -44,29 +34,13 @@ class ProductViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets. queryset = Product.objects.filter( is_active=True, store__status=Store.Status.APPROVED, ).select_related('store', 'category') + filterset_class = ProductFilter def get_serializer_class(self): if self.action == 'retrieve': return ProductDetailSerializer return ProductListSerializer - def get_queryset(self): - queryset = super().get_queryset() - - store_uuid = self.request.query_params.get('store') - if store_uuid: - queryset = queryset.filter(store__uuid=store_uuid) - - category_uuid = self.request.query_params.get('category') - if category_uuid: - queryset = queryset.filter(category__uuid=category_uuid) - - search = self.request.query_params.get('search') - if search: - queryset = queryset.filter(Q(name__icontains=search) | Q(description__icontains=search)) - - return queryset - class SellerProductViewSet(viewsets.ModelViewSet): """Seller's own product management (S06 list, S07 add, S08 inventory).""" diff --git a/apps/locations/filters.py b/apps/locations/filters.py new file mode 100644 index 0000000..fbbace7 --- /dev/null +++ b/apps/locations/filters.py @@ -0,0 +1,24 @@ +from django.db.models import Q +from django_filters import rest_framework as filters + +from .models import City, Neighborhood + + +class CityFilter(filters.FilterSet): + search = filters.CharFilter(field_name='name', lookup_expr='icontains', help_text='Search by city name.') + + class Meta: + model = City + fields = ['search'] + + +class NeighborhoodFilter(filters.FilterSet): + city = filters.UUIDFilter(field_name='city__uuid', help_text='Filter by city UUID.') + search = filters.CharFilter(method='filter_search', help_text='Search by neighborhood or city name.') + + class Meta: + model = Neighborhood + fields = ['city', 'search'] + + def filter_search(self, queryset, name, value): + return queryset.filter(Q(name__icontains=value) | Q(city__name__icontains=value)) diff --git a/apps/locations/views.py b/apps/locations/views.py index 0b9b06f..ea09874 100644 --- a/apps/locations/views.py +++ b/apps/locations/views.py @@ -1,61 +1,27 @@ -from django.db.models import Q -from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import mixins, viewsets from rest_framework.permissions import AllowAny, IsAuthenticated from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements +from .filters import CityFilter, NeighborhoodFilter from .models import Address, City, Neighborhood from .serializers import AddressSerializer, CitySerializer, NeighborhoodSerializer -@extend_schema_view( - list=extend_schema( - parameters=[ - OpenApiParameter('search', str, description='Search by city name.'), - ], - ), -) class CityViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): schema_tags = ['Locations'] permission_classes = [AllowAny] serializer_class = CitySerializer queryset = City.objects.filter(is_active=True) - - def get_queryset(self): - queryset = super().get_queryset() - search = self.request.query_params.get('search') - if search: - queryset = queryset.filter(name__icontains=search) - return queryset + filterset_class = CityFilter -@extend_schema_view( - list=extend_schema( - parameters=[ - OpenApiParameter('city', str, description='Filter by city UUID.'), - OpenApiParameter('search', str, description='Search by neighborhood or city name.'), - ], - ), -) class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): schema_tags = ['Locations'] permission_classes = [AllowAny] serializer_class = NeighborhoodSerializer queryset = Neighborhood.objects.filter(is_active=True).select_related('city') - - def get_queryset(self): - queryset = super().get_queryset() - - city_uuid = self.request.query_params.get('city') - if city_uuid: - queryset = queryset.filter(city__uuid=city_uuid) - - search = self.request.query_params.get('search') - if search: - queryset = queryset.filter(Q(name__icontains=search) | Q(city__name__icontains=search)) - - return queryset + filterset_class = NeighborhoodFilter class AddressViewSet(viewsets.ModelViewSet): diff --git a/apps/orders/views.py b/apps/orders/views.py index 2ee9e42..a7ba204 100644 --- a/apps/orders/views.py +++ b/apps/orders/views.py @@ -160,15 +160,12 @@ class SellerOrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, views required_alternate_scopes = { "POST": [[]], } + filterset_fields = ['status'] def get_queryset(self): if getattr(self, 'swagger_fake_view', False): return Order.objects.none() - queryset = Order.objects.filter(store=self.request.user.store).select_related('store').prefetch_related('items') - status_param = self.request.query_params.get('status') - if status_param: - queryset = queryset.filter(status=status_param) - return queryset + return Order.objects.filter(store=self.request.user.store).select_related('store').prefetch_related('items') def _apply_transition(self, request, to_status): order = self.get_object() diff --git a/apps/reviews/filters.py b/apps/reviews/filters.py new file mode 100644 index 0000000..6f55ffe --- /dev/null +++ b/apps/reviews/filters.py @@ -0,0 +1,11 @@ +from django_filters import rest_framework as filters + +from .models import Review + + +class ReviewFilter(filters.FilterSet): + store = filters.UUIDFilter(field_name='store__uuid', help_text='Filter by store UUID.') + + class Meta: + model = Review + fields = ['store'] diff --git a/apps/reviews/views.py b/apps/reviews/views.py index 3d58eda..f2f4746 100644 --- a/apps/reviews/views.py +++ b/apps/reviews/views.py @@ -6,6 +6,7 @@ from rest_framework.response import Response from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements from apps.core.permissions import IsStoreOwner +from .filters import ReviewFilter from .models import Review from .serializers import ReviewSerializer, SellerReplySerializer @@ -19,19 +20,14 @@ class ReviewViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.Gen required_alternate_scopes = { "POST": [[]], } + filterset_class = ReviewFilter + queryset = Review.objects.select_related('customer', 'store') def get_permissions(self): if self.action == 'create': return [IsAuthenticatedOrTokenMatchesOASRequirements] return [AllowAny()] - def get_queryset(self): - queryset = Review.objects.select_related('customer', 'store') - store_uuid = self.request.query_params.get('store') - if store_uuid: - queryset = queryset.filter(store__uuid=store_uuid) - return queryset - class SellerReviewViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): """Reviews left for the authenticated seller's store, with reply support.""" diff --git a/apps/stores/filters.py b/apps/stores/filters.py new file mode 100644 index 0000000..808bdf2 --- /dev/null +++ b/apps/stores/filters.py @@ -0,0 +1,19 @@ +from django.db.models import Q +from django_filters import rest_framework as filters + +from .models import Store + + +class StoreFilter(filters.FilterSet): + category = filters.UUIDFilter(field_name='category__uuid', help_text='Filter by store category UUID.') + neighborhood = filters.UUIDFilter( + field_name='service_neighborhoods__uuid', help_text='Filter by service neighborhood UUID.', + ) + search = filters.CharFilter(method='filter_search', help_text='Search by store name/description.') + + class Meta: + model = Store + fields = ['category', 'neighborhood', 'search'] + + def filter_search(self, queryset, name, value): + return queryset.filter(Q(name__icontains=value) | Q(description__icontains=value)) diff --git a/apps/stores/views.py b/apps/stores/views.py index 8fb01af..aab11e7 100644 --- a/apps/stores/views.py +++ b/apps/stores/views.py @@ -1,7 +1,6 @@ from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.geos import Point from django.contrib.gis.measure import D -from django.db.models import Q from django.shortcuts import get_object_or_404 from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework import mixins, status, viewsets @@ -13,6 +12,7 @@ from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRe from apps.core.permissions import IsStoreOwner +from .filters import StoreFilter from .models import Store, StoreCategory, StoreWorkingHours from .serializers import ( SellerStoreSerializer, @@ -33,9 +33,6 @@ class StoreCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): @extend_schema_view( list=extend_schema( parameters=[ - OpenApiParameter('category', str, description='Filter by store category UUID.'), - OpenApiParameter('neighborhood', str, description='Filter by service neighborhood UUID.'), - OpenApiParameter('search', str, description='Search by store name/description.'), OpenApiParameter('lat', float, description='Latitude; used with `lng` to sort by distance (15km radius).'), OpenApiParameter('lng', float, description='Longitude; used with `lat` to sort by distance (15km radius).'), ], @@ -46,7 +43,8 @@ class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge schema_tags = ['Stores'] permission_classes = [AllowAny] - queryset = Store.objects.filter(status=Store.Status.APPROVED).select_related('category', 'city') + queryset = Store.objects.filter(status=Store.Status.APPROVED).select_related('category', 'city').distinct() + filterset_class = StoreFilter def get_serializer_class(self): if self.action == 'retrieve': @@ -56,18 +54,6 @@ class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge def get_queryset(self): queryset = super().get_queryset() - neighborhood_uuid = self.request.query_params.get('neighborhood') - if neighborhood_uuid: - queryset = queryset.filter(service_neighborhoods__uuid=neighborhood_uuid) - - category_uuid = self.request.query_params.get('category') - if category_uuid: - queryset = queryset.filter(category__uuid=category_uuid) - - search = self.request.query_params.get('search') - if search: - queryset = queryset.filter(Q(name__icontains=search) | Q(description__icontains=search)) - lat = self.request.query_params.get('lat') lng = self.request.query_params.get('lng') if lat and lng: @@ -76,7 +62,7 @@ class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge distance=Distance('location', point) ).order_by('distance') - return queryset.distinct() + return queryset class SellerStoreView(APIView):