Merge remote-tracking branch 'origin/main'

This commit is contained in:
marzban-dev
2025-01-30 00:29:04 +03:30
18 changed files with 302 additions and 84 deletions
+19 -3
View File
@@ -1,22 +1,38 @@
from django.contrib import admin
from .models import *
from unfold.admin import ModelAdmin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from import_export.admin import ImportExportModelAdmin
from unfold.contrib.import_export.forms import ExportForm, ImportForm, SelectableFieldsExportForm
from unfold.contrib.forms.widgets import ArrayWidget, WysiwygWidget
from django.contrib.postgres.fields import ArrayField
@admin.register(User)
class UserAdmin(ModelAdmin, ImportExportModelAdmin):
class UserAdmin(BaseUserAdmin, ModelAdmin, ImportExportModelAdmin):
filter_horizontal = []
ordering = []
list_filter = []
list_display = ['phone', 'email', 'is_superuser']
readonly_fields = ['phone']
readonly_fields = []
exclude = ('otp_hash', 'otp_expiry', 'is_active', 'is_staff', 'password', 'last_login')
import_form_class = ImportForm
export_form_class = ExportForm
fieldsets = (
('Personal info', {'fields': ('first_name', 'last_name', 'profile_photo')}),
('contact', {'fields': ('phone', 'email')}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('phone', 'password1', 'password2'),
}),
)
compressed_fields = True
warn_unsaved_form = True
View File
+27
View File
@@ -0,0 +1,27 @@
from django.contrib import admin
from .models import *
from unfold.admin import ModelAdmin
from import_export.admin import ImportExportModelAdmin
from unfold.contrib.import_export.forms import ExportForm, ImportForm, SelectableFieldsExportForm
from unfold.contrib.forms.widgets import ArrayWidget, WysiwygWidget
from django.contrib.postgres.fields import ArrayField
@admin.register(BlogModel)
class BlogModelAdmin(ModelAdmin, ImportExportModelAdmin):
import_form_class = ImportForm
export_form_class = ExportForm
compressed_fields = True
warn_unsaved_form = True
formfield_overrides = {
models.TextField: {
"widget": WysiwygWidget,
},
ArrayField: {
"widget": ArrayWidget,
}
}
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'blog'
+38
View File
@@ -0,0 +1,38 @@
# Generated by Django 5.1.2 on 2025-01-29 12:17
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('product', '0004_alter_subcategorymodel_parent'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BlogModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(blank=True, max_length=200, unique=True)),
('content', models.TextField()),
('summery', models.TextField()),
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_published', models.BooleanField(default=False)),
('cover_image', models.ImageField(upload_to='blog_covers/')),
('views', models.PositiveIntegerField(default=0)),
('meta_description', models.CharField(help_text='این فیلد را حتما پر کنید', max_length=300)),
('meta_keywords', models.CharField(help_text='این فیلد را حتما پر کنید', max_length=300)),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blogs', to=settings.AUTH_USER_MODEL)),
('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blogs', to='product.subcategorymodel')),
],
),
]
View File
+38
View File
@@ -0,0 +1,38 @@
from django.db import models
from account.models import User
from django.utils.text import slugify
from django.utils.timezone import now
from product.models import SubCategoryModel
class BlogModel(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='blogs')
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True, blank=True)
content = models.TextField()
summery = models.TextField()
category = models.ForeignKey(SubCategoryModel, on_delete=models.SET_NULL, null=True, related_name='blogs')
created_at = models.DateTimeField(default=now, editable=False)
updated_at = models.DateTimeField(auto_now=True)
is_published = models.BooleanField(default=False)
cover_image = models.ImageField(upload_to='blog_covers/', blank=True)
views = models.PositiveIntegerField(default=0)
meta_description = models.CharField(max_length=300, help_text='این فیلد را حتما پر کنید')
meta_keywords = models.CharField(max_length=300, help_text='این فیلد را حتما پر کنید')
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
def __str__(self):
return self.title
# class Comment(models.Model):
# blog = models.ForeignKey(Blog, on_delete=models.CASCADE, related_name='comments')
# user = models.ForeignKey(User, on_delete=models.CASCADE)
# content = models.TextField()
# created_at = models.DateTimeField(default=now)
# def __str__(self):
# return f'Comment by {self.user} on {self.blog}'
+13
View File
@@ -0,0 +1,13 @@
from rest_framework import serializers
from .models import BlogModel
class BlogSerilizer(serializers.ModelSerializer):
class Meta:
model = BlogModel
fields = ['title','author', 'slug', 'category', 'created_at', 'updated_at', 'cover_image', 'views']
class AllBlogSerilizer(serializers.ModelSerializer):
class Meta:
model = BlogModel
exclude = ('is_published',)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path('all', views.AllBlogView.as_view(), name='product-chat-view'),
path('<int:pk>', views.BlogView.as_view(), name='product-chat-view'),
]
+77
View File
@@ -0,0 +1,77 @@
from django.shortcuts import render
from rest_framework.views import APIView, Response
from rest_framework import status
from .models import BlogModel
from .serializers import AllBlogSerilizer, BlogSerilizer
from django.shortcuts import get_object_or_404
from utils.pagination import StructurePagination
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
class AllBlogView(APIView):
serializer_class = AllBlogSerilizer
pagination_class = StructurePagination
authentication_classes = []
@extend_schema(
parameters=[
OpenApiParameter(
name="limit",
description="لیمیتش",
required=False,
type=OpenApiTypes.INT,
),
OpenApiParameter(
name="offset",
description="افستش",
required=False,
type=OpenApiTypes.INT,
)
],
responses={
200: AllBlogSerilizer(many=True),
404: OpenApiTypes.OBJECT,
},
)
def get(self, request):
blogs = BlogModel.objects.filter(is_published=True)
paginator = self.pagination_class()
paginated_blogs = paginator.paginate_queryset(blogs, request)
blog_ser = self.serializer_class(instance=paginated_blogs, many=True, context={'request': request})
return paginator.get_paginated_response(blog_ser.data)
class BlogView(APIView):
serializer_class = BlogSerilizer
authentication_classes = []
def get_client_ip(self, request):
"""Helper function to get the client IP from request headers."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def get(self, request, pk):
blog = get_object_or_404(BlogModel, pk=pk)
if blog.is_published:
# Track views using session
client_ip = self.get_client_ip(request)
session_key = f'viewed_blog_{pk}_{client_ip}'
if not request.session.get(session_key):
blog.views += 1
blog.save()
print(f'views {blog.views}')
print(session_key)
request.session[session_key] = True
request.session.set_expiry(3600)
blog_ser = self.serializer_class(instance=blog, context={'request': request})
return Response(blog_ser.data, status=status.HTTP_200_OK)
else:
return Response({'detail': 'object with the given id does not exist or is not published yet'},
status=status.HTTP_404_NOT_FOUND)
+1
View File
@@ -111,6 +111,7 @@ INSTALLED_APPS = [
'chat',
'order',
'home',
'blog',
]
MIDDLEWARE = [
+1
View File
@@ -24,6 +24,7 @@ urlpatterns = [
path('accounts/', include('account.urls')),
path('chat/', include('chat.urls')),
path('tickets/', include('ticket.urls')),
path('blogs/', include('blog.urls')),
path('', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
]
+1 -1
View File
@@ -8,7 +8,7 @@ class ProductChatSerializer(serializers.ModelSerializer):
is_new = serializers.SerializerMethodField()
class Meta:
model = ProductModel
fields = ['name', 'description', 'price', 'in_stock', 'discount', ]
fields = ['name', 'description', 'price', 'in_stock', 'discount', 'is_new']
def get_price(self, obj):
dollor_price = self.context.get('dollor_price')
dollar_to_dirham = 0.27
+4 -7
View File
@@ -63,8 +63,7 @@ class ProductView(APIView):
class AllProductsView(APIView):
serializer_class = ProductSerializer
pagination_class = StructurePagination
authentication_classes = [] # Add authentication if required
authentication_classes = []
@extend_schema(
parameters=[
OpenApiParameter(
@@ -172,11 +171,9 @@ class AllProductsView(APIView):
products = products.filter(Q(name__icontains=search_query) | Q(description__icontains=search_query))
# Price filters
try:
price_gte = int(request.query_params.get('price_gte', None))
price_lte = int(request.query_params.get('price_lte', None))
except:
return Response({'detail': 'value error price_gte and price_lte should be a number'}, status=status.HTTP_400_BAD_REQUEST)
price_gte = request.query_params.get('price_gte', None)
price_lte = request.query_params.get('price_lte', None)
if price_gte:
products = products.filter(price__gte=price_gte)
if price_lte:
@@ -1,10 +1,10 @@
<script setup lang="ts">
// imports
import useGetCategories from "~/composables/api/product/useGetCategories";
import useGetProducts, {
type GetProductsFilters,
} from "~/composables/api/products/useGetProducts";
import { useParams } from "~/composables/global/useParams";
import { PRODUCT_RANGE } from "~/constants";
// state
@@ -24,36 +24,43 @@ const in_stock = ref(JSON.parse(params.in_stock) ?? false);
const sliderValueDebounced = refDebounced(sliderValue, 1000);
const options = [
{
name: "میوه",
children: [
{ name: "سیب" },
{ name: "موز" },
{ name: "پرتقال" },
{ name: "طالبی" },
{ name: "انگور" },
{ name: "هندوانه" },
{ name: "خربزه" },
{ name: "گلابی" },
],
},
{
name: "سبزیجات",
children: [
{ name: "کلم" },
{ name: "بروکلی" },
{ name: "هویج" },
{ name: "کاهو" },
{ name: "اسفناج" },
{ name: "چوی بوک" },
{ name: "گل کلم" },
{ name: "سیب زمینی" },
],
},
];
// queries
const { isPending: productsIsPending } = useGetProducts(params);
const filters = computed(() => {
return {
sort: params.sort ?? "newest",
search: params.search ?? "",
price_gte: params.price_gte ?? PRODUCT_RANGE.min,
price_lte: params.price_lte ?? PRODUCT_RANGE.max,
in_stock: params.in_stock ?? false,
has_discount: params.has_discount ?? false,
category: params.category ?? undefined,
page: params.page ?? 1,
};
});
const { data: categories, suspense } = useGetCategories();
await useAsyncData(async () => {
await suspense();
});
const { isPending: productsIsPending } = useGetProducts(filters);
// computed
const allCategories = computed(() => {
return categories.value!.map((category) => {
return {
name: category.name,
children: category.subcategorys.map((sub) => {
return {
name: sub.name,
};
}),
};
});
});
// methods
@@ -130,7 +137,7 @@ watch(
<Icon name="ci:grid" size="24" />
دسته بندی
</div>
<ComboBox :options="options" v-model="params.category" />
<ComboBox :options="allCategories" v-model="params.category" />
</div>
<div class="flex flex-col w-full gap-5">
@@ -20,22 +20,11 @@ export type GetProductsFilters = {
// composable
const useGetProducts = (params?: GetProductsFilters) => {
const useGetProducts = (params?: ComputedRef<GetProductsFilters>) => {
// state
const { $axios: axios } = useNuxtApp();
const {
search,
sort,
in_stock,
has_discount,
category,
price_gte,
price_lte,
page,
} = toRefs(params as GetProductsFilters);
// methods
const handleGetProducts = async (params?: GetProductsFilters) => {
@@ -50,7 +39,7 @@ const useGetProducts = (params?: GetProductsFilters) => {
category: params?.category,
price_gte: params?.price_gte,
price_lte: params?.price_lte,
offset: params?.page ? Number(params.page) * 9 - 9 : 0,
offset: Number(params?.page) * 9 - 9,
limit: 9,
},
}
@@ -61,18 +50,8 @@ const useGetProducts = (params?: GetProductsFilters) => {
return useQuery({
staleTime: 60 * 1000,
queryKey: [
QUERY_KEYS.products,
search,
sort,
in_stock,
has_discount,
category,
price_gte,
price_lte,
page,
],
queryFn: () => handleGetProducts(params),
queryKey: [QUERY_KEYS.products, params],
queryFn: () => handleGetProducts(params?.value),
});
};
+25 -17
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
// import
import useGetProducts, {
@@ -9,17 +8,19 @@ import { PRODUCT_RANGE } from "~/constants";
// state
const params: GetProductsFilters = useUrlSearchParams("history", {
initialValue: {
search: "",
sort: "newest",
price_gte: PRODUCT_RANGE.min,
price_lte: PRODUCT_RANGE.max,
in_stock: false,
has_discount: false,
category: "",
page: "1",
},
const params: GetProductsFilters = useUrlSearchParams("history");
const filters = computed(() => {
return {
sort: params.sort ?? "newest",
search: params.search ?? "",
price_gte: params.price_gte ?? PRODUCT_RANGE.min,
price_lte: params.price_lte ?? PRODUCT_RANGE.max,
in_stock: params.in_stock ?? false,
has_discount: params.has_discount ?? false,
category: params.category ?? undefined,
page: params.page ?? 1,
};
});
const search = ref(params.search ?? "");
@@ -31,7 +32,7 @@ provide("params", params);
// queries
const { data, isLoading: productsIsLoading } = useGetProducts(params);
const { data, isLoading: productsIsLoading } = useGetProducts(filters);
const products = computed(() => {
return data.value?.results.flat();
@@ -55,17 +56,17 @@ watch(
<template>
<div class="w-full container flex flex-col">
<div class="w-full flex justify-end items-center py-[5rem]">
<div class="w-full flex justify-end items-end py-[5rem]">
<div
class="flex flex-col items-start gap-[1.5rem] text-black w-full"
>
<!-- <div class="flex-center gap-[.75rem]">
<div class="flex-center gap-[.75rem]">
<span>خانه</span>
<span>/</span>
<span>محصولات</span>
<span>/</span>
<span>همه</span>
</div> -->
</div>
<h1 class="typo-h-3">لیست محصولات</h1>
</div>
@@ -86,7 +87,14 @@ watch(
</div>
</template>
</Input>
<FilterButton />
<!-- <Suspense>
<FilterButton />
<template #fallback>
<Skeleton
class="!w-[10.35rem] !h-[3.35rem] !rounded-full"
/>
</template>
</Suspense> -->
</div>
</div>
<ul