This commit is contained in:
marzban-dev
2026-05-15 13:43:53 +03:30
30 changed files with 432 additions and 123 deletions
+8 -6
View File
@@ -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):
-1
View File
@@ -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/<int:pk>', views.EditAddressView.as_view(), name='edit-address'),
path('address/delete/<int:pk>', views.DeleteAddressView.as_view(), name='delete-address'),
+3 -3
View File
@@ -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:
+4 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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': {
+12
View File
@@ -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,
},
],
},
+5 -4
View File
@@ -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()
+4 -2
View File
@@ -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
+10 -2
View File
@@ -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
@@ -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,
),
]
@@ -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='تاریخ ثبت سفارش'),
),
]
+8 -7
View File
@@ -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):
+3 -3
View File
@@ -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():
+2 -2
View File
@@ -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)
+44 -4
View File
@@ -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',)
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')},
},
),
]
+38 -1
View File
@@ -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}⭐)"
+71 -4
View File
@@ -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
+8 -2
View File
@@ -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<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'),
path('', AllProductsView.as_view(), name='category-products'),
]
]
+61
View File
@@ -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/<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)
+19 -12
View File
@@ -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 @@
<span>فرستنده :</span><span>فروشگاه هی ملز</span>
</div>
<div class="info-item">
<span>شناسه ملی :</span><span>---</span>
<span>شناسه ملی :</span><span>14014423086</span>
</div>
<div class="info-item">
<span>شماره ثبت :</span><span>---</span>
<span>شماره ثبت :</span><span>64852</span>
</div>
{% comment %} <div class="info-item">
<span>شماره مجوز :</span><span>-</span>
</div> {% endcomment %}
<div class="info-item">
<span>شماره مجوز :</span><span>---</span>
<span>نشانی شرکت :</span><span>شیراز بلوار گلستان بلوار سما خیابان طهماسبی پلاک ۱۹۸</span>
</div>
<div class="info-item">
<span>نشانی شرکت :</span><span>---</span>
<div class="info-item" style="flex-basis: 100%;">
<span>کد پستی :</span><span>7145746584</span>
</div>
<div class="info-item">
<span>کد پستی :</span><span>---</span>
</div>
<div class="info-item">
<span>تلفن و فکس :</span><span>---</span>
<div class="info-item" style="flex-basis: 100%;">
<span>تلفن و فکس :</span><span>02193111026</span>
</div>
</div>
</div>
@@ -492,6 +495,10 @@
<div class="footer">
<p>با تشکر از خرید شما</p>
<p>این فاکتور به صورت الکترونیکی صادر شده و نیازی به امضا و مهر ندارد</p>
<p>«هرگونه عودت یا ارسال کالا، صرفاً پس از هماهنگی با واحد پشتیبانی شرکت امکان‌پذیر است.»</p>
<p>«مشتری موظف است پیش از هرگونه مرجوعی یا ارسال، با پشتیبانی شرکت هماهنگی لازم را انجام دهد.»</p>
<p>«عودت، تعویض یا ارسال کالا بدون هماهنگی قبلی با شرکت مورد پذیرش نخواهد بود.</p>
</div>
</div>
</body>
@@ -371,25 +371,22 @@
<div class="info-panel">
<div class="info-flex">
<div class="info-item">
<span>فرستنده :</span><span>{{ shop.shop_name }}</span>
<span>فرستنده :</span><span>فروشگاه هی ملز</span>
</div>
<div class="info-item">
<span>شناسه ملی :</span><span>---</span>
<span>شناسه ملی :</span><span>14014423086</span>
</div>
<div class="info-item">
<span>شماره ثبت :</span><span>---</span>
<span>شماره ثبت :</span><span>64852</span>
</div>
<div class="info-item">
<span>شماره مجوز :</span><span>---</span>
<span>نشانی شرکت :</span><span>شیراز بلوار گلستان بلوار سما خیابان طهماسبی پلاک ۱۹۸</span>
</div>
<div class="info-item">
<span>نشانی شرکت :</span><span>---</span>
<div class="info-item" style="flex-basis: 100%;">
<span>کد پستی :</span><span>7145746584</span>
</div>
<div class="info-item">
<span>کد پستی :</span><span>---</span>
</div>
<div class="info-item">
<span>تلفن و فکس :</span><span>---</span>
<div class="info-item" style="flex-basis: 100%;">
<span>تلفن و فکس :</span><span>02193111026</span>
</div>
</div>
</div>
@@ -324,8 +324,8 @@ watch(
:to="{ name: nextPage?.name, query: { gw: nextPage?.query } }"
>
<Button
start-icon="arrow-right"
class="w-full rounded-100"
start-icon="ci:arrow-right"
class="w-full rounded-100 py-2 lg:typo-p-sm"
variant="primary"
>
{{ nextPage?.label }}
+13 -13
View File
@@ -59,7 +59,7 @@ const handleDeleteFromCart = () => {
},
});
},
}
},
);
};
@@ -106,9 +106,9 @@ watch(
},
});
},
}
},
);
}
},
);
</script>
@@ -117,8 +117,12 @@ watch(
class="flex flex-col items-center w-full gap-4 p-4 border lg:flex-row border-slate-200 rounded-xl bg-slate-50 overflow-hidden relative"
>
<div class="flex items-start justify-start w-full gap-2.5 lg:gap-4">
<Skeleton
v-if="cartImageIsLoading"
class="size-[4rem] lg:!size-[12rem] aspect-square shrink-0 !rounded-xl border border-slate-200"
/>
<div
v-if="!cartImageIsLoading"
v-else
class="size-[4rem] lg:size-[12rem] aspect-square shrink-0 rounded-xl border border-slate-200 overflow-hidden"
>
<NuxtImg
@@ -129,10 +133,6 @@ watch(
alt="product"
/>
</div>
<Skeleton
v-else
class="!size-[12rem] aspect-square shrink-0 !rounded-xl border border-slate-200"
/>
<div class="flex flex-col w-full gap-3 lg:gap-4">
<div class="flex items-center justify-between gap-3">
@@ -142,7 +142,7 @@ watch(
<div class="w-max flex-center gap-2">
<div
v-if="data.discount > 0"
class="text-white bg-blue-500 px-3 lg:px-4 py-1.5 lg:py-2 text-[10px] lg:text-xs rounded-full flex items-center gap-1"
class="text-white whitespace-pre bg-blue-500 px-3 lg:px-4 py-1.5 lg:py-2 text-[8px] lg:text-xs rounded-full flex items-center gap-1"
>
{{ data.discount_amount }}
تخفیف
@@ -164,7 +164,7 @@ watch(
{{ data.product.title }}
</NuxtLink>
<div class="flex items-center justify-start gap-1.5">
<div class="flex items-center justify-start flex-wrap gap-1.5">
<div
v-if="!!data.product.color"
class="px-3 py-1 rounded-full border border-slate-200 text-xs lg:text-sm flex-center gap-1.5"
@@ -182,7 +182,7 @@ watch(
v-if="data.product.product_attributes.length > 0"
v-for="(variant, index) in data.product.product_attributes"
:index="index"
class="px-3 py-1 rounded-full border border-slate-200 text-xs lg:text-sm"
class="px-3 py-1 whitespace-pre rounded-full border border-slate-200 text-xs lg:text-sm"
>
{{ variant.value }}
</span>
@@ -305,7 +305,7 @@ watch(
<div class="flex flex-col items-end">
<span
v-if="data.discount > 0"
class="typo-p-xs relative flex-center w-fit line-through text-slate-400"
class="text-[10px] lg:typo-p-xs relative flex-center w-fit line-through text-slate-400"
>
{{ data.price }}
</span>
@@ -315,7 +315,7 @@ watch(
>
تخفیف ویژه: {{ data.special_discount_amount }}
</span>
<span class="typo-p-md relative flex-center w-fit font-medium">
<span class="typo-p-sm lg:typo-p-md relative flex-center w-fit font-medium">
{{ data.final_price }}
</span>
</div>
@@ -54,8 +54,7 @@ const handleSubmit = () => {
>
<template #trigger>
<Button
class="rounded-full shrink-0 whitespace-pre"
end-icon="ci:bi-trash"
class="rounded-full shrink-0 whitespace-pre max-lg:text-[10px]! max-lg:py-1"
size="md"
>
حذف همه
+28 -34
View File
@@ -50,50 +50,44 @@ const isShow = computed({
class="bg-black/50 backdrop-blur-sm data-[state=open]:animate-overlay-show fixed inset-0 z-999"
/>
<div
class="fixed inset-0 w-full h-svh z-9999 flex-center"
v-if="isShow"
<DialogContent
class="fixed inset-0 z-9999 flex items-start justify-center overflow-y-auto overscroll-contain data-[state=open]:animate-content-show focus:outline-none"
style="touch-action: pan-y"
>
<div
:class="contectClass"
class="overflow-y-auto max-h-svh absolute left-[50%] py-10 w-fit max-w-[50rem] translate-x-[-50%]"
class="relative w-fit max-w-[50rem] my-auto py-10"
>
<DialogContent
class="data-[state=open]:animate-content-show text-black font-iran-yekan-x focus:outline-none z-[100]"
<div
class="rounded-2xl bg-white text-black font-iran-yekan-x shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px]"
>
<div
class="rounded-2xl bg-white shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px]"
class="w-full flex items-center px-6 justify-between py-[1.5rem] border-b border-slate-200"
>
<div
class="w-full flex items-center px-6 justify-between py-[1.5rem] border-b border-slate-200"
<DialogClose
class="inline-flex size-8 items-center justify-center transition-all rounded-full bg-gray-50 border border-slate-200 hover:border-black focus:outline-none"
aria-label="Close"
>
<DialogClose
class="inline-flex size-8 items-center justify-center transition-all rounded-full bg-gray-50 border border-slate-200 hover:border-black focus:outline-none"
aria-label="Close"
>
<Icon
name="ci:bi-x-lg"
class="**:fill-black"
/>
</DialogClose>
<DialogTitle
class="typo-sub-h-md lg:typo-sub-h-xl font-semibold flex items-center gap-3"
>
{{ title }}
<Icon
v-if="!!icon"
:name="icon"
:size="iconSize"
/>
</DialogTitle>
</div>
<div class="w-full px-6">
<slot name="content" />
</div>
<Icon
name="ci:bi-x-lg"
class="**:fill-black"
/>
</DialogClose>
<DialogTitle class="typo-sub-h-md lg:typo-sub-h-xl font-semibold flex items-center gap-3">
{{ title }}
<Icon
v-if="!!icon"
:name="icon"
:size="iconSize"
/>
</DialogTitle>
</div>
</DialogContent>
<div class="w-full px-6">
<slot name="content" />
</div>
</div>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -124,7 +124,7 @@ const resetAvatarFile = async () => {
</button>
</div>
<div class="w-full flex-center gap-4 max-w-[500px] flex-wrap">
<!-- <div class="w-full flex-center gap-4 max-w-[500px] flex-wrap">
<button
class="size-6 lg:size-8 rounded-full bg-orange-100 whitespace-nowrap ring-2 hover:ring-black ring-slate-200 ring-offset-3"
/>
@@ -140,7 +140,7 @@ const resetAvatarFile = async () => {
<button
class="size-6 lg:size-8 rounded-full bg-amber-800 whitespace-nowrap ring-2 hover:ring-black ring-slate-200 ring-offset-3"
/>
</div>
</div> -->
<div class="w-full flex-col-center gap-5">
<Avatar
+2 -2
View File
@@ -40,7 +40,7 @@ const hasCartItem = computed(() => !!cart.value && cart.value.items.length! > 0)
class="flex items-center gap-2 text-sm lg:text-[1rem] font-medium"
>
<Icon
name="ci:bi-arrow-right"
name="ci:arrow-right"
class="**:stroke-blue-500"
/>
<span class="text-blue-500">
@@ -49,7 +49,7 @@ const hasCartItem = computed(() => !!cart.value && cart.value.items.length! > 0)
</NuxtLink>
</div>
<h1 class="w-full text-center lg:w-6/12 typo-h-5 lg:typo-h-4">
<h1 v-if="pageTitle" class="w-full text-center lg:w-6/12 typo-h-5 lg:typo-h-4">
{{ pageTitle }}
</h1>
+1 -1
View File
@@ -29,7 +29,7 @@ const hasCartItem = computed(() => !!cart.value && cart.value.items.length! > 0)
<div class="w-full flex flex-col gap-4 lg:gap-6">
<div
v-if="hasCartItem"
class="flex items-center justify-between w-full gap-3 px-4 py-4 rounded-xl bg-slate-50 border border-slate-200"
class="flex items-center justify-between w-full gap-3 px-4 py-2 lg:py-4 rounded-xl bg-slate-50 border border-slate-200"
>
<Skeleton
v-if="cartIsLoading"