import re from .models import ProductModel from rest_framework import serializers from django.core.paginator import Paginator from rest_framework.views import APIView from .models import * from .serializers import * from rest_framework import status from rest_framework.response import Response from django.db.models import Q, Value, Case, When, FloatField, F, CharField, Func from django.db.models.functions import Coalesce, Length from django.contrib.postgres.search import TrigramSimilarity from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticatedOrReadOnly from utils.pagination import StructurePagination from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes from rest_framework.permissions import AllowAny from order.serializers import OrderItemSerailzier from order.models import OrderModel from django.db.models import Min, Max, Count, Prefetch from home.models import ShowCaseSlider from home.serializers import ShowCaseSliderSerialzier from order.models import Cart, CartItem from django.db.models import Min, Max, Value _PERSIAN_CHAR_MAP = str.maketrans({ # Arabic letters -> Persian equivalents 'ي': 'ی', 'ك': 'ک', # Arabic ya/kaf presentation forms -> Persian 'ﻱ': 'ی', 'ﻲ': 'ی', 'ﻳ': 'ی', 'ﻴ': 'ی', 'ﻙ': 'ک', 'ﻚ': 'ک', 'ﻛ': 'ک', 'ﻜ': 'ک', # Alef variants -> bare alef (so "ایفون" matches "آیفون") 'آ': 'ا', 'أ': 'ا', 'إ': 'ا', 'ٱ': 'ا', # Hamza on waw/ya -> bare letter 'ؤ': 'و', 'ئ': 'ی', # Ta marbuta / he variants -> he 'ة': 'ه', 'ۀ': 'ه', 'ﻩ': 'ه', 'ﻪ': 'ه', 'ﻫ': 'ه', 'ﻬ': 'ه', # Tatweel - drop 'ـ': '', # Tashkeel (diacritics) - drop 'ً': '', 'ٌ': '', 'ٍ': '', 'َ': '', 'ُ': '', 'ِ': '', 'ّ': '', 'ْ': '', # Zero-width / direction marks '‌': ' ', '‍': ' ', '‎': '', '‏': '', # Arabic-Indic / Persian digits -> ASCII '۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4', '۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9', '٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4', '٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9', }) def _normalize_search_text(text): """Normalize a search string to handle Persian/Arabic variants, ZWNJ, and case.""" if not text: return '' return re.sub(r'\s+', ' ', text.translate(_PERSIAN_CHAR_MAP)).strip().lower() # SQL-side equivalent of _PERSIAN_CHAR_MAP for PostgreSQL translate(). # Each char at position i in FROM is replaced by char at position i in TO; # chars past len(TO) are deleted entirely. This must mirror the Python map so # stored values and query strings normalize to the same form. _SQL_NORM_FROM = ( 'يك' # Arabic ya/kaf -> Persian 'ﻱﻲﻳﻴ' # Arabic ya presentation forms 'ﻙﻚﻛﻜ' # Arabic kaf presentation forms 'آأإٱ' # alef variants 'ؤ' # waw with hamza 'ئ' # ya with hamza 'ةۀ' # ta marbuta / he with hamza 'ﻩﻪﻫﻬ' # he presentation forms '‌‍' # ZWNJ, ZWJ -> space '۰۱۲۳۴۵۶۷۸۹' # Persian digits '٠١٢٣٤٥٦٧٨٩' # Arabic-Indic digits # Deletions (no matching char in TO): 'ـ' # tatweel '‎‏' # LRM, RLM 'ًٌٍَُِّْ' # tashkeel ) _SQL_NORM_TO = ( 'یک' 'یییی' 'کککک' 'اااا' 'و' 'ی' 'هه' 'هههه' ' ' '0123456789' '0123456789' ) def NormalizePersian(expression): """SQL expression that calls the ``normalize_persian(text)`` Postgres function. The function (defined in migration 0076) computes ``lower(translate(t, FROM, TO))`` and is marked IMMUTABLE so GIN trigram indexes on ``normalize_persian(name)`` etc. can be matched by the planner. Calling the function (instead of inlining translate/lower) is what lets queries use those indexes — otherwise every search is a full sequential scan. """ return Func(expression, function='normalize_persian', output_field=CharField()) def _apply_product_search(queryset, search_query): """Filter and rank a Product queryset by a (possibly Persian) search query. Returns (queryset, normalized_query). The queryset is annotated with ``similarity`` so callers can ``order_by('-similarity', ...)``. When no product strictly matches, falls back to a looser similarity-based filter so the user sees suggestions instead of an empty page. """ normalized_query = _normalize_search_text(search_query) if search_query else '' if not normalized_query: return queryset, '' tokens = [t for t in normalized_query.split(' ') if len(t) >= 2] annotated = queryset.annotate( norm_name=NormalizePersian('name'), norm_keywords=NormalizePersian(Coalesce('meta_keywords', Value(''))), norm_category=NormalizePersian(Coalesce('category__name', Value(''))), norm_desc=NormalizePersian(Coalesce('description', Value(''))), ).annotate( name_sim=TrigramSimilarity(F('norm_name'), normalized_query), keywords_sim=TrigramSimilarity(F('norm_keywords'), normalized_query), category_sim=TrigramSimilarity(F('norm_category'), normalized_query), desc_sim=TrigramSimilarity(F('norm_desc'), normalized_query), ).annotate( # Word-boundary aware bonuses. The space-padded variants are what make # "چای" rank above "چایساز" — the former matches "چای " (word boundary) # while the latter only matches the glued prefix. # # Uses case-sensitive lookups (__contains, not __icontains) because both # sides are already lowercased: __icontains would wrap the expression in # UPPER(...) and break the GIN trigram index match. match_bonus=Case( When(norm_name__exact=normalized_query, then=Value(10.0)), When(norm_name__startswith=normalized_query + ' ', then=Value(6.0)), When(norm_name__startswith=normalized_query, then=Value(3.5)), When(norm_name__contains=' ' + normalized_query + ' ', then=Value(3.0)), When(norm_name__contains=' ' + normalized_query, then=Value(2.5)), When(norm_name__contains=normalized_query + ' ', then=Value(2.5)), When(norm_name__contains=normalized_query, then=Value(1.5)), default=Value(0.0), output_field=FloatField(), ) ).annotate( similarity=( F('match_bonus') + F('name_sim') * Value(2.0) + F('keywords_sim') * Value(0.8) + F('category_sim') * Value(0.4) + F('desc_sim') * Value(0.15) ) ) if tokens: # Token AND filter. Limited to fields we have GIN trigram indexes for # (name, keywords, category.name in migration 0076) — including # description or slug here would force a sequential scan on the OR # branch and undo the index speedup. Description still contributes via # ``desc_sim`` to ranking on the already-narrowed result set. token_filter = Q() for token in tokens: token_filter &= ( Q(norm_name__contains=token) | Q(norm_keywords__contains=token) | Q(norm_category__contains=token) ) strict_filter = ( token_filter | Q(name_sim__gte=0.45) | Q(keywords_sim__gte=0.5) ) else: strict_filter = Q(name_sim__gte=0.4) | Q(keywords_sim__gte=0.4) strict_products = annotated.filter(strict_filter).distinct() if strict_products.exists(): return strict_products, normalized_query # No strict matches — relax thresholds so the user gets "similar" # suggestions instead of an empty result page. loose_filter = ( Q(name_sim__gte=0.18) | Q(keywords_sim__gte=0.22) | Q(category_sim__gte=0.3) | Q(match_bonus__gt=0) ) return annotated.filter(loose_filter).distinct(), normalized_query # class APIView(APIView): # def __init__(self, *args, **kwargs): # super().__init__(*args, **kwargs) # print('here') # print(self.permission_classes) # if AllowAny in self.permission_classes or not self.permission_classes: # print('asdf') # self.authentication_classes = [] class AllCategories(APIView): serializer_class = MainCategorySerializer authentication_classes = [] @extend_schema( # parameters=[ # OpenApiParameter( # name="search", # description="Search by category name or description.", # required=False, # type=OpenApiTypes.STR, # ) # ], responses={ 200: MainCategorySerializer(many=True), 404: OpenApiTypes.OBJECT, }, ) def get(self, request): # search_query = request.query_params.get('search', None) # if search_query: # categories = MainCategoryModel.objects.filter(Q(name__icontains=search_query) | Q(slug__icontains=search_query)) # else: # Optimize query with prefetch_related to avoid N+1 queries categories = MainCategoryModel.objects.prefetch_related( Prefetch( 'subcategorys', queryset=SubCategoryModel.objects.annotate( product_count=Count('products') ) ) ).all() categories_ser = self.serializer_class( instance=categories, many=True, context={'request': request}) return Response(categories_ser.data, status=status.HTTP_200_OK) class UnitCategorySerializerV2(serializers.ModelSerializer): subcategorys = serializers.SerializerMethodField() class Meta: model = UnitCategoryModel fields = ['id', 'name', 'slug', 'icon', 'meta_title', 'meta_description', 'image', 'subcategorys'] def get_subcategorys(self, obj): main_categories = obj.maincategorys.all() return MainCategorySerializer(main_categories, many=True, context=self.context).data class AllCategoriesV2(APIView): serializer_class = UnitCategorySerializerV2 authentication_classes = [] @extend_schema( responses={ 200: UnitCategorySerializerV2(many=True), 404: OpenApiTypes.OBJECT, }, ) def get(self, request): from django.core.cache import cache # Check cache first cache_key = 'all_categories_v2' cached_data = cache.get(cache_key) if cached_data: return Response(cached_data, status=status.HTTP_200_OK) # Optimize query with prefetch_related to avoid N+1 queries unit_categories = UnitCategoryModel.objects.prefetch_related( Prefetch( 'maincategorys', queryset=MainCategoryModel.objects.only( 'id', 'name', 'slug', 'icon', 'meta_title', 'meta_description', 'image', 'video' ).prefetch_related( Prefetch( 'subcategorys', queryset=SubCategoryModel.objects.only( 'id', 'name', 'slug', 'icon', 'meta_title', 'meta_description', 'image', 'parent_id' ).annotate( product_count=Count('products') ) ) ) ) ).only('id', 'name', 'slug', 'icon', 'meta_title', 'meta_description', 'image').all() categories_ser = self.serializer_class( instance=unit_categories, many=True, context={'request': request}) response_data = categories_ser.data # Cache for 10 minutes cache.set(cache_key, response_data, 60 * 10) return Response(response_data, status=status.HTTP_200_OK) class ProductView(APIView): serializer_class = DynamicProductSerializer permission_classes = [AllowAny] # authentication_classes = [] @extend_schema( responses={ 200: DynamicProductSerializer(context={'view_type': 'instance'}), 404: OpenApiTypes.OBJECT, }, ) def get(self, request, slug): from django.db.models import F # Optimize query with select_related and prefetch_related to avoid N+1 queries product = get_object_or_404( ProductModel.objects.select_related( 'category', 'category__parent', 'shop') .prefetch_related( 'variants__product_attributes__attribute_type', 'variants__in_pack_items', 'variants__images', 'variants__details__details', 'variants__details__detail_category', 'related_products__variants__product_attributes', 'related_products__category', 'ratings', ), slug=slug ) # Increment product view count atomically using F expressions to avoid race conditions ProductModel.objects.filter(id=product.id).update(view=F('view') + 1) product.view += 1 # Update in-memory instance for response if request.user.is_authenticated: cart_obj, _ = Cart.objects.get_or_create(user=request.user) # Optimize cart items query - prefetch all related data cart_items = cart_obj.items.select_related( 'product_variant__product' ).prefetch_related( 'product_variant__images', 'product_variant__product_attributes__attribute_type' ) cart_items_ser = OrderItemSerailzier( cart_items, many=True, context={'request': request}) product_ser_context = { 'request': request, 'view_type': 'instance', 'cart_items': cart_items_ser.data} else: product_ser_context = {'request': request, 'view_type': 'instance'} product_ser = self.serializer_class( instance=product, many=False, context=product_ser_context) return Response(product_ser.data, status=status.HTTP_200_OK) class AllProductsView(APIView): serializer_class = DynamicProductSerializer pagination_class = StructurePagination authentication_classes = [] @extend_schema( parameters=[ OpenApiParameter( name="search", description="Search by product name or description.", required=False, type=OpenApiTypes.STR, ), # OpenApiParameter( # name="category", # type={'type': 'array', 'items': {'type': 'number'}}, # location=OpenApiParameter.QUERY, # required=False, # style='form', # explode=False, # ), OpenApiParameter( name="category", type=OpenApiTypes.STR, description="slug category (send it with category type)", required=False, ), # OpenApiParameter( # name="category_type", # type=OpenApiTypes.STR, # required=False, # enum=['sub', 'main'], # ), OpenApiParameter( name="price_gte", description="Filter products with price greater than or equal to this value.", required=False, type=OpenApiTypes.FLOAT, ), OpenApiParameter( name="price_lte", description="Filter products with price less than or equal to this value.", required=False, type=OpenApiTypes.FLOAT, ), OpenApiParameter( name="sort", description=( "Sort results by one of the following fields:\n" "`name`, `-name`, `price`, `-price`, `created_at`, `-created_at`." "\nPrefix with `-` for descending order." "remove the price form sorting templory " ), required=False, type=OpenApiTypes.STR, ), OpenApiParameter( name="limit", description="Number of results to return per page (pagination).", required=False, type=OpenApiTypes.INT, ), OpenApiParameter( name="offset", description="The starting position of the results (pagination).", required=False, type=OpenApiTypes.INT, ), OpenApiParameter( name="in_stock", description="Filter products that are in stock (positive stock).", required=False, type=OpenApiTypes.BOOL, ), OpenApiParameter( name="has_discount", description="Filter products that have a discount.", required=False, type=OpenApiTypes.BOOL, ) ], description=( "Retrieve products with optional filters and sorting. " "Provide a list of category IDs to filter products by those categories and their subcategories." ), responses={ 200: DynamicProductSerializer(many=True, context={'view_type': 'list'}), 404: OpenApiTypes.OBJECT, }, ) def get(self, request): try: category_slug = request.query_params.get('category') # Start with optimized base query products = ProductModel.objects.select_related( 'category', 'category__parent' ).prefetch_related( 'variants__product_attributes__attribute_type', 'variants__images', ) if category_slug: if 'category' not in category_slug: sub_category = get_object_or_404( SubCategoryModel, slug=category_slug) products = products.filter(category=sub_category) else: main_category = get_object_or_404( MainCategoryModel, slug=category_slug) sub_categories = main_category.subcategorys.all() products = products.filter(category__in=sub_categories) in_stock = request.query_params.get('in_stock') if in_stock is not None: if in_stock.lower() == 'true': products = products.filter( variants__in_stock__gt=0 ).distinct() elif in_stock.lower() != 'false': return Response( {'detail': 'in_stock must be "true" or "false".'}, status=status.HTTP_400_BAD_REQUEST ) # Discount filter has_discount = request.query_params.get('has_discount') if has_discount is not None: if has_discount.lower() == 'true': products = products.filter( variants__discount__gt=0 ).distinct() elif has_discount.lower() != 'false': return Response( {'detail': 'has_discount must be "true" or "false".'}, status=status.HTTP_400_BAD_REQUEST ) # Search (Persian-aware, with typo tolerance + similar-results fallback) search_query = request.query_params.get('search') products, normalized_query = _apply_product_search(products, search_query) # Price annotation (IMPORTANT for sorting) products = products.annotate( min_price=Min('variants__price'), max_price=Max('variants__price') ) # Price filters price_gte = request.query_params.get('price_gte') if price_gte: try: products = products.filter( max_price__gte=float(price_gte) ) except ValueError: return Response( {'detail': 'price_gte must be a number.'}, status=status.HTTP_400_BAD_REQUEST ) price_lte = request.query_params.get('price_lte') if price_lte: try: products = products.filter( min_price__lte=float(price_lte) ) except ValueError: return Response({'detail': 'price_lte must be a number.'}, status=status.HTTP_400_BAD_REQUEST) # Sorting sort_by = request.query_params.get('sort') if sort_by == 'newest': sort_by = '-created_at' elif sort_by == 'oldest': sort_by = 'created_at' if sort_by in ['name', '-name', 'created_at', '-created_at']: products = products.order_by(sort_by) elif sort_by in ['price', '-price']: products = products.order_by('min_price' if sort_by == 'price' else '-min_price') elif normalized_query: # Tie-break on shorter name: ensures "چای" outranks "چای ساز" # when their bonus-adjusted similarities are close. products = products.order_by('-similarity', Length('norm_name'), 'name') else: products = products.order_by('name') # Pagination paginator = self.pagination_class() paginated_products = paginator.paginate_queryset(products, request) serializer = self.serializer_class( paginated_products, many=True, context={'request': request, 'view_type': 'list'} ) return paginator.get_paginated_response(serializer.data) except MainCategoryModel.DoesNotExist: return Response({"detail": "Main Category not found."}, status=status.HTTP_404_NOT_FOUND) except SubCategoryModel.DoesNotExist: return Response({"detail": "Sub Category not found."}, status=status.HTTP_404_NOT_FOUND) class ShowCaseCategoryListView(APIView): serializer_class = ShowCaseSliderSerialzier permission_classes = [AllowAny] def get(self, request): categoryes = ShowCaseSlider.objects.all() categoryes_ser = self.serializer_class( instance=categoryes, many=True, context={'request': request}) return Response(categoryes_ser.data, status=status.HTTP_200_OK) class ShowCaseProductsView(APIView): serializer_class = DynamicProductSerializer pagination_class = StructurePagination authentication_classes = [] @extend_schema( parameters=[ OpenApiParameter( name="search", description="Search by product name or description.", required=False, type=OpenApiTypes.STR, ), OpenApiParameter( name="slider_category", type=OpenApiTypes.INT, required=False, ), OpenApiParameter( name="price_gte", description="Filter products with price greater than or equal to this value.", required=False, type=OpenApiTypes.FLOAT, ), OpenApiParameter( name="price_lte", description="Filter products with price less than or equal to this value.", required=False, type=OpenApiTypes.FLOAT, ), OpenApiParameter( name="sort", description=( "Sort results by one of the following fields:\n" "`name`, `-name`, `price`, `-price`, `created_at`, `-created_at`." "\nPrefix with `-` for descending order." "remove the price form sorting templory " ), required=False, type=OpenApiTypes.STR, ), OpenApiParameter( name="limit", description="Number of results to return per page (pagination).", required=False, type=OpenApiTypes.INT, ), OpenApiParameter( name="offset", description="The starting position of the results (pagination).", required=False, type=OpenApiTypes.INT, ), OpenApiParameter( name="in_stock", description="Filter products that are in stock (positive stock).", required=False, type=OpenApiTypes.BOOL, ), OpenApiParameter( name="has_discount", description="Filter products that have a discount.", required=False, type=OpenApiTypes.BOOL, ) ], description=( "Retrieve products with optional filters and sorting. " "Provide a list of category IDs to filter products by those categories and their subcategories." ), responses={ 200: DynamicProductSerializer(many=True, context={'view_type': 'list'}), 404: OpenApiTypes.OBJECT, }, ) def get(self, request): try: category_id = request.query_params.get('slider_category', None) # Start with optimized base query products = ProductModel.objects.select_related( 'category', 'category__parent' ).prefetch_related( 'variants__product_attributes__attribute_type', 'variants__images', ) if category_id: try: category_id = int(category_id) except ValueError: return Response({'detail': 'value error category id should be a number'}, status=status.HTTP_400_BAD_REQUEST) slider_category = get_object_or_404( ShowCaseSlider, pk=category_id) products = products.filter( variants__slider_category=slider_category).distinct() else: products = products.filter( variants__slider_category__isnull=False).distinct() # Filter by stock status if `in_stock` is specified in_stock = request.query_params.get('in_stock', "false") == 'true' if in_stock: products = products.filter(variants__in_stock__gt=0).distinct() # Filter by discount if `has_discount` is specified has_discount = request.query_params.get( 'has_discount', "false") == 'true' if has_discount: products = products.filter(variants__discount__gt=0).distinct() # Search filter (Persian-aware, with typo tolerance + similar-results fallback) search_query = request.query_params.get('search', None) products, normalized_query = _apply_product_search(products, search_query) # Price filters price_gte = request.query_params.get('price_gte', None) price_lte = request.query_params.get('price_lte', None) products = products.annotate(min_price=Min( 'variants__price'), max_price=Max('variants__price')) if price_gte: products = products.filter(max_price__gte=price_gte) if price_lte: products = products.filter(min_price__lte=price_lte) # Sorting sort_by = request.query_params.get('sort', None) if sort_by in ['name', '-name', 'created_at', '-created_at']: products = products.order_by(sort_by) elif normalized_query: products = products.order_by('-similarity', Length('norm_name'), 'name') else: products = products.order_by('name') # Pagination paginator = self.pagination_class() paginated_products = paginator.paginate_queryset(products, request) serializer = self.serializer_class(paginated_products, many=True, context={ 'request': request, 'view_type': 'slider'}) return paginator.get_paginated_response(serializer.data) except MainCategoryModel.DoesNotExist: return Response({"detail": "Category not found."}, status=status.HTTP_404_NOT_FOUND) class CommentView(APIView): serializer_class = CommentSerializer permission_classes = [IsAuthenticatedOrReadOnly] pagination_class = StructurePagination @extend_schema( parameters=[ OpenApiParameter( name="limit", description="Number of results to return per page (pagination).", required=False, type=OpenApiTypes.INT, ), OpenApiParameter( name="offset", description="The starting position of the results (pagination).", required=False, type=OpenApiTypes.INT, ) ], responses={ 200: CommentSerializer(many=True), 404: OpenApiTypes.OBJECT, }, ) def get(self, request, slug): product = get_object_or_404(ProductModel, slug=slug) # Optimize comments query to prefetch user data comments = product.comments.filter( review_status__in=['not_reviwed', 'reviewed_and_confirmed'] ).select_related('user') paginator = self.pagination_class() paginated_comments = paginator.paginate_queryset(comments, request) # OPTIMIZATION: Fetch all user ratings in a single query # Get user IDs from paginated comments to only fetch ratings for displayed comments user_ids = [comment.user_id for comment in paginated_comments] # Single query to get all ratings for these users on this product user_ratings = ProductRating.objects.filter( product=product, user_id__in=user_ids ).values_list('user_id', 'rating') # Build cache dictionary for O(1) lookups in serializer user_ratings_cache = {user_id: rating for user_id, rating in user_ratings} # Pass cache to serializer context to avoid N queries comments_ser = self.serializer_class( instance=paginated_comments, many=True, context={ 'request': request, 'user_ratings_cache': user_ratings_cache, 'product': product } ) return paginator.get_paginated_response(comments_ser.data) def post(self, request, slug): comment_ser = CommentSerializer(data=request.data) product = get_object_or_404(ProductModel, slug=slug) if comment_ser.is_valid(): comment_ser.save(product=product, user=request.user) return Response(comment_ser.data, status=status.HTTP_201_CREATED) return Response(comment_ser.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, pk): comment = get_object_or_404(CommentModel, pk=pk) if comment.user == request.user: comment.delete() return Response(status=status.HTTP_204_NO_CONTENT) else: return Response({"detail": "شما اجازه ی پاک کردن این کامنت را ندارید"}, status=status.HTTP_403_FORBIDDEN) class BotProductSerializer(serializers.ModelSerializer): class Meta: model = ProductModel fields = ['pk', 'name'] class BotProductsView(APIView): serializer_class = BotProductSerializer def get(self, request): bot_products = ProductModel.objects.filter(show_in_bot=True) if bot_products.exists(): serialized = self.serializer_class(bot_products, many=True) return Response({ "success": True, "products": serialized.data }) else: return Response({ "success": False, "products": [] }) class BotProductDetailView(APIView): def get(self, request, pk): product = get_object_or_404(ProductModel, pk=pk, show_in_bot=True) return Response({ 'name': product.name, 'banner': product.bot_banner, 'link': f'https://heymlz.com/product/{product.slug}' }) class BotCategorySerializer(serializers.ModelSerializer): link = serializers.SerializerMethodField() class Meta: model = MainCategoryModel fields = ['pk', 'name', 'link'] def get_link(self, obj): return f'https://heymlz.com/products/category/{obj.slug}' class BotCategoryView(APIView): serializer_class = BotCategorySerializer def get(self, request): categories = MainCategoryModel.objects.all() categories_ser = self.serializer_class(categories, many=True) if categories.exists(): return Response({ "success": True, "categories": categories_ser.data }) else: return Response({ "success": False, "categories": [] }) class ProductRatingView(APIView): """ API endpoint to submit/update a product rating POST: /api/products//rating/ """ permission_classes = [IsAuthenticatedOrReadOnly] serializer_class = ProductRatingSerializer def post(self, request, slug): if not request.user.is_authenticated: return Response( {'detail': 'احراز هویت الزامی است'}, status=status.HTTP_401_UNAUTHORIZED ) product = get_object_or_404(ProductModel, slug=slug) # Check if user already rated this product existing_rating = ProductRating.objects.filter( product=product, user=request.user ).exists() if existing_rating: return Response( {'detail': 'شما قبلا این محصول را امتیاز دادید'}, status=status.HTTP_400_BAD_REQUEST ) serializer = ProductRatingSerializer( data=request.data, context={'product_id': product.id, 'request': request} ) if serializer.is_valid(): serializer.save() # Invalidate cache for this product from django.core.cache import cache cache.delete(f'product_avg_rating_{product.id}') return Response( {'detail': 'امتیاز شما با موفقیت ثبت شد'}, status=status.HTTP_201_CREATED ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)