diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 6c287c1..255860e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -31,5 +31,5 @@ jobs: script: | cd /root/hshop/ docker compose down - docker compose build + docker compose build --no-cache docker compose up -d \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/backend/account/views.py b/backend/account/views.py index 77c067f..64d6fac 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -61,7 +61,7 @@ Code: {otp}""" # response = sms_api.send_otp_sms(otp_input) if response['statusCode'] == 200: - return Response({'detail': 'OTP sent successfully'}, status=status.HTTP_200_OK) + return Response({'detail': f'OTP sent successfully {otp}'}, status=status.HTTP_200_OK) else: print(response) return Response({'detail': f'مشکلی در ارسال کد رخ داد'}, status=status.HTTP_200_OK) diff --git a/backend/blog/serializers.py b/backend/blog/serializers.py index 5027ecd..3c2570f 100644 --- a/backend/blog/serializers.py +++ b/backend/blog/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from .models import BlogModel from account.models import User +from product.serializers import SubCategorySerializer class AuthorSerializer(serializers.ModelSerializer): full_name = serializers.SerializerMethodField() @@ -14,6 +15,7 @@ class AuthorSerializer(serializers.ModelSerializer): return 'ادمین وبسایت' class BlogSerilizer(serializers.ModelSerializer): + category = SubCategorySerializer() author = AuthorSerializer() class Meta: model = BlogModel @@ -22,6 +24,7 @@ class BlogSerilizer(serializers.ModelSerializer): class AllBlogSerilizer(serializers.ModelSerializer): author = AuthorSerializer() + category = SubCategorySerializer() class Meta: model = BlogModel - exclude = ('is_published', 'content', 'summery', ) \ No newline at end of file + exclude = ('is_published', 'content',) \ No newline at end of file diff --git a/backend/core/settings/base.py b/backend/core/settings/base.py index ee792e4..38808a3 100644 --- a/backend/core/settings/base.py +++ b/backend/core/settings/base.py @@ -30,7 +30,7 @@ EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") DEFAULT_FROM_EMAIL = os.getenv("SECRET_KEY") # Security and Debugging -SECRET_KEY = os.getenv("SECRET_KEY") +SECRET_KEY = os.getenv("SECRET_KEY") or 'this is just for cron job dont judge me' DEBUG = True BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -65,6 +65,7 @@ INSTALLED_APPS = [ "rest_framework.authtoken", "import_export", "django_jalali", + 'django_crontab', # Custom Apps "product", "account", @@ -233,3 +234,12 @@ AWS_S3_OBJECT_PARAMETERS = { 'ACL': 'public-read', } +# ============================================================================== +# django CRONJOBS +# ============================================================================== + +CRONJOBS = [ + ('* * * * *', 'product.cron.update_product_prices', f'>> {BASE_DIR}/logfile.log 2>&1'), +] + + diff --git a/backend/dockerfile b/backend/dockerfile index 16ceef1..09c155a 100644 --- a/backend/dockerfile +++ b/backend/dockerfile @@ -13,5 +13,6 @@ COPY . /app/ CMD ["sh", "-c", "python manage.py makemigrations && \ python manage.py migrate && \ + python manage.py crontab add && \ python manage.py collectstatic --no-input && \ gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3"] \ No newline at end of file diff --git a/backend/order/migrations/0011_orderitemmodel_price.py b/backend/order/migrations/0011_orderitemmodel_price.py new file mode 100644 index 0000000..ceaf65f --- /dev/null +++ b/backend/order/migrations/0011_orderitemmodel_price.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.2 on 2025-03-08 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0010_alter_orderitemmodel_quantity'), + ] + + operations = [ + migrations.AddField( + model_name='orderitemmodel', + name='price', + field=models.PositiveIntegerField(default=0, verbose_name='قیمت'), + preserve_default=False, + ), + ] diff --git a/backend/order/models.py b/backend/order/models.py index c400e1c..1251999 100644 --- a/backend/order/models.py +++ b/backend/order/models.py @@ -97,13 +97,14 @@ class OrderModel(models.Model): class OrderItemModel(models.Model): order = models.ForeignKey(OrderModel, on_delete=models.CASCADE, related_name='items', verbose_name='سفارش') quantity = models.PositiveSmallIntegerField(verbose_name="تعداد") + price = models.PositiveIntegerField(verbose_name='قیمت') product = models.ForeignKey(ProductVariant, on_delete=models.PROTECT, verbose_name="محصول") class Meta: verbose_name = 'ایتم سبد خرید' verbose_name_plural = 'ایتم های سبد خرید' def total(self): - return self.quantity * self.product.get_toman_price() + return self.quantity * self.product.price def total_with_discount(self): return self.quantity * self.product.get_toman_price_after_discount() diff --git a/backend/order/serializers.py b/backend/order/serializers.py index 37f5cca..f796e5b 100644 --- a/backend/order/serializers.py +++ b/backend/order/serializers.py @@ -1,12 +1,15 @@ from rest_framework import serializers from .models import OrderItemModel, OrderModel - +from product.serializers import ProductVariantSerialzier class OrderItemSerailzier(serializers.ModelSerializer): + product = serializers.SerializerMethodField() class Meta: model = OrderItemModel fields = "__all__" read_only_fields = ('order', 'product') + def get_product(self, obj): + return ProductVariantSerialzier(instance=obj.product, context={'request': self.context.get('request')}).data class CartSerializer(serializers.ModelSerializer): items = OrderItemSerailzier(many=True) diff --git a/backend/product/admin.py b/backend/product/admin.py index 11359e1..a7d6e19 100644 --- a/backend/product/admin.py +++ b/backend/product/admin.py @@ -9,7 +9,7 @@ from django.contrib.postgres.fields import ArrayField from unfold.widgets import UnfoldAdminColorInputWidget from unfold.decorators import action, display from utils.admin import ModelAdmin - +from django.shortcuts import redirect @@ -140,8 +140,10 @@ class ProductVariantInLine(StackedInline): show_change_link = True tab = True min_num = 1 + readonly_fields = ['price'] # inlines = [DetailModelInLine] autocomplete_fields = ['product_attributes', 'in_pack_items', 'images', 'details'] + fields = ['images', 'video','input_price', 'min_price', 'currency', 'price', 'discount','in_stock', 'color', 'product_attributes', 'in_pack_items', 'details','sell'] # search_fields = [''] @@ -156,6 +158,7 @@ class ProductVariantAdmin(ModelAdmin, ImportExportModelAdmin): export_form_class = ExportForm autocomplete_fields = ['product_attributes', 'images', 'in_pack_items', 'details'] warn_unsaved_form = True + readonly_fields = ['price'] # inlines = [DetailModelInLine] @admin.register(ProductModel) @@ -169,6 +172,7 @@ class ProductModelAdmin(ModelAdmin, ImportExportModelAdmin): autocomplete_fields = ['related_products', ] # compressed_fields = True warn_unsaved_form = True + actions_list = ['redirect_to_learn', 'update_products_price'] list_display = ['display_image', 'display_price', 'view', 'show', 'rating', 'category', ] fieldsets = ( ('فیلد های اصلی', {'fields': ('name', 'description', 'category', 'related_products', 'show',), "classes": ["tab"],}), @@ -188,7 +192,7 @@ class ProductModelAdmin(ModelAdmin, ImportExportModelAdmin): def display_price(self, obj): if obj.variants.all().first(): - return obj.variants.all().first().get_toman_price() + return obj.variants.all().first().price display_price.short_description = 'قیمت تومانی' @display(description='محصول', header=True) @@ -209,6 +213,15 @@ class ProductModelAdmin(ModelAdmin, ImportExportModelAdmin): # "squared": True, }, ] + + @action(description=f"اپدیت قیمت ها") + def update_products_price(self, request): + print('from the button') + ProductVariant.update_all_prices() + messages.success(request, f"قیمت {ProductVariant.objects.all().count()} تنوع محصول اپدیت شد") + return redirect("admin:product_productmodel_changelist") + + # @display( # description=("نمایش در صفحه ی اصلی"), # label={ diff --git a/backend/product/cron.py b/backend/product/cron.py new file mode 100644 index 0000000..883774f --- /dev/null +++ b/backend/product/cron.py @@ -0,0 +1,5 @@ +from product.models import ProductVariant + +def update_product_prices(): + print('calling the update product prices from cron') + ProductVariant.update_all_prices() \ No newline at end of file diff --git a/backend/product/migrations/0034_remove_productvariant_max_price_and_more.py b/backend/product/migrations/0034_remove_productvariant_max_price_and_more.py new file mode 100644 index 0000000..786bb3a --- /dev/null +++ b/backend/product/migrations/0034_remove_productvariant_max_price_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.2 on 2025-03-08 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0033_alter_productvariant_details'), + ] + + operations = [ + migrations.RemoveField( + model_name='productvariant', + name='max_price', + ), + migrations.AddField( + model_name='productvariant', + name='input_price', + field=models.PositiveIntegerField(default=0, verbose_name='قیمت ورودی'), + ), + migrations.AlterField( + model_name='productvariant', + name='color', + field=models.CharField(blank=True, max_length=7, null=True, verbose_name='رنگ'), + ), + migrations.AlterField( + model_name='productvariant', + name='details', + field=models.ManyToManyField(related_name='product', to='product.productdetailmodel', verbose_name='جزییات محصول'), + ), + migrations.AlterField( + model_name='productvariant', + name='price', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='قیمت محاسبه شده'), + ), + ] diff --git a/backend/product/models.py b/backend/product/models.py index de8aca0..ea20a76 100644 --- a/backend/product/models.py +++ b/backend/product/models.py @@ -215,14 +215,13 @@ class ProductDetailModel(models.Model): # def __str__(self): # return f'جزيیات محصول {self.product}' - class ProductVariant(models.Model): product = models.ForeignKey(ProductModel, on_delete=models.CASCADE, related_name='variants', verbose_name='محصول') product_attributes = models.ManyToManyField(AttributeValue, verbose_name='ویژگی‌ها', related_name='variant') in_stock = models.PositiveIntegerField(default=0, verbose_name='تعداد موجود') - price = models.PositiveIntegerField(default=0, verbose_name='قیمت') + price = models.PositiveIntegerField(verbose_name='قیمت محاسبه شده', blank=True, null=True) + input_price = models.PositiveIntegerField(default=0, verbose_name='قیمت ورودی') min_price = models.PositiveIntegerField(verbose_name='قیمت کف', help_text='این قیمت برای کف قیمتی محصول در نظر گرفته میشود') - max_price = models.PositiveIntegerField(verbose_name='قیمت سقف', help_text='این قیمت برای سقف قیمتی محصول در نظر گرفته میشود') currency_type = ( ('dollor', 'دلار'), ('toman', 'تومان'), @@ -232,33 +231,56 @@ class ProductVariant(models.Model): sell = models.IntegerField(default=0, verbose_name='فروش') currency = models.CharField(verbose_name='نوع ارز', max_length=20, choices=currency_type) discount = models.SmallIntegerField(default=0, verbose_name='تخفیف') - color = models.CharField(verbose_name='رنک', max_length=7, blank=True, null=True) + color = models.CharField(verbose_name='رنگ', max_length=7, blank=True, null=True) images = models.ManyToManyField(ProductImageModel, verbose_name='عکس ها') video = models.FileField(upload_to='product_videos/', blank=True, null=True, verbose_name='ویدیو') - details = models.ManyToManyField(ProductDetailModel, verbose_name='جزيیات محصول', related_name='product') + details = models.ManyToManyField(ProductDetailModel, verbose_name='جزییات محصول', related_name='product') class Meta: verbose_name = 'تنوع محصول' verbose_name_plural = 'تنوع‌های محصول' def __str__(self): return f"{self.product.name} - {', '.join(str(attr) for attr in self.product_attributes.all())}" - - def get_toman_price(self, dollor_price=None): + + def set_or_update_price(self, dollor_price=None): if not dollor_price: dollor_object, _ = DollorModel.objects.get_or_create(unique_filed='unique') dollor_price = dollor_object.price - dollar_to_dirham = 0.27 if dollor_price is None: raise ValidationError({"dollor_price": "The 'dollor_price' must be provided in the context for dollar pricing."}) + + dollar_to_dirham = 0.27 + if self.currency == 'toman': - toman_price = self.price + toman_price = self.input_price elif self.currency == 'dollor': - toman_price = self.price * dollor_price + toman_price = self.input_price * dollor_price elif self.currency == 'derham': - toman_price = self.price * dollor_price * dollar_to_dirham - toman_price = toman_price if toman_price > self.min_price else self.min_price - return toman_price + toman_price = self.input_price * dollor_price * dollar_to_dirham + else: + toman_price = self.input_price + + self.price = max(toman_price, self.min_price) + + def save(self, *args, **kwargs): + self.set_or_update_price() + super().save(*args, **kwargs) def get_toman_price_after_discount(self): - return self.get_toman_price() * ((100 - self.discount) / 100) + return self.price * ((100 - self.discount) / 100) + + @classmethod + def update_all_prices(cls): + print('calling the update all prices ') + dollor_object, _ = DollorModel.objects.get_or_create(unique_filed='unique') + print(dollor_object.price) + dollor_object.update_price() + dollor_object.save() + dollor_price = dollor_object.price + print(dollor_object.price) + print('classmethod dollor price update ') + products = cls.objects.all() + for product in products: + product.set_or_update_price(dollor_price=dollor_price) + product.save() \ No newline at end of file diff --git a/backend/product/serializers.py b/backend/product/serializers.py index 6a1320a..68ac5a6 100644 --- a/backend/product/serializers.py +++ b/backend/product/serializers.py @@ -46,18 +46,13 @@ class ProductImageSerailizer(serializers.ModelSerializer): class ProductVariantSerialzier(serializers.ModelSerializer): product_attributes = AttributeValueSerialzier(many=True) - price = serializers.SerializerMethodField() in_pack_items = InPackItemsSerialzier(many=True) images = ProductImageSerailizer(many=True) details = ProductDetailSerializer(many=True, read_only=True) class Meta: model = ProductVariant - exclude = ('min_price', 'max_price','sell', 'currency', 'product') + exclude = ('min_price', 'sell', 'currency', 'product', 'input_price') - def get_price(self, obj): - dollor_price = self.context.get('dollor_price') - toman_price = obj.get_toman_price(dollor_price=dollor_price) - return "{:,.0f} تومان".format(toman_price) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/backend/requirements.txt b/backend/requirements.txt index 29093dc..a3a2e7b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,6 +25,7 @@ django-cleanup==8.1.0 django-colorfield==0.11.0 django-cors-headers==4.4.0 django-cron==0.6.0 +django-crontab==0.7.1 django-dbbackup==4.2.1 django-dirtyfields==1.9.3 django-filter==24.3 diff --git a/docker-compose.yml b/docker-compose.yml index 66ea3cc..14dbfba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: [ "sh", "-c", - "python manage.py migrate && python manage.py collectstatic --no-input && gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3", + "python manage.py migrate && python manage.py collectstatic --no-input && python manage.py crontab add && gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3", ] networks: - default diff --git a/frontend/assets/css/input.comp.css b/frontend/assets/css/input.comp.css index 391c5d0..5c82387 100644 --- a/frontend/assets/css/input.comp.css +++ b/frontend/assets/css/input.comp.css @@ -1,6 +1,6 @@ @utility input-outlined-error { @apply text-danger-600 border-danger-600; - svg[class~=iconify] path { + svg[class~=iconify]:not(.static-icon) path { @apply stroke-danger-600; } } @@ -11,7 +11,7 @@ @utility input-outlined-disabled { @apply text-slate-300 border-slate-200; - svg[class~=iconify] path { + svg[class~=iconify]:not(.static-icon) path { @apply stroke-slate-300; } } @@ -28,7 +28,7 @@ &:focus-within { @apply border-black text-black; - svg[class~=iconify] path { + svg[class~=iconify]:not(.static-icon) path { @apply stroke-black; } } @@ -37,7 +37,7 @@ @utility input-outlined { @apply text-slate-500 border-slate-200; - svg[class~=iconify] path { + svg[class~=iconify]:not(.static-icon) path { @apply stroke-slate-500; } } diff --git a/frontend/assets/css/tailwind.css b/frontend/assets/css/tailwind.css index ce44cf6..5143a81 100644 --- a/frontend/assets/css/tailwind.css +++ b/frontend/assets/css/tailwind.css @@ -10,6 +10,11 @@ @import "./fonts/yekan-bakh.css"; @theme { + + /* CONTAINER */ + + --app-container-padding: 20px; + /* COLORS */ --color-slate-50: hsl(210, 40%, 98%); --color-slate-100: hsl(210, 40%, 96%); @@ -128,6 +133,8 @@ /* ANIMATIONS */ --animate-marquee: marquee 20s linear infinite; --animate-marquee-reverse: marquee 20s linear infinite reverse; + --animate-fade-in: fadeIn 350ms ease-in-out; + --animate-slide-down: slideDown 300ms ease-out; --animate-slide-up: slideUp 300ms ease-out; --animate-overlay-show: overlayShow 150ms ease-in; @@ -148,6 +155,16 @@ } } + @keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } + } + @keyframes slideDown { from { height: 0; @@ -265,31 +282,7 @@ /* CONTAINER */ @utility container { - @apply mx-auto px-6; - - @screen 2xs { - @apply px-4; - } - - @screen xs { - @apply px-4; - } - - @screen sm { - @apply px-4; - } - - @screen md { - @apply px-8; - } - - @screen lg { - @apply px-12; - } - - @screen xl { - @apply px-20; - } + @apply mx-auto px-[var(--app-container-padding)]; } @layer { diff --git a/frontend/components/global/Input.vue b/frontend/components/global/Input.vue index f467f30..014c7a0 100644 --- a/frontend/components/global/Input.vue +++ b/frontend/components/global/Input.vue @@ -15,7 +15,7 @@ const props = withDefaults(defineProps(), { disabled: false, placeholder: "وارد نشده", }); -const { variant, message, error, disabled, modelValue } = toRefs(props); +const { variant, error, modelValue } = toRefs(props); // emits @@ -29,7 +29,7 @@ const inputRef = ref(null); const value = computed({ get: () => modelValue.value ?? "", - set: (value) => emit("update:modelValue", value), + set: (value) => emit("update:modelValue", value) }); const classes = computed(() => { @@ -41,8 +41,8 @@ const classes = computed(() => { "input-effects": !error.value, [variant.value === "solid" ? "input-solid-error" - : "input-outlined-error"]: error.value, - }, + : "input-outlined-error"]: error.value + } ]; }); diff --git a/frontend/components/global/ProductsGrid.vue b/frontend/components/global/ProductsGrid.vue new file mode 100644 index 0000000..0b57388 --- /dev/null +++ b/frontend/components/global/ProductsGrid.vue @@ -0,0 +1,42 @@ + + + \ No newline at end of file diff --git a/frontend/components/global/ServiceHighlights.vue b/frontend/components/global/ServiceHighlights.vue index 296387e..ea8e905 100644 --- a/frontend/components/global/ServiceHighlights.vue +++ b/frontend/components/global/ServiceHighlights.vue @@ -35,11 +35,11 @@ const highlights = ref([