rateing system

This commit is contained in:
Parsa Nazer
2026-05-12 08:53:12 +03:30
parent 9041366720
commit e678829a86
7 changed files with 240 additions and 6 deletions
+12
View File
@@ -171,6 +171,18 @@ UNFOLD = {
"link": reverse_lazy("admin:order_discountcode_changelist"), "link": reverse_lazy("admin:order_discountcode_changelist"),
"permission": lambda request: request.user.is_superuser, "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,
},
], ],
}, },
+38 -1
View File
@@ -543,6 +543,20 @@ class CommentAdmin(ModelAdmin, ImportExportModelAdmin):
return obj.content[0:35] + '...' return obj.content[0:35] + '...'
display_content.short_description = 'محتوای کامنت' 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) @admin.register(DollorModel)
class DollorAdmin(ModelAdmin, ImportExportModelAdmin): class DollorAdmin(ModelAdmin, ImportExportModelAdmin):
import_form_class = ImportForm import_form_class = ImportForm
@@ -558,4 +572,27 @@ class DollorAdmin(ModelAdmin, ImportExportModelAdmin):
"widget": ArrayWidget, "widget": ArrayWidget,
} }
} }
readonly_fields = ('price',) 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
@@ -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')},
},
),
]
+34
View File
@@ -493,3 +493,37 @@ class ProductVariant(DirtyFieldsMixin, models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.set_or_update_price() self.set_or_update_price()
super().save(*args, **kwargs) 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}⭐)"
+59 -3
View File
@@ -158,6 +158,7 @@ class DynamicProductSerializer(serializers.ModelSerializer):
main_image = serializers.SerializerMethodField() main_image = serializers.SerializerMethodField()
customer_pickup_title = serializers.SerializerMethodField() customer_pickup_title = serializers.SerializerMethodField()
customer_pickup_description = serializers.SerializerMethodField() customer_pickup_description = serializers.SerializerMethodField()
average_rating = serializers.SerializerMethodField()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -174,9 +175,9 @@ class DynamicProductSerializer(serializers.ModelSerializer):
model = ProductModel model = ProductModel
fields = "__all__" fields = "__all__"
view_type = { 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'], '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', ], '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'], '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'] 'chat': ['id', 'name', 'description', 'variants', 'image']
} }
@@ -241,6 +242,39 @@ class DynamicProductSerializer(serializers.ModelSerializer):
# Use exists() with filter instead of fetching all products # Use exists() with filter instead of fetching all products
return UserFavorites.objects.filter(user=request.user, products=obj).exists() 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): def get_variants(self, obj):
view_type = self.context.get('view_type') view_type = self.context.get('view_type')
if view_type == 'slider': if view_type == 'slider':
@@ -292,3 +326,25 @@ class BotProductSerializer(serializers.ModelSerializer):
'pk', 'pk',
'name' '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
+8 -2
View File
@@ -1,5 +1,10 @@
from django.urls import path, re_path 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 = [ urlpatterns = [
path('slider_category', ShowCaseProductsView.as_view(), name='category-products'), path('slider_category', ShowCaseProductsView.as_view(), name='category-products'),
@@ -10,6 +15,7 @@ urlpatterns = [
path('categories/bot', BotCategoryView.as_view(), name='bot-categories'), path('categories/bot', BotCategoryView.as_view(), name='bot-categories'),
path('slider_categories', ShowCaseCategoryListView.as_view(), name='all-categories'), path('slider_categories', ShowCaseCategoryListView.as_view(), name='all-categories'),
re_path(r'^comments/(?P<slug>[\w\u0600-\u06FF\-]+)$', CommentView.as_view(), name='comment-views'), re_path(r'^comments/(?P<slug>[\w\u0600-\u06FF\-]+)$', CommentView.as_view(), name='comment-views'),
re_path(r'^(?P<slug>[\w\u0600-\u06FF\-]+)/rating/$', ProductRatingView.as_view(), name='product-rating'),
re_path(r'^(?P<slug>[\w\u0600-\u06FF\-]+)/$', ProductView.as_view(), name='product-detail'), re_path(r'^(?P<slug>[\w\u0600-\u06FF\-]+)/$', ProductView.as_view(), name='product-detail'),
path('', AllProductsView.as_view(), name='category-products'), path('', AllProductsView.as_view(), name='category-products'),
] ]
+55
View File
@@ -134,6 +134,12 @@ class ProductView(APIView):
permission_classes = [AllowAny] permission_classes = [AllowAny]
# authentication_classes = [] # authentication_classes = []
@extend_schema(
responses={
200: DynamicProductSerializer(context={'view_type': 'instance'}),
404: OpenApiTypes.OBJECT,
},
)
def get(self, request, slug): def get(self, request, slug):
# Optimize query with select_related and prefetch_related to avoid N+1 queries # Optimize query with select_related and prefetch_related to avoid N+1 queries
product = get_object_or_404( product = get_object_or_404(
@@ -147,6 +153,7 @@ class ProductView(APIView):
'variants__details__detail_category', 'variants__details__detail_category',
'related_products__variants__product_attributes', 'related_products__variants__product_attributes',
'related_products__category', 'related_products__category',
'ratings',
), ),
slug=slug slug=slug
) )
@@ -662,3 +669,51 @@ class BotCategoryView(APIView):
"success": False, "success": False,
"categories": [] "categories": []
}) })
class ProductRatingView(APIView):
"""
API endpoint to submit/update a product rating
POST: /api/products/<slug>/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)