Add search to city/neighborhood endpoints; document filters for swagger #4

Merged
Ghasemi merged 3 commits from feature/filter-stores into master 2026-08-23 02:47:05 -04:00
10 changed files with 88 additions and 95 deletions
Showing only changes of commit eb5eb38665 - Show all commits

View file

@ -13,3 +13,6 @@
- Test coverage for `StoreViewSet` list filtering (`apps/stores/tests/test_store_list.py`) — previously untested. - 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 `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. - 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).

17
apps/catalog/filters.py Normal file
View file

@ -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))

View file

@ -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 import mixins, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticated 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.core.permissions import IsStoreOwner
from apps.stores.models import Store from apps.stores.models import Store
from .filters import ProductFilter
from .models import Product, ProductCategory from .models import Product, ProductCategory
from .serializers import ( from .serializers import (
ProductCategorySerializer, ProductCategorySerializer,
@ -27,15 +26,6 @@ class ProductCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
queryset = ProductCategory.objects.all() 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): class ProductViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
"""Customer-facing product browsing (store page, product detail, search).""" """Customer-facing product browsing (store page, product detail, search)."""
@ -44,29 +34,13 @@ class ProductViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.
queryset = Product.objects.filter( queryset = Product.objects.filter(
is_active=True, store__status=Store.Status.APPROVED, is_active=True, store__status=Store.Status.APPROVED,
).select_related('store', 'category') ).select_related('store', 'category')
filterset_class = ProductFilter
def get_serializer_class(self): def get_serializer_class(self):
if self.action == 'retrieve': if self.action == 'retrieve':
return ProductDetailSerializer return ProductDetailSerializer
return ProductListSerializer 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): class SellerProductViewSet(viewsets.ModelViewSet):
"""Seller's own product management (S06 list, S07 add, S08 inventory).""" """Seller's own product management (S06 list, S07 add, S08 inventory)."""

24
apps/locations/filters.py Normal file
View file

@ -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))

View file

@ -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 import mixins, viewsets
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from .filters import CityFilter, NeighborhoodFilter
from .models import Address, City, Neighborhood from .models import Address, City, Neighborhood
from .serializers import AddressSerializer, CitySerializer, NeighborhoodSerializer 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): class CityViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
schema_tags = ['Locations'] schema_tags = ['Locations']
permission_classes = [AllowAny] permission_classes = [AllowAny]
serializer_class = CitySerializer serializer_class = CitySerializer
queryset = City.objects.filter(is_active=True) queryset = City.objects.filter(is_active=True)
filterset_class = CityFilter
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
@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): class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
schema_tags = ['Locations'] schema_tags = ['Locations']
permission_classes = [AllowAny] permission_classes = [AllowAny]
serializer_class = NeighborhoodSerializer serializer_class = NeighborhoodSerializer
queryset = Neighborhood.objects.filter(is_active=True).select_related('city') queryset = Neighborhood.objects.filter(is_active=True).select_related('city')
filterset_class = NeighborhoodFilter
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
class AddressViewSet(viewsets.ModelViewSet): class AddressViewSet(viewsets.ModelViewSet):

View file

@ -160,15 +160,12 @@ class SellerOrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, views
required_alternate_scopes = { required_alternate_scopes = {
"POST": [[]], "POST": [[]],
} }
filterset_fields = ['status']
def get_queryset(self): def get_queryset(self):
if getattr(self, 'swagger_fake_view', False): if getattr(self, 'swagger_fake_view', False):
return Order.objects.none() return Order.objects.none()
queryset = Order.objects.filter(store=self.request.user.store).select_related('store').prefetch_related('items') return 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
def _apply_transition(self, request, to_status): def _apply_transition(self, request, to_status):
order = self.get_object() order = self.get_object()

11
apps/reviews/filters.py Normal file
View file

@ -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']

View file

@ -6,6 +6,7 @@ from rest_framework.response import Response
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.core.permissions import IsStoreOwner from apps.core.permissions import IsStoreOwner
from .filters import ReviewFilter
from .models import Review from .models import Review
from .serializers import ReviewSerializer, SellerReplySerializer from .serializers import ReviewSerializer, SellerReplySerializer
@ -19,19 +20,14 @@ class ReviewViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.Gen
required_alternate_scopes = { required_alternate_scopes = {
"POST": [[]], "POST": [[]],
} }
filterset_class = ReviewFilter
queryset = Review.objects.select_related('customer', 'store')
def get_permissions(self): def get_permissions(self):
if self.action == 'create': if self.action == 'create':
return [IsAuthenticatedOrTokenMatchesOASRequirements] return [IsAuthenticatedOrTokenMatchesOASRequirements]
return [AllowAny()] 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): class SellerReviewViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
"""Reviews left for the authenticated seller's store, with reply support.""" """Reviews left for the authenticated seller's store, with reply support."""

19
apps/stores/filters.py Normal file
View file

@ -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))

View file

@ -1,7 +1,6 @@
from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D from django.contrib.gis.measure import D
from django.db.models import Q
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view
from rest_framework import mixins, status, viewsets 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 apps.core.permissions import IsStoreOwner
from .filters import StoreFilter
from .models import Store, StoreCategory, StoreWorkingHours from .models import Store, StoreCategory, StoreWorkingHours
from .serializers import ( from .serializers import (
SellerStoreSerializer, SellerStoreSerializer,
@ -33,9 +33,6 @@ class StoreCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
@extend_schema_view( @extend_schema_view(
list=extend_schema( list=extend_schema(
parameters=[ 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('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).'), 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'] schema_tags = ['Stores']
permission_classes = [AllowAny] 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): def get_serializer_class(self):
if self.action == 'retrieve': if self.action == 'retrieve':
@ -56,18 +54,6 @@ class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() 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') lat = self.request.query_params.get('lat')
lng = self.request.query_params.get('lng') lng = self.request.query_params.get('lng')
if lat and lng: if lat and lng:
@ -76,7 +62,7 @@ class StoreViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
distance=Distance('location', point) distance=Distance('location', point)
).order_by('distance') ).order_by('distance')
return queryset.distinct() return queryset
class SellerStoreView(APIView): class SellerStoreView(APIView):