diff --git a/backend/account/urls.py b/backend/account/urls.py index c01ce3a..93ac5cb 100644 --- a/backend/account/urls.py +++ b/backend/account/urls.py @@ -4,6 +4,7 @@ from djoser.urls.jwt import views as djoser_jwt_views urlpatterns = [ path('profile', views.ProfileView.as_view()), + path('verify', djoser_jwt_views.TokenVerifyView.as_view(), name='jwt-verify'), path('send_otp', views.SendOTPView.as_view(), name='send-otp-view'), path('address/create', views.CreateAddressView.as_view(), name='create-address'), path('address/edit/', views.EditAddressView.as_view(), name='edit-address'), diff --git a/backend/core/settings.py b/backend/core/settings.py index 05712e2..6336fd9 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -200,8 +200,8 @@ REST_FRAMEWORK = { } SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=1), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=2), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'ROTATE_REFRESH_TOKENS': True, 'BLACKLIST_AFTER_ROTATION': True, } diff --git a/backend/core/urls.py b/backend/core/urls.py index d8e4a3e..2286b6f 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -16,7 +16,7 @@ urlpatterns = [ path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), - # path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('admin/', admin.site.urls), path('schema/', SpectacularAPIView.as_view(), name='schema'), # path('comment/', views.CommentView.as_view(), name='comment-list'), diff --git a/backend/product/migrations/0011_productmodel_created_at.py b/backend/product/migrations/0011_productmodel_created_at.py new file mode 100644 index 0000000..d600e23 --- /dev/null +++ b/backend/product/migrations/0011_productmodel_created_at.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.2 on 2025-01-14 17:41 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0010_remove_productmodel_link_of_metas'), + ] + + operations = [ + migrations.AddField( + model_name='productmodel', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='زمان ثبت محصول'), + preserve_default=False, + ), + ] diff --git a/backend/product/migrations/0012_productmodel_category.py b/backend/product/migrations/0012_productmodel_category.py new file mode 100644 index 0000000..2aafeb4 --- /dev/null +++ b/backend/product/migrations/0012_productmodel_category.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.2 on 2025-01-14 18:31 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0011_productmodel_created_at'), + ] + + operations = [ + migrations.AddField( + model_name='productmodel', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='product.categorymodel'), + ), + ] diff --git a/backend/product/models.py b/backend/product/models.py index fd56048..0d0cd2f 100644 --- a/backend/product/models.py +++ b/backend/product/models.py @@ -112,7 +112,8 @@ class ProductModel(models.Model): meta_description = models.CharField(max_length=300, blank=True, null=True, help_text='این فیلد را حتما پر کنید') meta_keywords = models.CharField(max_length=300, blank=True, null=True, help_text='این فیلد را حتما پر کنید') meta_rating = models.FloatField(default=5, help_text='امتیاز محصول') - + created_at = models.DateTimeField(auto_now_add=True, verbose_name='زمان ثبت محصول') + category = models.ForeignKey(CategoryModel, blank=True, null=True, on_delete=models.SET_NULL) def format_discount_price(self): discount_price = int(self.price * (100 - self.discount) / 100) formatted_num = "{:,.0f}".format(discount_price) diff --git a/backend/product/serializers.py b/backend/product/serializers.py index a4726f9..277e28b 100644 --- a/backend/product/serializers.py +++ b/backend/product/serializers.py @@ -1,8 +1,11 @@ from .models import * from rest_framework import serializers +from django.utils import timezone +from datetime import timedelta class ProductChatSerializer(serializers.ModelSerializer): price = serializers.SerializerMethodField() + is_new = serializers.SerializerMethodField() class Meta: model = ProductModel fields = ['name', 'description', 'price', 'in_stock', 'discount', ] @@ -13,11 +16,14 @@ class ProductChatSerializer(serializers.ModelSerializer): if dollor_price is None: raise ValidationError({"dollor_price": "The 'dollor_price' must be provided in the context for dollar pricing."}) if obj.currency == 'toman': - return obj.price + toman_price = obj.price elif obj.currency == 'dollor': - return obj.price * dollor_price + toman_price = obj.price * dollor_price elif obj.currency == 'derham': - return obj.price * dollor_price * dollar_to_dirham + toman_price = obj.price * dollor_price * dollar_to_dirham + return "{:,.0f} تومان".format(toman_price) + def get_is_new(self, obj): + return timezone.now() < obj.created_at + timedelta(days=7) class ProductSerializer(ProductChatSerializer): class Meta: diff --git a/backend/product/views.py b/backend/product/views.py index 63b1c5d..563e442 100644 --- a/backend/product/views.py +++ b/backend/product/views.py @@ -8,28 +8,60 @@ from django.db.models import Q from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticatedOrReadOnly from utils.pagination import StructurePagination -from drf_spectacular.utils import extend_schema, OpenApiParameter -from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes from rest_framework.permissions import AllowAny + +# class CustomAPIView(APIView): +# def __init__(self, *args, **kwargs): +# super().__init__(*args, **kwargs) +# print('here') +# print(self.permission_classes) +# if not getattr(self, 'permission_classes')[0] != AllowAny or not self.permission_classes: +# print('asdf') +# self.authentication_classes = [] + + class AllCategories(APIView): serializer_class = CategorySerializer + authentication_classes = [] + @extend_schema( + parameters=[ + OpenApiParameter( + name="search", + description="Search by category name or description.", + required=False, + type=OpenApiTypes.STR, + ) + ], + responses={ + 200: CategorySerializer(many=True), + 404: OpenApiTypes.OBJECT, + }, + ) def get(self, request): - categories = CategoryModel.objects.all() + search_query = request.query_params.get('search', None) + if search_query: + categories = CategoryModel.objects.filter(Q(name__icontains=search_query) | Q(slug__icontains=search_query)) + else: + categories = CategoryModel.objects.all() categories_ser = self.serializer_class(instance=categories, many=True) return Response({"categories": categories_ser.data}, status=status.HTTP_200_OK) class ProductView(APIView): serializer_class = ProductSerializer permission_classes = [AllowAny] + authentication_classes = [] def get(self, request, pk): product = get_object_or_404(ProductModel, id=pk) product_ser = self.serializer_class(instance=product, many=False) return Response(product_ser.data, status=status.HTTP_200_OK) + class AllProductsView(APIView): serializer_class = ProductSerializer pagination_class = StructurePagination + authentication_classes = [] # Add authentication if required @extend_schema( parameters=[ @@ -41,10 +73,12 @@ class AllProductsView(APIView): ), OpenApiParameter( name="category", - description="Filter by category ID.", + type={'type': 'array', 'items': {'type': 'number'}}, + location=OpenApiParameter.QUERY, required=False, - type=OpenApiTypes.INT, - ), + style='form', + explode=False, + ), OpenApiParameter( name="price_gte", description="Filter products with price greater than or equal to this value.", @@ -61,7 +95,7 @@ class AllProductsView(APIView): name="sort", description=( "Sort results by one of the following fields:\n" - "`name`, `-name`, `price`, `-price`, `discount`, `-discount`." + "`name`, `-name`, `price`, `-price`, `discount`, `-discount`, `created_at`, `-created_at`." "\nPrefix with `-` for descending order." ), required=False, @@ -79,32 +113,56 @@ class AllProductsView(APIView): required=False, type=OpenApiTypes.INT, ), + OpenApiParameter( + name="in_stock", + description="Filter products that are in stock (positive stock).", + required=False, + type=OpenApiTypes.BOOL, + ), + OpenApiParameter( + name="has_discount", + description="Filter products that have a discount.", + required=False, + type=OpenApiTypes.BOOL, + ) ], description=( "Retrieve products with optional filters and sorting. " - "Provide a category ID to filter products in that category and its subcategories." + "Provide a list of category IDs to filter products by those categories and their subcategories." ), responses={ 200: ProductSerializer(many=True), 404: OpenApiTypes.OBJECT, }, ) - def get(self, request, pk=None): + def get(self, request): try: - if pk: - category = Category.objects.get(pk=pk) - products = ProductModel.objects.filter(category__in=category.get_descendants(include_self=True)) + # Get list of category IDs from query parameters + category_ids = request.query_params.getlist('category', []) + if category_ids: + # Convert category IDs to integers and filter products by these categories + category_ids = [int(id) for id in category_ids] + + products = ProductModel.objects.filter(category__id__in=category_ids) else: products = ProductModel.objects.all() + # Filter by stock status if `in_stock` is specified + in_stock = request.query_params.get('in_stock', "false") == 'true' + if in_stock: + products = products.filter(in_stock__gt=0) + + # Filter by discount if `has_discount` is specified + has_discount = request.query_params.get('has_discount', "false") == 'true' + if has_discount: + products = products.filter(discount__gt=0) + + # Search filter search_query = request.query_params.get('search', None) if search_query: products = products.filter(Q(name__icontains=search_query) | Q(description__icontains=search_query)) - category_filter = request.query_params.get('category', None) - if category_filter: - products = products.filter(category__id=category_filter) - + # Price filters price_gte = request.query_params.get('price_gte', None) price_lte = request.query_params.get('price_lte', None) if price_gte: @@ -112,18 +170,20 @@ class AllProductsView(APIView): if price_lte: products = products.filter(price__lte=price_lte) + # Sorting sort_by = request.query_params.get('sort', None) - if sort_by in ['name', '-name', 'price', '-price', 'discount', '-discount']: + if sort_by in ['name', '-name', 'price', '-price', 'discount', '-discount', 'created_at', '-created_at']: products = products.order_by(sort_by) else: products = products.order_by('name') + # Pagination paginator = self.pagination_class() paginated_products = paginator.paginate_queryset(products, request) serializer = self.serializer_class(paginated_products, many=True) return paginator.get_paginated_response(serializer.data) - except Category.DoesNotExist: + except CategoryModel.DoesNotExist: return Response({"detail": "Category not found."}, status=status.HTTP_404_NOT_FOUND) diff --git a/frontend/.gitignore b/frontend/.gitignore index 4a7f73a..5379696 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -10,7 +10,7 @@ dist node_modules # Logs -logs +.logs *.log # Misc diff --git a/frontend/components/global/Header.vue b/frontend/components/global/Header.vue index 07cda45..3341176 100644 --- a/frontend/components/global/Header.vue +++ b/frontend/components/global/Header.vue @@ -19,24 +19,24 @@ const { logout } = useAuth(); const nav_links = ref([ { title: "فروشگاه", - path: "#", + path: "#" }, { title: "دسته بندی ها", - path: "#", + path: "#" }, { title: "جستجو", - path: "#", + path: "#" }, { title: "ارتباط با ما", - path: "#", + path: "#" }, { title: "امکانات", - path: "#", - }, + path: "#" + } ]); @@ -53,7 +53,13 @@ const nav_links = ref([ -
KIR
+