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:
commit
6796922fe6
13 changed files with 171 additions and 68 deletions
|
|
@ -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
17
apps/catalog/filters.py
Normal 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))
|
||||
|
|
@ -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
24
apps/locations/filters.py
Normal 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))
|
||||
42
apps/locations/tests/test_location_list.py
Normal file
42
apps/locations/tests/test_location_list.py
Normal 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)})
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
11
apps/reviews/filters.py
Normal 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']
|
||||
|
|
@ -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
19
apps/stores/filters.py
Normal 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))
|
||||
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue