rateing system
This commit is contained in:
@@ -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,
|
||||||
|
},
|
||||||
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -559,3 +573,26 @@ class DollorAdmin(ModelAdmin, ImportExportModelAdmin):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}⭐)"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user