Merge pull request 'Add search to city/neighborhood endpoints; document filters for swagger' (#4) from feature/filter-stores into master

Reviewed-on: #4
This commit is contained in:
Ghasemi 2026-08-23 02:47:04 -04:00
commit 6796922fe6
13 changed files with 171 additions and 68 deletions

View file

@ -7,5 +7,12 @@
- `GET /api/v1/products/` — same issue: documented the existing `store`, `category`, and `search` query params for swagger (`apps/catalog/views.py`).
### Added
- `GET /api/v1/cities/?search=` — search cities by name (`apps/locations/views.py`); previously no text search existed.
- `GET /api/v1/neighborhoods/?search=` — search neighborhoods by neighborhood name or city name, alongside the existing `city` UUID filter (`apps/locations/views.py`).
- `GET /api/v1/stores/` now returns `phone_number` in the list (previously only on the single-store detail view) and a `distance_km` field, populated whenever `lat`/`lng` are passed (`apps/stores/serializers.py`).
- 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).

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.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)."""

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

@ -0,0 +1,42 @@
from rest_framework.test import APITestCase
from apps.locations.models import City, Neighborhood
class LocationListFilterTests(APITestCase):
@classmethod
def setUpTestData(cls):
cls.tehran = City.objects.create(name='تهران', slug='tehran')
cls.mashhad = City.objects.create(name='مشهد', slug='mashhad')
cls.vanak = Neighborhood.objects.create(city=cls.tehran, name='ونک', slug='vanak')
cls.tajrish = Neighborhood.objects.create(city=cls.tehran, name='تجریش', slug='tajrish')
cls.mashhad_hood = Neighborhood.objects.create(city=cls.mashhad, name='احمدآباد', slug='ahmadabad')
def test_search_city_by_name(self):
response = self.client.get('/api/v1/cities/', {'search': 'مشهد'})
self.assertEqual(response.status_code, 200)
uuids = {city['uuid'] for city in response.data['results']}
self.assertEqual(uuids, {str(self.mashhad.uuid)})
def test_filter_neighborhood_by_city(self):
response = self.client.get('/api/v1/neighborhoods/', {'city': str(self.tehran.uuid)})
self.assertEqual(response.status_code, 200)
uuids = {n['uuid'] for n in response.data['results']}
self.assertEqual(uuids, {str(self.vanak.uuid), str(self.tajrish.uuid)})
def test_search_neighborhood_by_name(self):
response = self.client.get('/api/v1/neighborhoods/', {'search': 'ونک'})
self.assertEqual(response.status_code, 200)
uuids = {n['uuid'] for n in response.data['results']}
self.assertEqual(uuids, {str(self.vanak.uuid)})
def test_search_neighborhood_by_city_name(self):
response = self.client.get('/api/v1/neighborhoods/', {'search': 'مشهد'})
self.assertEqual(response.status_code, 200)
uuids = {n['uuid'] for n in response.data['results']}
self.assertEqual(uuids, {str(self.mashhad_hood.uuid)})

View file

@ -3,6 +3,7 @@ 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
@ -12,6 +13,7 @@ class CityViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Gen
permission_classes = [AllowAny]
serializer_class = CitySerializer
queryset = City.objects.filter(is_active=True)
filterset_class = CityFilter
class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
@ -19,13 +21,7 @@ class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, view
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)
return queryset
filterset_class = NeighborhoodFilter
class AddressViewSet(viewsets.ModelViewSet):

View file

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

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.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."""

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

@ -31,13 +31,14 @@ class StoreListSerializer(serializers.ModelSerializer):
category = StoreCategorySerializer(read_only=True)
logo_url = serializers.SerializerMethodField()
cover_image_url = serializers.SerializerMethodField()
distance_km = serializers.SerializerMethodField()
class Meta:
model = Store
fields = (
'uuid', 'name', 'category', 'logo', 'logo_url', 'cover_image', 'cover_image_url',
'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
'free_delivery_threshold', 'is_open',
'phone_number', 'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
'free_delivery_threshold', 'is_open', 'distance_km',
)
@extend_schema_field(serializers.URLField(allow_null=True))
@ -48,13 +49,19 @@ class StoreListSerializer(serializers.ModelSerializer):
def get_cover_image_url(self, obj):
return presigned_media_url(obj.cover_image)
@extend_schema_field(serializers.FloatField(allow_null=True))
def get_distance_km(self, obj):
"""Only present when the request passed `lat`/`lng` (see StoreViewSet.get_queryset)."""
distance = getattr(obj, 'distance', None)
return round(distance.km, 2) if distance is not None else None
class StoreDetailSerializer(StoreListSerializer):
working_hours = StoreWorkingHoursSerializer(many=True, read_only=True)
class Meta(StoreListSerializer.Meta):
fields = StoreListSerializer.Meta.fields + (
'description', 'address', 'phone_number',
'description', 'address',
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'working_hours',
)

View file

@ -1,3 +1,5 @@
from django.contrib.gis.geos import Point
from apps.stores.models import StoreCategory
from apps.orders.tests.base import OrdersTestCase
@ -21,3 +23,28 @@ class StoreListFilterTests(OrdersTestCase):
self.assertEqual(response.status_code, 200)
uuids = {store['uuid'] for store in response.data['results']}
self.assertEqual(uuids, {str(self.store1.uuid), str(self.store2.uuid)})
def test_phone_number_included_in_list(self):
self.store1.phone_number = '02112345678'
self.store1.save(update_fields=['phone_number'])
response = self.client.get('/api/v1/stores/')
store = next(s for s in response.data['results'] if s['uuid'] == str(self.store1.uuid))
self.assertEqual(store['phone_number'], '02112345678')
def test_distance_km_present_when_lat_lng_given(self):
self.store1.location = Point(51.4, 35.75, srid=4326)
self.store1.save(update_fields=['location'])
response = self.client.get('/api/v1/stores/', {'lat': '35.75', 'lng': '51.4'})
store = next(s for s in response.data['results'] if s['uuid'] == str(self.store1.uuid))
self.assertIsNotNone(store['distance_km'])
self.assertAlmostEqual(store['distance_km'], 0, delta=0.1)
def test_distance_km_absent_without_lat_lng(self):
response = self.client.get('/api/v1/stores/')
store = next(s for s in response.data['results'] if s['uuid'] == str(self.store1.uuid))
self.assertIsNone(store['distance_km'])

View file

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