This commit is contained in:
Mamalizz
2025-01-19 19:21:47 +03:30
26 changed files with 1657 additions and 89 deletions
+1
View File
@@ -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/<int:pk>', views.EditAddressView.as_view(), name='edit-address'),
+2 -2
View File
@@ -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,
}
+1 -1
View File
@@ -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/<int:pk>', views.CommentView.as_view(), name='comment-list'),
@@ -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,
),
]
@@ -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'),
),
]
+2 -1
View File
@@ -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)
+9 -3
View File
@@ -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:
+78 -18
View File
@@ -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)
+1 -1
View File
@@ -10,7 +10,7 @@ dist
node_modules
# Logs
logs
.logs
*.log
# Misc
+13 -7
View File
@@ -19,24 +19,24 @@ const { logout } = useAuth();
const nav_links = ref<NavLink[]>([
{
title: "فروشگاه",
path: "#",
path: "#"
},
{
title: "دسته بندی ها",
path: "#",
path: "#"
},
{
title: "جستجو",
path: "#",
path: "#"
},
{
title: "ارتباط با ما",
path: "#",
path: "#"
},
{
title: "امکانات",
path: "#",
},
path: "#"
}
]);
</script>
@@ -53,7 +53,13 @@ const nav_links = ref<NavLink[]>([
<button @click="() => logout(true)">خروج از وبسایت</button>
</div>
<div v-else class="text-black">KIR</div>
<button
@click="navigateTo('/signin')"
class="cursor-pointer"
v-else
>
وارد شوید
</button>
<nav
class="flex-center gap-[2.5rem] w-8/12 typo-label-sm text-slate-500"
@@ -2,7 +2,8 @@
// import
import type { ToastOptions } from "~/composables/useToast";
import type { ToastOptions } from "~/composables/global/useToast";
import { useToast } from "~/composables/global/useToast";
// type
+1
View File
@@ -61,6 +61,7 @@ const onSwiper = (swiper: SwiperClass) => {
:key="slide.id"
>
<CategoryCard
:id="slide.id"
category="یک دسته بندی تست"
picture="/img/product-1.jpg"
:count="20"
@@ -7,6 +7,7 @@ import ChatInput from "~/components/product/ChatBox/ChatInput.vue";
import { useIsMutating } from "@tanstack/vue-query";
import { MUTATION_KEYS } from "~/constants";
import CloseButton from "~/components/product/ChatBox/CloseButton.vue";
import { useAuth } from "~/composables/api/auth/useAuth";
// provide-inject
@@ -14,6 +15,8 @@ const { isOpen } = inject("isOpen") as any;
// state
const { isLoggedIn } = useAuth();
const route = useRoute();
const id = route.params.id as string | number;
@@ -126,55 +129,62 @@ whenever(
>
<CloseButton :disabled="!!isCreateMessagePending" />
<Transition name="zoom" mode="out-in">
<div
v-if="!isChatPending"
class="p-4.5 h-full flex flex-col justify-between gap-4"
>
<template v-if="isLoggedIn">
<Transition name="zoom" mode="out-in">
<div
:style="{
v-if="!isChatPending"
class="p-4.5 h-full flex flex-col justify-between gap-4"
>
<div
:style="{
maskImage:
'linear-gradient(to top, transparent, black 5%, black, black)',
}"
class="hide-scrollbar flex flex-col py-7 gap-6 h-full overflow-y-auto"
ref="chatContainerEl"
>
<div
v-if="hasMoreChat"
class="py-2 flex items-center justify-center"
class="hide-scrollbar flex flex-col py-7 gap-6 h-full overflow-y-auto"
ref="chatContainerEl"
>
<Icon name="svg-spinners:3-dots-fade" size="24" />
<div
v-if="hasMoreChat"
class="py-2 flex items-center justify-center"
>
<Icon name="svg-spinners:3-dots-fade" size="24" />
</div>
<ChatMessage
v-for="(message, index) in chatMessages"
:key="message.id"
:id="message.id"
:reverse="message.sender === 'ai'"
:content="message.content"
:isLast="chatMessages?.length === index + 1"
@textUpdate="scrollToBottom"
/>
<ChatMessage
v-if="!!isCreateMessagePending"
:id="Date.now() + 1"
reverse
content=""
isLast
@textUpdate="scrollToBottom"
:loadingContent="!!isCreateMessagePending"
/>
</div>
<ChatMessage
v-for="(message, index) in chatMessages"
:key="message.id"
:id="message.id"
:reverse="message.sender === 'ai'"
:content="message.content"
:isLast="chatMessages?.length === index + 1"
@textUpdate="scrollToBottom"
/>
<ChatMessage
v-if="!!isCreateMessagePending"
:id="Date.now() + 1"
reverse
content=""
isLast
@textUpdate="scrollToBottom"
:loadingContent="!!isCreateMessagePending"
/>
<ChatInput />
</div>
<ChatInput />
</div>
<div
v-else
class="w-full h-full flex items-center justify-center absolute inset-0"
>
<AiLoading />
</div>
</Transition>
<div
v-else
class="w-full h-full flex items-center justify-center absolute inset-0"
>
<AiLoading />
</div>
</Transition>
</template>
<div class="text-black p-4.5 size-full flex justify-center items-center" v-else>
Please sign in first
</div>
</div>
</Transition>
</template>
@@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/vue-query";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
import { useAuth } from "~/composables/api/auth/useAuth";
// types
+11 -1
View File
@@ -3,6 +3,7 @@ export const useAuth = () => {
// state
const token = useCookie("token");
const refreshToken = useCookie("refresh-token");
// method
@@ -10,11 +11,20 @@ export const useAuth = () => {
token.value = newToken;
};
const updateRefreshToken = (newToken: string) => {
refreshToken.value = newToken;
};
const logout = (reload ?: boolean) => {
token.value = undefined;
refreshToken.value = undefined;
if (reload) window.location.reload();
};
return { token, updateToken, logout };
// computed
const isLoggedIn = computed(() => !!token.value);
return { token, refreshToken, updateRefreshToken, updateToken, logout, isLoggedIn };
};
@@ -0,0 +1,36 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type RefreshAuthRequest = {
refresh: string,
};
export type RefreshAuthResponse = {
access: string,
refresh: string,
};
const useRefreshAuth = () => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleRefreshAuth = async (variables: RefreshAuthRequest) => {
const { data } = await axios.post<RefreshAuthResponse>(`${API_ENDPOINTS.auth.refresh}/`, variables);
return data;
};
return useMutation({
mutationFn: (variables: RefreshAuthRequest) => handleRefreshAuth(variables)
});
};
export default useRefreshAuth;
@@ -0,0 +1,30 @@
// imports
import { useMutation } from "@tanstack/vue-query";
import { API_ENDPOINTS } from "~/constants";
// types
export type VerifyRequest = {
token: string,
};
const useVerify = () => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleVerify = async (variables: VerifyRequest) => {
const { data } = await axios.post(`${API_ENDPOINTS.auth.verify}`, variables);
return data;
};
return useMutation({
mutationFn: (variables: VerifyRequest) => handleVerify(variables)
});
};
export default useVerify;
+4 -1
View File
@@ -2,6 +2,7 @@
import { useInfiniteQuery } from "@tanstack/vue-query";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
import { useAuth } from "~/composables/api/auth/useAuth";
// types
@@ -16,6 +17,8 @@ const useGetBranch = (
const { $axios: axios } = useNuxtApp();
const { isLoggedIn } = useAuth();
// method
const handleGetChat = async ({ productId, limit, offset }: {
@@ -37,7 +40,7 @@ const useGetBranch = (
};
return useInfiniteQuery({
enabled,
enabled: isLoggedIn,
queryKey: [QUERY_KEYS.chat],
initialPageParam: {
limit: 10,
+2
View File
@@ -7,6 +7,8 @@ export const API_ENDPOINTS = {
get: "/products",
},
auth: {
refresh: "/token/refresh",
verify: "/accounts/verify",
signin: "/token",
logout: "/accounts/logout",
},
+67
View File
@@ -1,3 +1,70 @@
<script lang="ts" setup>
// import
import { useAuth } from "~/composables/api/auth/useAuth";
import useRefreshAuth from "~/composables/api/auth/useRefreshAuth";
import useVerify from "~/composables/api/auth/useVerify";
// state
const { mutateAsync: refreshAuth } = useRefreshAuth();
const { token, refreshToken, updateToken, updateRefreshToken, logout } = useAuth();
const { mutateAsync: verify } = useVerify();
// lifecycle
onServerPrefetch(async () => {
if (!!token.value) {
// 1.1 - token is there
try {
await verify({
token: token.value
});
// 2.1 - token is valid, finish
} catch (e) {
// 2.2 - token is there, but not valid, try to refresh token
if (!!refreshToken.value) {
// 3.1 - refresh token is there, try to refresh
try {
const refreshResponse = await refreshAuth({ refresh: refreshToken.value });
// 4.1 - token is refreshed successfully, finish
updateToken(refreshResponse.access);
updateRefreshToken(refreshResponse.refresh);
} catch (e) {
// 4.2 - cant refreshing token, logout
logout();
}
} else {
// 3.2 - refresh token is not exist, logout
logout();
}
}
} else {
// 1.2 - token is not exist, logout
logout();
}
});
</script>
<template>
<div
class="w-full flex flex-col-center persian-number font-iran-yekan-x"
+6 -3
View File
@@ -1,9 +1,12 @@
export default defineNuxtRouteMiddleware(() => {
const { token } = useAuth();
import { useAuth } from "~/composables/api/auth/useAuth";
if (token.value) {
export default defineNuxtRouteMiddleware(() => {
const { token, logout } = useAuth();
if (!!token.value) {
return;
} else {
logout();
return navigateTo("/signin");
}
});
@@ -1,3 +1,5 @@
import { useAuth } from "~/composables/api/auth/useAuth";
export default defineNuxtRouteMiddleware(() => {
const { token } = useAuth();
+4 -5
View File
@@ -28,11 +28,10 @@ definePageMeta({
const { addToast } = useToast();
const { updateToken } = useAuth();
const { updateToken, updateRefreshToken } = useAuth();
const { refetch: refetchAccount } = useGetAccount();
const showOtp = ref(false);
const otpCode = ref([]);
@@ -107,6 +106,8 @@ const handleLogin = async () => {
});
updateToken(response.access);
updateRefreshToken(response.refresh);
await new Promise(resolve => setTimeout(resolve, 1000));
await refetchAccount();
addToast({
@@ -116,9 +117,7 @@ const handleLogin = async () => {
}
});
setTimeout(() => {
navigateTo("/");
}, 2000);
navigateTo("/");
} catch (e) {
otpCode.value = [];
addToast({ message: "مشکلی پیش آمده" });
+5 -5
View File
@@ -4,10 +4,10 @@ import { API_ENDPOINTS } from "~/constants";
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
const { token } = useAuth();
const { token, logout } = useAuth();
const axios = axiosOriginal.create({
baseURL: config.public.API_BASE_URL,
baseURL: config.public.API_BASE_URL
});
axios.interceptors.request.use((config) => {
@@ -25,9 +25,9 @@ export default defineNuxtPlugin(() => {
return response;
}, function(error) {
if (error.status === 401) {
logout();
}
// if (error.status === 401) {
// logout();
// }
return Promise.reject(error);
});
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
import fs from "fs/promises";
type LogType = {
title: string;
status?: "success" | "error" | "info" | "warning";
message?: string,
details?: any
}
class Logger {
private static formatToMarkdown(log: LogType) {
const date = new Date();
let month = "" + (date.getMonth() + 1);
let day = "" + date.getDate();
let year = date.getFullYear();
let hour = date.getHours();
let minutes = date.getMinutes();
let seconds = date.getSeconds();
if (month.length < 2) {
month = "0" + month;
}
if (day.length < 2) {
day = "0" + day;
}
let markdownContent = "";
let icon = "️";
switch (log.status) {
case "info":
icon = "️";
break;
case "error":
icon = "‼️";
break;
case "warning":
icon = "⚠️";
break;
case "success":
icon = "✅";
break;
default :
icon = "️";
break;
}
markdownContent += `# ${icon} ${log.title} \n`;
markdownContent += `## ${[year, month, day].join("-")} ${hour}:${minutes}:${seconds} \n`;
if (log.message) {
markdownContent += `**Message:** ${log.message}\n`;
}
if (log.details) {
markdownContent += `**Details:**\n\n\`\`\`json\n${JSON.stringify(log.details, null, 2)}\n\`\`\`\n\n`;
}
markdownContent += "<br></br>\n\n";
markdownContent += "---\n";
return markdownContent;
}
public static async log(info: LogType) {
const formattedLog = this.formatToMarkdown(info);
try {
await fs.appendFile(".logs/log.md", formattedLog);
} catch (e) {
console.error(e);
}
}
}
export default Logger;