diff --git a/backend/account/models.py b/backend/account/models.py index 3863b82..baf3fdf 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -2,6 +2,9 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, Permis from django.db import models from django.utils.translation import gettext_lazy as _ import random +import logging + +logger = logging.getLogger(__name__) from datetime import datetime, timedelta from django.utils import timezone from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken @@ -113,7 +116,7 @@ class User(AbstractBaseUser, PermissionsMixin): for token in tokens: BlacklistedToken.objects.get_or_create(token=token) except Exception as e: - print(f"block list error: {e}") + logger.error(f"block list error: {e}") def __str__(self): @@ -200,7 +203,6 @@ class PushSubscription(models.Model): "icon": 'https://api.heymlz.com' + icon, "image": 'https://api.heymlz.com' + icon, } - print(payload) try: webpush( subscription_info={ @@ -214,7 +216,7 @@ class PushSubscription(models.Model): } ) except WebPushException as ex: - print("Failed to send notification:", ex) + logger.error(f"Failed to send notification: {ex}") @classmethod def send_group_notification(cls, user, title, body): @@ -240,7 +242,7 @@ class PushSubscription(models.Model): } ) except WebPushException as ex: - print(f"Failed to send notification to {sub.user}:", ex) + logger.error(f"Failed to send notification to {sub.user}: {ex}") @@ -277,10 +279,10 @@ def get_location_from_ip(ip_address): if data["status"] == "success": return data['country'], data['regionName'], data['city'], data.get('zip', 'ناموجود'), data['lat'], data['lon'], data['isp'] else: - print("Error fetching data: ", data["message"]) + logger.error(f"Error fetching data: {data['message']}") return None except Exception as e: - print(f"An error occurred: {e}") + logger.error(f"An error occurred: {e}") return None class SecurityBreachAttemptModel(models.Model): diff --git a/backend/account/urls.py b/backend/account/urls.py index 027e332..f4e59d2 100644 --- a/backend/account/urls.py +++ b/backend/account/urls.py @@ -7,7 +7,6 @@ urlpatterns = [ path('profile', views.ProfileView.as_view()), path('verify', views.TokenVerifyView.as_view(), name='jwt-verify'), path('send_otp', views.SendOTPView.as_view(), name='send-otp-view'), - path('yee_token_bedeeee', views.KonGhoshadToken.as_view()), path('address/create', views.CreateAddressView.as_view(), name='create-address'), path('address/edit/', views.EditAddressView.as_view(), name='edit-address'), path('address/delete/', views.DeleteAddressView.as_view(), name='delete-address'), diff --git a/backend/account/views.py b/backend/account/views.py index fec0639..aa05e32 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -71,10 +71,10 @@ class SendOTPView(APIView): if response['statusCode'] == 200: - return Response({'detail': f'OTP sent successfully {otp}'}, status=status.HTTP_200_OK) + return Response({'detail': f'OTP sent successfully'}, status=status.HTTP_200_OK) else: - print(response) - return Response({f'detail': f'مشکلی در ارسال کد رخ داد {otp}'}, status=status.HTTP_200_OK) + logger.error(f"OTP send failed with response: {response}") + return Response({f'detail': f'مشکلی در ارسال کد رخ داد '}, status=status.HTTP_200_OK) # return Response({'detail': response, 'otp_code': otp}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except User.DoesNotExist: diff --git a/backend/chat/models.py b/backend/chat/models.py index ebbf5e4..0820302 100644 --- a/backend/chat/models.py +++ b/backend/chat/models.py @@ -1,5 +1,8 @@ from django.db import models from account.models import User +import logging + +logger = logging.getLogger(__name__) from product.models import ProductModel from django.conf import settings import openai @@ -55,7 +58,7 @@ class ProductChatModel(models.Model): self.thread = thread.id except Exception as e: - print(f'error in chat class: {e}') + logger.error(f'error in chat class: {e}') raise ValueError(f"Error creating OpenAI thread: {e}") super().save(*args, **kwargs) \ No newline at end of file diff --git a/backend/core/settings/base.py b/backend/core/settings/base.py index 838c7ce..d6d3778 100644 --- a/backend/core/settings/base.py +++ b/backend/core/settings/base.py @@ -136,7 +136,7 @@ AUTH_PASSWORD_VALIDATORS = [ ] LANGUAGE_CODE = "fa" -TIME_ZONE = "UTC" +TIME_ZONE = "Asia/Tehran" USE_I18N = True USE_L10N = True USE_TZ = True diff --git a/backend/core/settings/development.py b/backend/core/settings/development.py index f253036..e3428dc 100644 --- a/backend/core/settings/development.py +++ b/backend/core/settings/development.py @@ -21,7 +21,7 @@ STATIC_ROOT = 'app/static' USE_X_FORWARDED_HOST = True SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') - +ALLOWED_HOSTS = ['127.0.0.1', 'localhost', DOMAIN, API_DOMAIN, '0.0.0.0', "185.110.189.208", 'www.google.com', 'google.com'] DATABASES = { 'default': { 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/core/views.py b/backend/core/views.py index 9894f4e..06c55ee 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1,7 +1,10 @@ import json import random +import logging from functools import lru_cache +logger = logging.getLogger(__name__) + from django.contrib.humanize.templatetags.humanize import intcomma from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -274,9 +277,7 @@ class FakeAdminLoginView(View): ip = x_forwarded_for.split(',')[0] else: ip = request.META.get("REMOTE_ADDR") - print(ip) - print(len(ip)) - print(type(ip)) + logger.info(f"Honeypot GET request from IP: {ip}, length: {len(ip)}, type: {type(ip).__name__}") hacker, created = SecurityBreachAttemptModel.objects.get_or_create(ip_address=ip) return render(request, 'admin/fake_login.html', self.get_context(request)) @@ -286,7 +287,7 @@ class FakeAdminLoginView(View): ip = x_forwarded_for.split(',')[0] else: ip = request.META.get("REMOTE_ADDR") - print(ip) + logger.warning(f"Honeypot POST request from IP: {ip}") hacker, created = SecurityBreachAttemptModel.objects.get_or_create(ip_address=ip) hacker.trys += 1 hacker.save() diff --git a/backend/order/admin.py b/backend/order/admin.py index 2977048..d4891cc 100644 --- a/backend/order/admin.py +++ b/backend/order/admin.py @@ -1,5 +1,8 @@ from django.contrib import admin, messages from .models import * +import logging + +logger = logging.getLogger(__name__) from unfold.admin import TabularInline, StackedInline from unfold.contrib.inlines.admin import NonrelatedTabularInline from django.db.models import Q @@ -35,8 +38,7 @@ class OrderItemAdmin(ModelAdmin): if not hasattr(request.user, 'shop'): return False - print(obj.product.product.shop) - print(request.user.shop) + logger.debug(f"Shop permissions check: obj.shop={obj.product.product.shop}, user.shop={request.user.shop}") return request.user.shop == obj.product.product.shop diff --git a/backend/order/invoice_generator.py b/backend/order/invoice_generator.py index 6934262..6128df0 100644 --- a/backend/order/invoice_generator.py +++ b/backend/order/invoice_generator.py @@ -55,6 +55,10 @@ def generate_order_invoice(order_id): qr_code_path = os.path.join(template_dir, 'qr-code.png') # Use stored model fields for accuracy and consistency + # Format the Jalali date for display + jalali_date = to_jalali(order.created_at) + created_at_jalali_str = jalali_date.strftime('%Y-%m-%d %H:%M') if jalali_date else '---' + context = { 'order': order, 'order_number': order.pk, @@ -63,7 +67,7 @@ def generate_order_invoice(order_id): 'user': order.user, 'address': order.address, 'discount_code': order.discount_code, - 'created_at_jalali': to_jalali(order.created_at), + 'created_at_jalali': created_at_jalali_str, 'total_items': sum(item.quantity for item in items), 'subtotal': order.cart_total or 0, # Stored field: total before any discounts 'items_discount_amount': total_items_discount, @@ -157,6 +161,10 @@ def generate_shop_order_invoice(shop_order_id): # Calculate subtotal (cart total before discounts) subtotal = shop_order.subtotal or 0 + # Format the Jalali date for display + jalali_date = jdatetime.datetime.fromgregorian(datetime=shop_order.order_created_at) if shop_order.order_created_at else None + created_at_jalali_str = jalali_date.strftime('%Y-%m-%d %H:%M') if jalali_date else '---' + # Resolve image paths for the template (absolute file paths for WeasyPrint) import os template_dir = os.path.join(settings.BASE_DIR, 'templates', 'order') @@ -181,7 +189,7 @@ def generate_shop_order_invoice(shop_order_id): 'address_recipient_name': shop_order.address_recipient_name, 'items': items, 'items_with_discount': items_with_discount, - 'created_at_jalali': jdatetime.datetime.fromgregorian(datetime=shop_order.order_created_at) if shop_order.order_created_at else None, + 'created_at_jalali': created_at_jalali_str, 'total_items': shop_order.items_count, 'subtotal': subtotal, # Total after product discount 'items_discount_amount': total_items_discount, # Product discount amount diff --git a/backend/order/migrations/0044_alter_ordermodel_created_at.py b/backend/order/migrations/0044_alter_ordermodel_created_at.py new file mode 100644 index 0000000..3d68d97 --- /dev/null +++ b/backend/order/migrations/0044_alter_ordermodel_created_at.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2 on 2026-05-12 05:46 + +import django.utils.timezone +import django_jalali.db.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0043_shopdailyreport_is_settled_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='ordermodel', + name='created_at', + field=django_jalali.db.models.jDateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='تاریخ ثبت سفارش'), + preserve_default=False, + ), + ] diff --git a/backend/order/migrations/0045_alter_ordermodel_created_at.py b/backend/order/migrations/0045_alter_ordermodel_created_at.py new file mode 100644 index 0000000..eefcb1d --- /dev/null +++ b/backend/order/migrations/0045_alter_ordermodel_created_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2026-05-12 05:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0044_alter_ordermodel_created_at'), + ] + + operations = [ + migrations.AlterField( + model_name='ordermodel', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ثبت سفارش'), + ), + ] diff --git a/backend/order/models.py b/backend/order/models.py index 6aaa63c..6a45879 100644 --- a/backend/order/models.py +++ b/backend/order/models.py @@ -1,5 +1,8 @@ from account.models import SpecialDiscountCode from django.db import models, transaction +import logging + +logger = logging.getLogger(__name__) from account.models import User, UserAddressModel, PushSubscription from product.models import ProductModel, ProductVariant, ProductImageModel from django.utils import timezone @@ -33,7 +36,7 @@ class DiscountCode(models.Model): elif not self.quantity > 0: return 'این کد تخفیف تمام شده است' else: - print('log later bug') + logger.warning('Discount code validity check failed') class Cart(models.Model): @@ -168,8 +171,8 @@ class OrderModel(models.Model): null=True, related_name='orders', verbose_name='کاربر') address = models.ForeignKey(UserAddressModel, on_delete=models.SET_NULL, related_name='orders', null=True, verbose_name='ادرس') - created_at = jmodels.jDateField( - blank=True, null=True, verbose_name="تاریخ ثبت سفارش") + created_at = models.DateTimeField( + auto_now_add=True, verbose_name="تاریخ ثبت سفارش") is_paid = models.BooleanField(default=False, verbose_name="وضعیت پرداخت") discount_code = models.ForeignKey( DiscountCode, on_delete=models.PROTECT, null=True, blank=True, verbose_name="کدتخفیف") @@ -227,9 +230,7 @@ class OrderModel(models.Model): return True except Exception as e: - print(e) - # Log the error if you have logging setup - # logger.error(f"Failed to rollback stock for order {self.pk}: {e}") + logger.error(f"Failed to rollback stock for order {self.pk}: {e}") return False @@ -264,7 +265,7 @@ class OrderItemModel(models.Model): # @property def price_after_special_discount(self): all_discounts = (self.special_discount_amount or 0) + self.total_product_discount_amount() - print(all_discounts) + logger.debug(f"Total discounts calculated: {all_discounts}") return self.total_price_before_discount() - all_discounts def unit_price(self): diff --git a/backend/order/tasks.py b/backend/order/tasks.py index a19edab..cd23bd3 100644 --- a/backend/order/tasks.py +++ b/backend/order/tasks.py @@ -40,8 +40,8 @@ def send_change_status_notif(instance_pk, new_status): for user_sub in user_subs: try: user_sub.send_notif(f'سفارش شما به {new_status} تغییر کرد', f'سفارش شما به {new_status} تغییر کرد', ProductImageModel.objects.all().first().image.url) - except: - print('log later send notif error') + except Exception as e: + logger.error('Error sending status notification: ' + str(e)) @shared_task def send_change_status_sms(instance_pk, new_status): @@ -77,7 +77,7 @@ def generate_daily_shop_reports(): from .models import ShopOrderModel, ShopDailyReport target_date = (timezone.now() - timedelta(days=1)).date() - print(f'Generating shop reports for {target_date}') + logging.info(f'Generating shop reports for {target_date}') shop_orders = ShopOrderModel.objects.filter(created_at__date=target_date) if not shop_orders.exists(): diff --git a/backend/order/views.py b/backend/order/views.py index f33681c..85a8255 100644 --- a/backend/order/views.py +++ b/backend/order/views.py @@ -390,13 +390,13 @@ class PaymentView(APIView): }) except AZBankGatewaysException as e: - print(f"Payment gateway error: {e}") + logger.error(f"Payment gateway error: {e}") return Response({ 'error': 'خطا در اتصال به درگاه پرداخت' }, status=status.HTTP_400_BAD_REQUEST) except Exception as e: - print(f"Order creation error: {e}") + logger.error(f"Order creation error: {e}") return Response({ 'error': 'خطا در ثبت سفارش' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/backend/product/admin.py b/backend/product/admin.py index 00abca0..d0db23f 100644 --- a/backend/product/admin.py +++ b/backend/product/admin.py @@ -1,5 +1,8 @@ from django.contrib import admin, messages from django import forms +import logging + +logger = logging.getLogger(__name__) # from product.tasks import update_prices from .models import * from unfold.admin import TabularInline, StackedInline @@ -263,13 +266,13 @@ class ProductDetailModel1Admin(ModelAdmin, ImportExportModelAdmin): def get_queryset(self, request): if request.user.is_superuser: - print('here') + logger.info('Returning all ProductDetailModels for superuser') return ProductDetailModel.objects.all() if not hasattr(request.user, 'shop'): - print(' in here 2') + logger.info('User has no shop, returning empty queryset') return ProductDetailModel.objects.none() - print('in here 3') + logger.info('Filtering ProductDetailModels by shop') queryset = ProductDetailModel.objects.filter(product__product__shop__id=request.user.shop.id) return queryset @@ -543,6 +546,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 +575,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..27f242a 100644 --- a/backend/product/models.py +++ b/backend/product/models.py @@ -1,5 +1,8 @@ from django.db import models from django.utils.text import slugify +import logging + +logger = logging.getLogger(__name__) from account.models import User from django.urls import reverse import requests @@ -148,7 +151,7 @@ class DollorModel(models.Model): data = response.json() price = int(data["lastTradePrice"]) price_in_usd = price / 10.0 - print('\n\nprice from api \n\n') + logger.info('Price fetched from API') except Exception as e: return self.defualt_price @@ -493,3 +496,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..d1e5d65 100644 --- a/backend/product/serializers.py +++ b/backend/product/serializers.py @@ -158,7 +158,8 @@ class DynamicProductSerializer(serializers.ModelSerializer): main_image = serializers.SerializerMethodField() customer_pickup_title = serializers.SerializerMethodField() customer_pickup_description = serializers.SerializerMethodField() - + average_rating = serializers.SerializerMethodField() + user_rating = serializers.SerializerMethodField() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) view_type = self.context.get('view_type', 'all') @@ -174,12 +175,23 @@ 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', 'user_rating'], 'chat': ['id', 'name', 'description', 'variants', 'image'] } + + def get_user_rating(self, obj): + request = self.context.get('request') + if not request.user.is_authenticated: + return None + product_ratings = obj.ratings.all() + if product_ratings.filter(user=request.user).exists(): + return product_ratings.filter(user=request.user).first().rating + else: + return None + def _get_best_deal_variant(self, obj): """Get best deal variant from prefetched variants (pre-ordered by discount/price)""" if not hasattr(self, '_best_deal_cache'): @@ -241,6 +253,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 +337,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..e40327a 100644 --- a/backend/product/views.py +++ b/backend/product/views.py @@ -134,7 +134,15 @@ 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): + 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( @@ -147,10 +155,15 @@ class ProductView(APIView): '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 @@ -662,3 +675,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) diff --git a/backend/templates/order/invoice_order2.html b/backend/templates/order/invoice_order2.html index 913dc50..1f4a8f5 100644 --- a/backend/templates/order/invoice_order2.html +++ b/backend/templates/order/invoice_order2.html @@ -124,14 +124,17 @@ .info-flex { display: flex; flex-wrap: wrap; - gap: 4px 12px; + gap: 6px 12px; + align-items: flex-start; } .info-item { display: flex; gap: 3px; - white-space: nowrap; font-size: 9px; + min-width: 0; + flex-wrap: wrap; + word-break: break-word; } .info-item span:first-child { @@ -310,22 +313,22 @@ فرستنده :فروشگاه هی ملز
- شناسه ملی :--- + شناسه ملی :14014423086
- شماره ثبت :--- + شماره ثبت :64852
+ {% comment %}
+ شماره مجوز :- +
{% endcomment %}
- شماره مجوز :--- + نشانی شرکت :شیراز بلوار گلستان بلوار سما خیابان طهماسبی پلاک ۱۹۸
-
- نشانی شرکت :--- +
+ کد پستی :7145746584
-
- کد پستی :--- -
-
- تلفن و فکس :--- +
+ تلفن و فکس :02193111026
@@ -492,6 +495,10 @@ diff --git a/backend/templates/order/invoice_shop_order2.html b/backend/templates/order/invoice_shop_order2.html index acbe388..b934787 100644 --- a/backend/templates/order/invoice_shop_order2.html +++ b/backend/templates/order/invoice_shop_order2.html @@ -371,25 +371,22 @@
- فرستنده :{{ shop.shop_name }} + فرستنده :فروشگاه هی ملز
- شناسه ملی :--- + شناسه ملی :14014423086
- شماره ثبت :--- + شماره ثبت :64852
- شماره مجوز :--- + نشانی شرکت :شیراز بلوار گلستان بلوار سما خیابان طهماسبی پلاک ۱۹۸
-
- نشانی شرکت :--- +
+ کد پستی :7145746584
-
- کد پستی :--- -
-
- تلفن و فکس :--- +
+ تلفن و فکس :02193111026
diff --git a/frontend/components/cart/global/CartSummary.vue b/frontend/components/cart/global/CartSummary.vue index a6d3ec0..09cc74f 100644 --- a/frontend/components/cart/global/CartSummary.vue +++ b/frontend/components/cart/global/CartSummary.vue @@ -324,8 +324,8 @@ watch( :to="{ name: nextPage?.name, query: { gw: nextPage?.query } }" >
-
+
!!cart.value && cart.value.items.length! > 0) class="flex items-center gap-2 text-sm lg:text-[1rem] font-medium" > @@ -49,7 +49,7 @@ const hasCartItem = computed(() => !!cart.value && cart.value.items.length! > 0)
-

+

{{ pageTitle }}

diff --git a/frontend/pages/cart/index.vue b/frontend/pages/cart/index.vue index d236761..2bc6b60 100644 --- a/frontend/pages/cart/index.vue +++ b/frontend/pages/cart/index.vue @@ -29,7 +29,7 @@ const hasCartItem = computed(() => !!cart.value && cart.value.items.length! > 0)