diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c04a3..0205674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/apps/catalog/filters.py b/apps/catalog/filters.py new file mode 100644 index 0000000..0c5a812 --- /dev/null +++ b/apps/catalog/filters.py @@ -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)) diff --git a/apps/catalog/views.py b/apps/catalog/views.py index 6d62518..4474c83 100644 --- a/apps/catalog/views.py +++ b/apps/catalog/views.py @@ -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).""" diff --git a/apps/locations/filters.py b/apps/locations/filters.py new file mode 100644 index 0000000..fbbace7 --- /dev/null +++ b/apps/locations/filters.py @@ -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)) diff --git a/apps/locations/tests/test_location_list.py b/apps/locations/tests/test_location_list.py new file mode 100644 index 0000000..c571b44 --- /dev/null +++ b/apps/locations/tests/test_location_list.py @@ -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)}) diff --git a/apps/locations/views.py b/apps/locations/views.py index f156533..ea09874 100644 --- a/apps/locations/views.py +++ b/apps/locations/views.py @@ -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): diff --git a/apps/orders/views.py b/apps/orders/views.py index 2ee9e42..a7ba204 100644 --- a/apps/orders/views.py +++ b/apps/orders/views.py @@ -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() diff --git a/apps/reviews/filters.py b/apps/reviews/filters.py new file mode 100644 index 0000000..6f55ffe --- /dev/null +++ b/apps/reviews/filters.py @@ -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'] diff --git a/apps/reviews/views.py b/apps/reviews/views.py index 3d58eda..f2f4746 100644 --- a/apps/reviews/views.py +++ b/apps/reviews/views.py @@ -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.""" diff --git a/apps/stores/filters.py b/apps/stores/filters.py new file mode 100644 index 0000000..808bdf2 --- /dev/null +++ b/apps/stores/filters.py @@ -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)) diff --git a/apps/stores/serializers.py b/apps/stores/serializers.py index 262fde7..26b2af7 100644 --- a/apps/stores/serializers.py +++ b/apps/stores/serializers.py @@ -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', ) diff --git a/apps/stores/tests/test_store_list.py b/apps/stores/tests/test_store_list.py index 9049da1..11b61cf 100644 --- a/apps/stores/tests/test_store_list.py +++ b/apps/stores/tests/test_store_list.py @@ -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']) diff --git a/apps/stores/views.py b/apps/stores/views.py index 8fb01af..aab11e7 100644 --- a/apps/stores/views.py +++ b/apps/stores/views.py @@ -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):