Add search to city/neighborhood endpoints; document filters for swagger #4
10 changed files with 88 additions and 95 deletions
|
|
@ -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
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 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
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))
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
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.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
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))
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue