diff --git a/backend/core/settings/unfold_conf.py b/backend/core/settings/unfold_conf.py index 7596156..d71c96f 100644 --- a/backend/core/settings/unfold_conf.py +++ b/backend/core/settings/unfold_conf.py @@ -171,6 +171,18 @@ UNFOLD = { "link": reverse_lazy("admin:order_discountcode_changelist"), "permission": lambda request: request.user.is_superuser, }, + { + "title": _("نظرات کاربران"), + "icon": "comment", + "link": reverse_lazy("admin:product_commentmodel_changelist"), + "permission": lambda request: request.user.is_superuser, + }, + { + "title": _("امتیاز کاربران"), + "icon": "star_rate_half", + "link": reverse_lazy("admin:product_productrating_changelist"), + "permission": lambda request: request.user.is_superuser, + }, ], }, diff --git a/backend/product/admin.py b/backend/product/admin.py index 00abca0..6e12065 100644 --- a/backend/product/admin.py +++ b/backend/product/admin.py @@ -543,6 +543,20 @@ class CommentAdmin(ModelAdmin, ImportExportModelAdmin): return obj.content[0:35] + '...' display_content.short_description = 'محتوای کامنت' + def has_view_permission(self, request, obj = ...): + return request.user.is_superuser + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + + + @admin.register(DollorModel) class DollorAdmin(ModelAdmin, ImportExportModelAdmin): import_form_class = ImportForm @@ -558,4 +572,27 @@ class DollorAdmin(ModelAdmin, ImportExportModelAdmin): "widget": ArrayWidget, } } - readonly_fields = ('price',) \ No newline at end of file + readonly_fields = ('price',) + +@admin.register(ProductRating) +class ProductRatingAdmin(ModelAdmin): + list_display = ('product', 'user', 'rating', 'created_at') + list_filter = ('rating', 'created_at') + search_fields = ('product__name', 'user__phone', 'user__first_name', 'user__last_name') + readonly_fields = ('product', 'user', 'created_at', 'updated_at') + date_hierarchy = 'created_at' + ordering = ('-created_at',) + compressed_fields = True + warn_unsaved_form = True + + def has_view_permission(self, request, obj = ...): + return request.user.is_superuser + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser \ No newline at end of file diff --git a/backend/product/migrations/0073_productrating.py b/backend/product/migrations/0073_productrating.py new file mode 100644 index 0000000..14c53f5 --- /dev/null +++ b/backend/product/migrations/0073_productrating.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2 on 2026-05-12 04:59 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0072_remove_productmodel_product_show_idx_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ProductRating', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rating', models.PositiveIntegerField(choices=[(1, '1 - بسیار ضعیف'), (2, '2 - ضعیف'), (3, '3 - متوسط'), (4, '4 - خوب'), (5, '5 - عالی')], verbose_name='امتیاز')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='تاریخ آپدیت')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='product.productmodel', verbose_name='محصول')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_ratings', to=settings.AUTH_USER_MODEL, verbose_name='کاربر')), + ], + options={ + 'verbose_name': 'امتیاز محصول', + 'verbose_name_plural': 'امتیازات محصول', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['product'], name='rating_product_idx'), models.Index(fields=['user'], name='rating_user_idx'), models.Index(fields=['rating'], name='rating_rating_idx')], + 'unique_together': {('product', 'user')}, + }, + ), + ] diff --git a/backend/product/models.py b/backend/product/models.py index 1492992..808f666 100644 --- a/backend/product/models.py +++ b/backend/product/models.py @@ -493,3 +493,37 @@ class ProductVariant(DirtyFieldsMixin, models.Model): def save(self, *args, **kwargs): self.set_or_update_price() super().save(*args, **kwargs) + +class ProductRating(models.Model): + RATING_CHOICES = ( + (1, '1 - بسیار ضعیف'), + (2, '2 - ضعیف'), + (3, '3 - متوسط'), + (4, '4 - خوب'), + (5, '5 - عالی'), + ) + + product = models.ForeignKey( + ProductModel, on_delete=models.CASCADE, related_name='ratings', verbose_name='محصول') + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name='product_ratings', verbose_name='کاربر') + rating = models.PositiveIntegerField( + choices=RATING_CHOICES, verbose_name='امتیاز') + created_at = models.DateTimeField( + auto_now_add=True, verbose_name='تاریخ ایجاد') + updated_at = models.DateTimeField( + auto_now=True, verbose_name='تاریخ آپدیت') + + class Meta: + verbose_name = 'امتیاز محصول' + verbose_name_plural = 'امتیازات محصول' + unique_together = ('product', 'user') + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['product'], name='rating_product_idx'), + models.Index(fields=['user'], name='rating_user_idx'), + models.Index(fields=['rating'], name='rating_rating_idx'), + ] + + def __str__(self): + return f"{self.user} - {self.product} ({self.rating}⭐)" \ No newline at end of file diff --git a/backend/product/serializers.py b/backend/product/serializers.py index 5e153c6..cacd638 100644 --- a/backend/product/serializers.py +++ b/backend/product/serializers.py @@ -158,6 +158,7 @@ class DynamicProductSerializer(serializers.ModelSerializer): main_image = serializers.SerializerMethodField() customer_pickup_title = serializers.SerializerMethodField() customer_pickup_description = serializers.SerializerMethodField() + average_rating = serializers.SerializerMethodField() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -174,9 +175,9 @@ class DynamicProductSerializer(serializers.ModelSerializer): model = ProductModel fields = "__all__" view_type = { - 'list': ['id', 'name', 'rating', 'slug', 'category', 'colors', 'image', 'best_deal_price_before_discount', 'best_deal_price_after_discount', 'best_deal_discount', 'main_image'], - 'slider': ['id', 'name', 'rating', 'slug', 'category', 'variants', 'colors', 'image', 'best_deal_price_before_discount', 'best_deal_price_after_discount', 'best_deal_discount', ], - 'instance': ['id', 'name', 'description', 'rating', 'slug', 'meta_description', 'meta_keywords', 'meta_rating', 'category', 'related_products', 'in_pack_items', 'variants', 'colors', 'added_to_favorites', 'image', 'customer_pickup_title', 'customer_pickup_description'], + 'list': ['id', 'name', 'rating', 'slug', 'category', 'colors', 'image', 'best_deal_price_before_discount', 'best_deal_price_after_discount', 'best_deal_discount', 'main_image', 'average_rating'], + 'slider': ['id', 'name', 'rating', 'slug', 'category', 'variants', 'colors', 'image', 'best_deal_price_before_discount', 'best_deal_price_after_discount', 'best_deal_discount', 'average_rating'], + 'instance': ['id', 'name', 'description', 'rating', 'slug', 'meta_description', 'meta_keywords', 'meta_rating', 'category', 'related_products', 'in_pack_items', 'variants', 'colors', 'added_to_favorites', 'image', 'customer_pickup_title', 'customer_pickup_description', 'average_rating'], 'chat': ['id', 'name', 'description', 'variants', 'image'] } @@ -241,6 +242,39 @@ class DynamicProductSerializer(serializers.ModelSerializer): # Use exists() with filter instead of fetching all products return UserFavorites.objects.filter(user=request.user, products=obj).exists() + def get_average_rating(self, obj): + """Get cached average rating for product - optimized with prefetch""" + from django.core.cache import cache + from django.db.models import Avg + + cache_key = f'product_avg_rating_{obj.id}' + avg_rating = cache.get(cache_key) + + if avg_rating is None: + # Try to use prefetched ratings if available (no query needed) + try: + prefetched_ratings = obj._prefetched_objects_cache.get('ratings') + if prefetched_ratings is not None: + # Use prefetched data - no database query + ratings_list = [r.rating for r in prefetched_ratings] + if ratings_list: + avg_rating = round(sum(ratings_list) / len(ratings_list), 2) + else: + avg_rating = 0 + else: + # Fall back to aggregation query if not prefetched + avg_rating = obj.ratings.aggregate(Avg('rating'))['rating__avg'] or 0 + avg_rating = round(avg_rating, 2) + except (AttributeError, KeyError): + # Fall back to aggregation query + avg_rating = obj.ratings.aggregate(Avg('rating'))['rating__avg'] or 0 + avg_rating = round(avg_rating, 2) + + # Cache for 1 hour + cache.set(cache_key, avg_rating, 3600) + + return avg_rating + def get_variants(self, obj): view_type = self.context.get('view_type') if view_type == 'slider': @@ -292,3 +326,25 @@ class BotProductSerializer(serializers.ModelSerializer): 'pk', 'name' ] + + +class ProductRatingSerializer(serializers.ModelSerializer): + class Meta: + model = ProductRating + fields = ['rating'] + + def validate_rating(self, value): + if value not in [1, 2, 3, 4, 5]: + raise serializers.ValidationError("امتیاز باید بین 1 تا 5 باشد") + return value + + def create(self, validated_data): + product_id = self.context.get('product_id') + user = self.context.get('request').user + + rating = ProductRating.objects.create( + product_id=product_id, + user=user, + rating=validated_data['rating'] + ) + return rating diff --git a/backend/product/urls.py b/backend/product/urls.py index 5b0a275..2c9462f 100644 --- a/backend/product/urls.py +++ b/backend/product/urls.py @@ -1,5 +1,10 @@ from django.urls import path, re_path -from .views import AllCategories, ProductView, AllProductsView, CommentView, ShowCaseProductsView, ShowCaseCategoryListView, BotProductsView,BotProductDetailView,BotCategoryView ,AllCategoriesV2 +from .views import ( + AllCategories, ProductView, AllProductsView, CommentView, + ShowCaseProductsView, ShowCaseCategoryListView, BotProductsView, + BotProductDetailView, BotCategoryView, AllCategoriesV2, + ProductRatingView +) urlpatterns = [ path('slider_category', ShowCaseProductsView.as_view(), name='category-products'), @@ -10,6 +15,7 @@ urlpatterns = [ path('categories/bot', BotCategoryView.as_view(), name='bot-categories'), path('slider_categories', ShowCaseCategoryListView.as_view(), name='all-categories'), re_path(r'^comments/(?P[\w\u0600-\u06FF\-]+)$', CommentView.as_view(), name='comment-views'), + re_path(r'^(?P[\w\u0600-\u06FF\-]+)/rating/$', ProductRatingView.as_view(), name='product-rating'), re_path(r'^(?P[\w\u0600-\u06FF\-]+)/$', ProductView.as_view(), name='product-detail'), path('', AllProductsView.as_view(), name='category-products'), -] \ No newline at end of file +] diff --git a/backend/product/views.py b/backend/product/views.py index d4ea9b6..548bef6 100644 --- a/backend/product/views.py +++ b/backend/product/views.py @@ -134,6 +134,12 @@ class ProductView(APIView): permission_classes = [AllowAny] # authentication_classes = [] + @extend_schema( + responses={ + 200: DynamicProductSerializer(context={'view_type': 'instance'}), + 404: OpenApiTypes.OBJECT, + }, + ) def get(self, request, slug): # Optimize query with select_related and prefetch_related to avoid N+1 queries product = get_object_or_404( @@ -147,6 +153,7 @@ class ProductView(APIView): 'variants__details__detail_category', 'related_products__variants__product_attributes', 'related_products__category', + 'ratings', ), slug=slug ) @@ -662,3 +669,51 @@ class BotCategoryView(APIView): "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)