Compare commits

..

No commits in common. "6796922fe68f9856f1c2e4938c1aa51dceb9e915" and "2b3fd0fc28e34f13321ed11ad7a2db7bcaf30676" have entirely different histories.

13 changed files with 68 additions and 171 deletions

View file

@ -7,12 +7,5 @@
- `GET /api/v1/products/` — same issue: documented the existing `store`, `category`, and `search` query params for swagger (`apps/catalog/views.py`). - `GET /api/v1/products/` — same issue: documented the existing `store`, `category`, and `search` query params for swagger (`apps/catalog/views.py`).
### Added ### 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 `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.
### 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).

View file

@ -1,17 +0,0 @@
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,3 +1,5 @@
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
@ -8,7 +10,6 @@ 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,
@ -26,6 +27,15 @@ 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)."""
@ -34,13 +44,29 @@ 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)."""

View file

@ -1,24 +0,0 @@
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,42 +0,0 @@
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,7 +3,6 @@ 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
@ -13,7 +12,6 @@ class CityViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Gen
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
class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
@ -21,7 +19,13 @@ class NeighborhoodViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, view
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)
return queryset
class AddressViewSet(viewsets.ModelViewSet): class AddressViewSet(viewsets.ModelViewSet):

View file

@ -160,12 +160,15 @@ 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()
return Order.objects.filter(store=self.request.user.store).select_related('store').prefetch_related('items') 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
def _apply_transition(self, request, to_status): def _apply_transition(self, request, to_status):
order = self.get_object() order = self.get_object()

View file

@ -1,11 +0,0 @@
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,7 +6,6 @@ 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
@ -20,14 +19,19 @@ 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."""

View file

@ -1,19 +0,0 @@
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,14 +31,13 @@ class StoreListSerializer(serializers.ModelSerializer):
category = StoreCategorySerializer(read_only=True) category = StoreCategorySerializer(read_only=True)
logo_url = serializers.SerializerMethodField() logo_url = serializers.SerializerMethodField()
cover_image_url = serializers.SerializerMethodField() cover_image_url = serializers.SerializerMethodField()
distance_km = serializers.SerializerMethodField()
class Meta: class Meta:
model = Store model = Store
fields = ( fields = (
'uuid', 'name', 'category', 'logo', 'logo_url', 'cover_image', 'cover_image_url', 'uuid', 'name', 'category', 'logo', 'logo_url', 'cover_image', 'cover_image_url',
'phone_number', 'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee', 'rating_avg', 'rating_count', 'min_order_amount', 'delivery_fee',
'free_delivery_threshold', 'is_open', 'distance_km', 'free_delivery_threshold', 'is_open',
) )
@extend_schema_field(serializers.URLField(allow_null=True)) @extend_schema_field(serializers.URLField(allow_null=True))
@ -49,19 +48,13 @@ class StoreListSerializer(serializers.ModelSerializer):
def get_cover_image_url(self, obj): def get_cover_image_url(self, obj):
return presigned_media_url(obj.cover_image) 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): class StoreDetailSerializer(StoreListSerializer):
working_hours = StoreWorkingHoursSerializer(many=True, read_only=True) working_hours = StoreWorkingHoursSerializer(many=True, read_only=True)
class Meta(StoreListSerializer.Meta): class Meta(StoreListSerializer.Meta):
fields = StoreListSerializer.Meta.fields + ( fields = StoreListSerializer.Meta.fields + (
'description', 'address', 'description', 'address', 'phone_number',
'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'working_hours', 'accepts_wallet', 'accepts_online', 'accepts_cash_on_delivery', 'working_hours',
) )

View file

@ -1,5 +1,3 @@
from django.contrib.gis.geos import Point
from apps.stores.models import StoreCategory from apps.stores.models import StoreCategory
from apps.orders.tests.base import OrdersTestCase from apps.orders.tests.base import OrdersTestCase
@ -23,28 +21,3 @@ class StoreListFilterTests(OrdersTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
uuids = {store['uuid'] for store in response.data['results']} uuids = {store['uuid'] for store in response.data['results']}
self.assertEqual(uuids, {str(self.store1.uuid), str(self.store2.uuid)}) 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,6 +1,7 @@
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
@ -12,7 +13,6 @@ 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,6 +33,9 @@ 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).'),
], ],
@ -43,8 +46,7 @@ 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').distinct() queryset = Store.objects.filter(status=Store.Status.APPROVED).select_related('category', 'city')
filterset_class = StoreFilter
def get_serializer_class(self): def get_serializer_class(self):
if self.action == 'retrieve': if self.action == 'retrieve':
@ -54,6 +56,18 @@ 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:
@ -62,7 +76,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 return queryset.distinct()
class SellerStoreView(APIView): class SellerStoreView(APIView):