diff --git a/backend/account/admin.py b/backend/account/admin.py index d4e4460..b223804 100644 --- a/backend/account/admin.py +++ b/backend/account/admin.py @@ -10,6 +10,12 @@ from django.contrib.auth.models import Group from unfold.forms import AdminPasswordChangeForm from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm from utils.admin import ModelAdmin +from django.template.loader import render_to_string +from folium import Map, Marker +from unfold.decorators import action, display +from django.utils.html import format_html + + class UserAddressInLine(TabularInline): model = UserAddressModel extra = 0 @@ -95,4 +101,37 @@ class PushSubscription(ModelAdmin, ImportExportModelAdmin): import_form_class = ImportForm export_form_class = ExportForm compressed_fields = True - warn_unsaved_form = True \ No newline at end of file + warn_unsaved_form = True + + + +@admin.register(SecurityBreachAttemptModel) +class SecurityBreachAttemptAdmin(ModelAdmin, ImportExportModelAdmin): + import_form_class = ImportForm + export_form_class = ExportForm + compressed_fields = True + warn_unsaved_form = True + change_form_template = 'loction_chagne_form.html' + list_display = ['ip_address', 'country', 'region_name', 'city', 'zip_code', 'isp', 'created_at', 'trys', 'display_viewd'] + + def change_view(self, request, object_id, form_url='', extra_context=None): + extra_context = extra_context or {} + obj = self.get_object(request, object_id) + + if obj and obj.lat and obj.lon: + m = Map(location=[obj.lat, obj.lon], zoom_start=10) + Marker([obj.lat, obj.lon], popup=f"Location: {obj.ip_address}").add_to(m) + map_html = m._repr_html_() + extra_context['map_html'] = map_html + return super().change_view(request, object_id, form_url, extra_context) + + @display(description='دیده شده') + def display_viewd(self, instance): + if instance.viewd: + svg = f'' + else: + svg = f'' + + return format_html( + svg + ) \ No newline at end of file diff --git a/backend/account/migrations/0015_securitybreachattemptmodel.py b/backend/account/migrations/0015_securitybreachattemptmodel.py new file mode 100644 index 0000000..15e68d1 --- /dev/null +++ b/backend/account/migrations/0015_securitybreachattemptmodel.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.2 on 2025-02-20 20:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0014_alter_pushsubscription_created_at_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='SecurityBreachAttemptModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip', models.CharField(max_length=40, unique=True, verbose_name='آدرس آی\u200cپی')), + ('country', models.CharField(blank=True, max_length=40, null=True, verbose_name='کشور')), + ('region_name', models.CharField(blank=True, max_length=40, null=True, verbose_name='منطقه')), + ('city', models.CharField(blank=True, max_length=40, null=True, verbose_name='شهر')), + ('zip_code', models.CharField(blank=True, max_length=40, null=True, verbose_name='کد پستی')), + ('lon', models.CharField(blank=True, max_length=40, null=True, verbose_name='طول جغرافیایی')), + ('lat', models.CharField(blank=True, max_length=40, null=True, verbose_name='عرض جغرافیایی')), + ('isp', models.CharField(blank=True, max_length=40, null=True, verbose_name='ارائه\u200cدهنده اینترنت (ISP)')), + ], + options={ + 'verbose_name': 'تلاش نفوذ', + 'verbose_name_plural': 'تلاش\u200cهای نفوذ', + }, + ), + ] diff --git a/backend/account/migrations/0016_securitybreachattemptmodel_viewd.py b/backend/account/migrations/0016_securitybreachattemptmodel_viewd.py new file mode 100644 index 0000000..6f16be0 --- /dev/null +++ b/backend/account/migrations/0016_securitybreachattemptmodel_viewd.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-02-20 21:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0015_securitybreachattemptmodel'), + ] + + operations = [ + migrations.AddField( + model_name='securitybreachattemptmodel', + name='viewd', + field=models.BooleanField(default=False, verbose_name='تماشا شده'), + ), + ] diff --git a/backend/account/migrations/0017_securitybreachattemptmodel_created_at_and_more.py b/backend/account/migrations/0017_securitybreachattemptmodel_created_at_and_more.py new file mode 100644 index 0000000..ff75286 --- /dev/null +++ b/backend/account/migrations/0017_securitybreachattemptmodel_created_at_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.2 on 2025-02-20 21:35 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0016_securitybreachattemptmodel_viewd'), + ] + + operations = [ + migrations.AddField( + model_name='securitybreachattemptmodel', + name='created_at', + field=models.DateField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='securitybreachattemptmodel', + name='trys', + field=models.IntegerField(default=0), + ), + ] diff --git a/backend/account/migrations/0018_alter_securitybreachattemptmodel_created_at_and_more.py b/backend/account/migrations/0018_alter_securitybreachattemptmodel_created_at_and_more.py new file mode 100644 index 0000000..79be7eb --- /dev/null +++ b/backend/account/migrations/0018_alter_securitybreachattemptmodel_created_at_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-02-20 21:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0017_securitybreachattemptmodel_created_at_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='securitybreachattemptmodel', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='شروع حمله'), + ), + migrations.AlterField( + model_name='securitybreachattemptmodel', + name='trys', + field=models.IntegerField(default=0, verbose_name='تعداد تلاش ها'), + ), + ] diff --git a/backend/account/migrations/0019_alter_securitybreachattemptmodel_ip.py b/backend/account/migrations/0019_alter_securitybreachattemptmodel_ip.py new file mode 100644 index 0000000..000e252 --- /dev/null +++ b/backend/account/migrations/0019_alter_securitybreachattemptmodel_ip.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-02-20 22:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0018_alter_securitybreachattemptmodel_created_at_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='securitybreachattemptmodel', + name='ip', + field=models.CharField(max_length=100, unique=True, verbose_name='آدرس آی\u200cپی'), + ), + ] diff --git a/backend/account/migrations/0020_remove_securitybreachattemptmodel_ip.py b/backend/account/migrations/0020_remove_securitybreachattemptmodel_ip.py new file mode 100644 index 0000000..970aee9 --- /dev/null +++ b/backend/account/migrations/0020_remove_securitybreachattemptmodel_ip.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.2 on 2025-02-20 22:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0019_alter_securitybreachattemptmodel_ip'), + ] + + operations = [ + migrations.RemoveField( + model_name='securitybreachattemptmodel', + name='ip', + ), + ] diff --git a/backend/account/migrations/0021_securitybreachattemptmodel_ip.py b/backend/account/migrations/0021_securitybreachattemptmodel_ip.py new file mode 100644 index 0000000..b9d0509 --- /dev/null +++ b/backend/account/migrations/0021_securitybreachattemptmodel_ip.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.2 on 2025-02-20 22:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0020_remove_securitybreachattemptmodel_ip'), + ] + + operations = [ + migrations.AddField( + model_name='securitybreachattemptmodel', + name='ip', + field=models.CharField(default='', max_length=100, verbose_name='آدرس آی\u200cپی'), + preserve_default=False, + ), + ] diff --git a/backend/account/migrations/0022_remove_securitybreachattemptmodel_ip_and_more.py b/backend/account/migrations/0022_remove_securitybreachattemptmodel_ip_and_more.py new file mode 100644 index 0000000..0774469 --- /dev/null +++ b/backend/account/migrations/0022_remove_securitybreachattemptmodel_ip_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-02-20 22:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0021_securitybreachattemptmodel_ip'), + ] + + operations = [ + migrations.RemoveField( + model_name='securitybreachattemptmodel', + name='ip', + ), + migrations.AddField( + model_name='securitybreachattemptmodel', + name='ip_address', + field=models.CharField(default='', max_length=100, verbose_name='آدرس آی\u200cپی'), + preserve_default=False, + ), + ] diff --git a/backend/account/migrations/0023_alter_securitybreachattemptmodel_city_and_more.py b/backend/account/migrations/0023_alter_securitybreachattemptmodel_city_and_more.py new file mode 100644 index 0000000..c078b0d --- /dev/null +++ b/backend/account/migrations/0023_alter_securitybreachattemptmodel_city_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.1.2 on 2025-02-20 22:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0022_remove_securitybreachattemptmodel_ip_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='securitybreachattemptmodel', + name='city', + field=models.CharField(blank=True, max_length=103, null=True, verbose_name='شهر'), + ), + migrations.AlterField( + model_name='securitybreachattemptmodel', + name='country', + field=models.CharField(blank=True, max_length=101, null=True, verbose_name='کشور'), + ), + migrations.AlterField( + model_name='securitybreachattemptmodel', + name='isp', + field=models.CharField(blank=True, max_length=107, null=True, verbose_name='ارائه\u200cدهنده اینترنت (ISP)'), + ), + migrations.AlterField( + model_name='securitybreachattemptmodel', + name='lat', + field=models.CharField(blank=True, max_length=106, null=True, verbose_name='عرض جغرافیایی'), + ), + migrations.AlterField( + model_name='securitybreachattemptmodel', + name='lon', + field=models.CharField(blank=True, max_length=105, null=True, verbose_name='طول جغرافیایی'), + ), + migrations.AlterField( + model_name='securitybreachattemptmodel', + name='region_name', + field=models.CharField(blank=True, max_length=102, null=True, verbose_name='منطقه'), + ), + migrations.AlterField( + model_name='securitybreachattemptmodel', + name='zip_code', + field=models.CharField(blank=True, max_length=104, null=True, verbose_name='کد پستی'), + ), + ] diff --git a/backend/account/models.py b/backend/account/models.py index 6512684..73a1df3 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -8,6 +8,7 @@ from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, Ou import hashlib from django.contrib import admin from django.conf import settings +import requests class UserManager(BaseUserManager): def create_user(self, phone, password=None): if not phone: @@ -198,4 +199,46 @@ class PushSubscription(models.Model): } ) except WebPushException as ex: - print(f"Failed to send notification to {sub.user}:", ex) \ No newline at end of file + print(f"Failed to send notification to {sub.user}:", ex) + + + +def get_location_from_ip(ip_address): + try: + response = requests.get(f"http://ip-api.com/json/{ip_address}") + data = response.json() + 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"]) + return None + except Exception as e: + print(f"An error occurred: {e}") + return None + +class SecurityBreachAttemptModel(models.Model): + ip_address = models.CharField(max_length=100, verbose_name="آدرس آی‌پی") + country = models.CharField(max_length=101, verbose_name="کشور", blank=True, null=True) + region_name = models.CharField(max_length=102, verbose_name="منطقه", blank=True, null=True) + city = models.CharField(max_length=103, verbose_name="شهر", blank=True, null=True) + zip_code = models.CharField(max_length=104, verbose_name="کد پستی", blank=True, null=True) + lon = models.CharField(max_length=105, verbose_name="طول جغرافیایی", blank=True, null=True) + lat = models.CharField(max_length=106, verbose_name="عرض جغرافیایی", blank=True, null=True) + isp = models.CharField(max_length=107, verbose_name="ارائه‌دهنده اینترنت (ISP)", blank=True, null=True) + viewd = models.BooleanField(default=False, verbose_name='تماشا شده') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='شروع حمله') + trys = models.IntegerField(default=0, verbose_name='تعداد تلاش ها') + + def save(self, *args, **kwargs): + if not self.id: + location_data = get_location_from_ip(self.ip_address) + if location_data: + self.country, self.region_name, self.city, self.zip_code, self.lat, self.lon, self.isp = location_data + + super().save(*args, **kwargs) + + def __str__(self): + return f'تلاش نفوذ از {self.ip_address} در {self.city}, {self.country}' + class Meta: + verbose_name = "تلاش نفوذ" + verbose_name_plural = "تلاش‌های نفوذ" \ No newline at end of file diff --git a/backend/account/urls.py b/backend/account/urls.py index c5ebbf4..efdc99e 100644 --- a/backend/account/urls.py +++ b/backend/account/urls.py @@ -12,5 +12,6 @@ urlpatterns = [ path('address/delete/', views.DeleteAddressView.as_view(), name='delete-address'), path('address/list', views.GetUserAddressesView.as_view(), name='list-addresses'), path('address/', views.GetIDUserAddressView.as_view(), name='get-ID-address'), - path('subscribe', views.SubscribeView.as_view(), name='subscibe') + path('subscribe', views.SubscribeView.as_view(), name='subscibe'), + path('attack/view/', views.ChangeViewAttack.as_view(), name='attack-view'), ] \ No newline at end of file diff --git a/backend/account/views.py b/backend/account/views.py index 69e81dd..4b95a25 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -3,13 +3,14 @@ from rest_framework.views import APIView from rest_framework import generics, permissions, status from rest_framework.response import Response from .serializers import * -from .models import UserAddressModel, User +from .models import UserAddressModel, User, SecurityBreachAttemptModel from rest_framework.permissions import IsAuthenticated, AllowAny from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework_simplejwt.views import TokenObtainPairView -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, redirect from rest_framework_simplejwt.tokens import RefreshToken import ghasedak_sms +from django.views import View # this works only need to be used # class APIView(APIView): # def __init__(self, *args, **kwargs): @@ -52,9 +53,9 @@ class SendOTPView(APIView): ghasedak_sms.SendSingleSmsInput( message=message, receptor=phone, - line_number='90002930', + line_number='50001212124889', send_date='', - client_reference_id='' + client_reference_id=str(user.pk) ) ) @@ -64,6 +65,7 @@ class SendOTPView(APIView): return Response({'detail': 'OTP sent successfully'}, status=status.HTTP_200_OK) else: print('remmber to remove #TODO') + print(response) return Response({'detail': f'OTP sent successfully {otp}'}, status=status.HTTP_200_OK) # return Response({'detail': response, 'otp_code': otp}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -185,4 +187,12 @@ class SubscribeView(APIView): defaults=(push_ser.validated_data) ) return Response(status=status.HTTP_201_CREATED) - return Response(status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(status=status.HTTP_400_BAD_REQUEST) + + +class ChangeViewAttack(View): + def get(self, request, pk): + attack = get_object_or_404(SecurityBreachAttemptModel, pk=pk) + attack.viewd = not attack.viewd + attack.save() + return redirect('admin:account_securitybreachattemptmodel_changelist') \ No newline at end of file diff --git a/backend/core/settings/unfold_conf.py b/backend/core/settings/unfold_conf.py index 934d808..0577431 100644 --- a/backend/core/settings/unfold_conf.py +++ b/backend/core/settings/unfold_conf.py @@ -209,6 +209,12 @@ UNFOLD = { "icon": "contact_mail", "link": reverse_lazy("admin:account_useraddressmodel_changelist"), }, + { + "title": _("تلاش‌های نفوذ"), + "icon": "gpp_maybe", + "link": reverse_lazy("admin:account_securitybreachattemptmodel_changelist"), + "badge": 'utils.admin.new_attck_count' + }, ], }, diff --git a/backend/core/views.py b/backend/core/views.py index d673079..ee1b6d0 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -9,7 +9,7 @@ from django.views.generic import RedirectView, TemplateView from unfold.views import UnfoldModelAdminViewMixin from order.models import OrderModel from ticket.models import Ticket - +from account.models import SecurityBreachAttemptModel import json @@ -267,20 +267,27 @@ class FakeAdminLoginView(View): return context def get(self, request): - # Log empty attempt (optional) - ip = request.META.get("REMOTE_ADDR") - print(f"Honeypot page accessed from IP: {ip}") - + 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") + print(ip) + print(len(ip)) + print(type(ip)) + hacker, created = SecurityBreachAttemptModel.objects.get_or_create(ip_address=ip) return render(request, 'admin/fake_login.html', self.get_context(request)) def post(self, request): - username = request.POST.get("username") - password = request.POST.get("password") # Never actually used - ip = request.META.get("REMOTE_ADDR") - - print(f"Honeypot triggered! IP: {ip}, Username: {username}") + 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") + print(ip) + hacker, created = SecurityBreachAttemptModel.objects.get_or_create(ip_address=ip) + hacker.trys += 1 + hacker.save() messages.error(request, "Please correct the error below.") messages.error(request, "Please enter the correct شماره تماس and password for a staff account. Note that both fields may be case-sensitive.") - - # Redirect back to fake login page with context return render(request, 'admin/fake_login.html', self.get_context(request)) \ No newline at end of file diff --git a/backend/product/admin.py b/backend/product/admin.py index 3cfe104..11359e1 100644 --- a/backend/product/admin.py +++ b/backend/product/admin.py @@ -10,12 +10,6 @@ from unfold.widgets import UnfoldAdminColorInputWidget from unfold.decorators import action, display from utils.admin import ModelAdmin -@admin.register(ProductVariant) -class ProductVariantAdmin(ModelAdmin, ImportExportModelAdmin): - import_form_class = ImportForm - export_form_class = ExportForm - autocomplete_fields = ['product_attributes', 'images', 'in_pack_items'] - warn_unsaved_form = True @@ -48,6 +42,15 @@ class InPackItemsAdmin(ModelAdmin, ImportExportModelAdmin): } +class AttributeValueInLine(StackedInline): + model = AttributeValue + extra = 0 + show_change_link = True + min_num = 1 + # autocomplete_fields = ['product_attributes', 'in_pack_items', 'images'] + # search_fields = [''] + + @admin.register(AttributeType) class AttributeTypeAdmin(ModelAdmin, ImportExportModelAdmin): import_form_class = ImportForm @@ -55,7 +58,7 @@ class AttributeTypeAdmin(ModelAdmin, ImportExportModelAdmin): search_fields = ['name'] compressed_fields = True warn_unsaved_form = True - + inlines = [AttributeValueInLine] formfield_overrides = { ArrayField: { "widget": ArrayWidget, @@ -98,20 +101,6 @@ class ProductImagesAdmin(ModelAdmin): -class ProductVariantInLine(StackedInline): - model = ProductVariant - extra = 0 - show_change_link = True - tab = True - min_num = 1 - autocomplete_fields = ['product_attributes', 'in_pack_items', 'images'] - # search_fields = [''] - - - def formfield_for_dbfield(self, db_field, request, **kwargs): - if db_field.name == 'color': - kwargs['widget'] = UnfoldAdminColorInputWidget() - return super().formfield_for_dbfield(db_field, request, **kwargs) @@ -129,24 +118,51 @@ class DetailModelAdmin(ModelAdmin, ImportExportModelAdmin): "widget": ArrayWidget, } } +@admin.register(ProductDetailModel) +class ProductDetailModel1Admin(ModelAdmin, ImportExportModelAdmin): + import_form_class = ImportForm + export_form_class = ExportForm + search_fields = ['detail_category__title'] + compressed_fields = True + warn_unsaved_form = True + + formfield_overrides = { + ArrayField: { + "widget": ArrayWidget, + } + } - -class DetailModelInLine(TabularInline): - model = ProductDetailModel +class ProductVariantInLine(StackedInline): + model = ProductVariant extra = 0 - fields = ['detail', 'detail_category'] show_change_link = True - autocomplete_fields = ['detail', 'detail_category'] - + tab = True + min_num = 1 + # inlines = [DetailModelInLine] + autocomplete_fields = ['product_attributes', 'in_pack_items', 'images', 'details'] + # search_fields = [''] + + + def formfield_for_dbfield(self, db_field, request, **kwargs): + if db_field.name == 'color': + kwargs['widget'] = UnfoldAdminColorInputWidget() + return super().formfield_for_dbfield(db_field, request, **kwargs) +@admin.register(ProductVariant) +class ProductVariantAdmin(ModelAdmin, ImportExportModelAdmin): + import_form_class = ImportForm + export_form_class = ExportForm + autocomplete_fields = ['product_attributes', 'images', 'in_pack_items', 'details'] + warn_unsaved_form = True + # inlines = [DetailModelInLine] @admin.register(ProductModel) class ProductModelAdmin(ModelAdmin, ImportExportModelAdmin): import_form_class = ImportForm export_form_class = ExportForm - inlines = [ProductVariantInLine, DetailModelInLine] + inlines = [ProductVariantInLine] readonly_fields = ('slug', ) search_fields = ['name', 'description', ] list_filter = ['show', 'category'] diff --git a/backend/product/migrations/0031_alter_productdetailmodel_product.py b/backend/product/migrations/0031_alter_productdetailmodel_product.py new file mode 100644 index 0000000..55fb8cd --- /dev/null +++ b/backend/product/migrations/0031_alter_productdetailmodel_product.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.2 on 2025-02-18 19:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0030_rename_attributes_productvariant_product_attributes_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='productdetailmodel', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='details', to='product.productvariant', verbose_name='محصول مرتبط'), + ), + ] diff --git a/backend/product/migrations/0032_alter_productdetailmodel_unique_together_and_more.py b/backend/product/migrations/0032_alter_productdetailmodel_unique_together_and_more.py new file mode 100644 index 0000000..824bc49 --- /dev/null +++ b/backend/product/migrations/0032_alter_productdetailmodel_unique_together_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.2 on 2025-02-18 20:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0031_alter_productdetailmodel_product'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='productdetailmodel', + unique_together=set(), + ), + migrations.AddField( + model_name='productvariant', + name='details', + field=models.ManyToManyField(related_name='product', to='product.productdetailmodel', verbose_name='محصول مرتبط'), + ), + migrations.RemoveField( + model_name='productdetailmodel', + name='product', + ), + ] diff --git a/backend/product/migrations/0033_alter_productvariant_details.py b/backend/product/migrations/0033_alter_productvariant_details.py new file mode 100644 index 0000000..3080f15 --- /dev/null +++ b/backend/product/migrations/0033_alter_productvariant_details.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-02-18 20:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0032_alter_productdetailmodel_unique_together_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='productvariant', + name='details', + field=models.ManyToManyField(related_name='product', to='product.productdetailmodel', verbose_name='جزيیات محصول'), + ), + ] diff --git a/backend/product/models.py b/backend/product/models.py index 1cf00fa..de8aca0 100644 --- a/backend/product/models.py +++ b/backend/product/models.py @@ -152,17 +152,6 @@ class ProductDetailCategory(models.Model): verbose_name = 'دسته بندی جزيات' verbose_name_plural = 'دسته بندی های جزيیات' -class ProductDetailModel(models.Model): - product = models.ForeignKey(ProductModel, on_delete=models.CASCADE, verbose_name='محصول مرتبط', related_name='details') - detail_category = models.ForeignKey(ProductDetailCategory, on_delete=models.CASCADE, verbose_name='دسته بندی جزيات', blank=True, null=True) - detail = models.ManyToManyField(DetailModel, verbose_name='جزيات ها') - - class Meta: - verbose_name = 'جزیات محصول' - verbose_name_plural = 'جزیات محصول ها' - unique_together = ('product', 'detail_category') - def __str__(self): - return f'جزيیات محصول {self.product}' @@ -216,6 +205,15 @@ class ProductImageModel(models.Model): verbose_name_plural = 'عکس های محصولات' +class ProductDetailModel(models.Model): + detail_category = models.ForeignKey(ProductDetailCategory, on_delete=models.CASCADE, verbose_name='دسته بندی جزيات', blank=True, null=True) + detail = models.ManyToManyField(DetailModel, verbose_name='جزيات ها') + + class Meta: + verbose_name = 'جزیات محصول' + verbose_name_plural = 'جزیات محصول ها' + # def __str__(self): + # return f'جزيیات محصول {self.product}' class ProductVariant(models.Model): @@ -237,6 +235,7 @@ class ProductVariant(models.Model): 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') class Meta: verbose_name = 'تنوع محصول' verbose_name_plural = 'تنوع‌های محصول' @@ -262,4 +261,4 @@ class ProductVariant(models.Model): return toman_price def get_toman_price_after_discount(self): - return self.get_toman_price() * ((100 - self.discount) / 100) \ No newline at end of file + return self.get_toman_price() * ((100 - self.discount) / 100) diff --git a/backend/product/serializers.py b/backend/product/serializers.py index 3794822..6a1320a 100644 --- a/backend/product/serializers.py +++ b/backend/product/serializers.py @@ -14,9 +14,10 @@ class DetailSerializer(serializers.ModelSerializer): class ProductDetailSerializer(serializers.ModelSerializer): detail = DetailSerializer(many=True, read_only=True) + detail_category = serializers.StringRelatedField() class Meta: model = ProductDetailModel - exclude = ('product',) + fields = "__all__" class AttributeTypeSerialzier(serializers.ModelSerializer): @@ -48,6 +49,7 @@ class ProductVariantSerialzier(serializers.ModelSerializer): 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') @@ -66,14 +68,31 @@ class ProductVariantSerialzier(serializers.ModelSerializer): +class SubCategorySerializer(serializers.ModelSerializer): + product_count = serializers.SerializerMethodField() + parent = serializers.SerializerMethodField() + class Meta: + model = SubCategoryModel + fields = ['id', 'name', 'slug','icon', 'meta_title', 'meta_description', 'product_count', 'show', 'parent', 'image'] + def get_product_count(self, obj): + return obj.products.count() + def get_parent(self, obj): + return obj.parent.name + + +class MainCategorySerializer(serializers.ModelSerializer): + subcategorys = SubCategorySerializer(many=True) + class Meta: + model = MainCategoryModel + fields = ['id', 'name', 'slug', 'icon', 'meta_title', 'meta_description', 'subcategorys'] class DynamicProductSerializer(serializers.ModelSerializer): variants = serializers.SerializerMethodField() - # variants_colors = serializers.SerializerMethodField() + colors = serializers.SerializerMethodField() + category = SubCategorySerializer(read_only=True) is_new = serializers.SerializerMethodField() related_products = serializers.SerializerMethodField() - details = ProductDetailSerializer(many=True, read_only=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -91,18 +110,21 @@ class DynamicProductSerializer(serializers.ModelSerializer): model = ProductModel fields = "__all__" view_type = { - 'list': ['name', 'rating', 'slug', 'category', 'variants'], - 'instance': ['name', 'description', 'rating', 'slug', 'meta_description', 'meta_keywords', 'meta_rating', 'category', 'related_products', 'details', 'in_pack_items', 'variants'], - 'chat': ['name', 'description', 'variants'] + 'list': ['id','name', 'rating', 'slug', 'category', 'variants', 'colors'], + 'instance': ['id', 'name', 'description', 'rating', 'slug', 'meta_description', 'meta_keywords', 'meta_rating', 'category', 'related_products', 'in_pack_items', 'variants', 'colors'], + 'chat': ['id', 'name', 'description', 'variants'] } def get_variants(self, obj): - return ProductVariantSerialzier(instance=obj.variants.all(), many=True, context=self.context).data + varients = obj.variants.all() + colors = set(varient.color for varient in varients) + return ProductVariantSerialzier(instance=varients, many=True, context=self.context).data - # def get_variants_colors(self, obj): - # varients = obj.variants.all() - # attributes = AttributeValue.objects.filter(variant__in=varients) - # return AttributeValueForProductListSerialzier(instance=attributes, many=True, context=self.context).data + + def get_colors(self, obj): + varients = obj.variants.all() + colors = list(set(varient.color for varient in varients)) + return colors def get_is_new(self, obj): @@ -130,20 +152,3 @@ class CommentSerializer(serializers.ModelSerializer): exclude = ('review_status', ) read_only_fields = ('review_status', 'product', 'user') -class SubCategorySerializer(serializers.ModelSerializer): - product_count = serializers.SerializerMethodField() - parent = serializers.SerializerMethodField() - class Meta: - model = SubCategoryModel - fields = ['id', 'name', 'slug','icon', 'meta_title', 'meta_description', 'product_count', 'show', 'parent', 'image'] - def get_product_count(self, obj): - return obj.products.count() - def get_parent(self, obj): - return obj.parent.name - - -class MainCategorySerializer(serializers.ModelSerializer): - subcategorys = SubCategorySerializer(many=True) - class Meta: - model = MainCategoryModel - fields = ['id', 'name', 'slug', 'icon', 'meta_title', 'meta_description', 'subcategorys'] diff --git a/backend/requirements.txt b/backend/requirements.txt index 51240cc..ca6be94 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,7 @@ annotated-types==0.7.0 anyio==4.6.0 asgiref==3.8.1 attrs==24.2.0 +branca==0.8.1 certifi==2024.8.30 cffi==1.17.1 charset-normalizer==3.3.2 @@ -29,6 +30,7 @@ drf-spectacular==0.27.2 email_validator==2.2.0 factory_boy==3.3.1 Faker==28.4.1 +folium==0.19.4 frozenlist==1.4.1 geoip2==4.8.0 ghasedak_sms==1.0.3 @@ -43,11 +45,14 @@ idna==3.10 inflection==0.5.1 jalali_core==1.0.0 jdatetime==5.0.0 +Jinja2==3.1.5 jiter==0.8.2 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 +MarkupSafe==3.0.2 maxminddb==2.6.2 multidict==6.1.0 +numpy==2.2.3 oauthlib==3.2.2 openai==1.58.1 pillow==10.4.0 @@ -83,4 +88,5 @@ tzdata==2024.1 uritemplate==4.1.1 urllib3==2.2.3 whitenoise==6.7.0 +xyzservices==2025.1.0 yarl==1.11.1 diff --git a/backend/templates/loction_chagne_form.html b/backend/templates/loction_chagne_form.html new file mode 100644 index 0000000..78f2d31 --- /dev/null +++ b/backend/templates/loction_chagne_form.html @@ -0,0 +1,187 @@ +{% extends "admin/base_site.html" %} +{% load unfold %} +{% load i18n admin_urls static admin_modify %} + +{% block extrahead %}{{ block.super }} + + {{ media }} + +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %} + +{% if not is_popup %} + {% block breadcrumbs %} +
+
+
    + {% url 'admin:index' as link %} + {% trans 'Home' as name %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=name %} + + {% url 'admin:app_list' app_label=opts.app_label as link %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=opts.app_config.verbose_name %} + + {% if has_view_permission %} + {% url opts|admin_urlname:'changelist' as link %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=opts.verbose_name_plural|capfirst %} + {% else %} + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=opts.verbose_name_plural|capfirst %} + {% endif %} + + {% if add %} + {% blocktranslate trimmed with name=opts.verbose_name asvar breadcrumb_name %} + Add {{ name }} + {% endblocktranslate %} + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=breadcrumb_name %} + {% else %} + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=original|truncatewords:'18' %} + {% endif %} +
+
+
+ {% endblock %} +{% endif %} + + + +{% block nav-global-side %} + {% if has_add_permission %} + {% include "unfold/helpers/add_link.html" %} + {% endif %} +{% endblock %} + +{% block content %} +
+ {% block form_before %}{% endblock %} + {% if adminform.model_admin.change_form_outer_before_template %} + {% include adminform.model_admin.change_form_outer_before_template %} + {% endif %} + +
+ {% if original.lon and original.lat %} +
+
+ {% if original.country %} + + 🇺🇳 {{ original.country }} + + {% endif %} + + {% if original.region_name %} + + + {{ original.region_name }} + + {% endif %} + + {% if original.city %} + + + {{ original.city }} + + {% endif %} + + {% if original.zip_code %} + + + {{ original.zip_code }} + + {% endif %} + + {% if original.isp %} + + + {{ original.isp }} + + {% endif %} + {% if original.ip_address %} + + + {{ original.ip_address }} + + {% endif %} +
+
+ + {% if map_html %} + +
+
+ {{ map_html|safe }} +
+
+ {% else %} +

نقشه در دسترس نیست

+ {% endif %} + {% else %} +

موقعیت جغرافیایی موجود نیست

+ {% endif %} +
+ + {% if adminform.model_admin.change_form_outer_after_template %} + {% include adminform.model_admin.change_form_outer_after_template %} + {% endif %} + + {% block form_after %}{% endblock %} +
+{% endblock %} + +{% block extrajs %} + + +{% endblock %} \ No newline at end of file diff --git a/backend/ticket/migrations/0011_ticket_content.py b/backend/ticket/migrations/0011_ticket_content.py new file mode 100644 index 0000000..afcea91 --- /dev/null +++ b/backend/ticket/migrations/0011_ticket_content.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.2 on 2025-02-21 17:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ticket', '0010_alter_message_created_at_alter_ticket_created_at_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='ticket', + name='content', + field=models.TextField(default='', verbose_name='جزيیات تیکت'), + preserve_default=False, + ), + ] diff --git a/backend/ticket/migrations/0012_alter_ticket_status.py b/backend/ticket/migrations/0012_alter_ticket_status.py new file mode 100644 index 0000000..817c709 --- /dev/null +++ b/backend/ticket/migrations/0012_alter_ticket_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-02-21 18:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ticket', '0011_ticket_content'), + ] + + operations = [ + migrations.AlterField( + model_name='ticket', + name='status', + field=models.CharField(choices=[('in_progress', 'در حال پردازش'), ('resolved', 'حل شده'), ('closed', 'بسته')], default='open', max_length=20, verbose_name='وضعیت تیکت'), + ), + ] diff --git a/backend/ticket/models.py b/backend/ticket/models.py index b59682b..edfd231 100644 --- a/backend/ticket/models.py +++ b/backend/ticket/models.py @@ -6,7 +6,6 @@ from django_jalali.db import models as jmodels class Ticket(models.Model): objects = jmodels.jManager() STATUS_CHOICES = [ - ('open', 'باز'), ('in_progress', 'در حال پردازش'), ('resolved', 'حل شده'), ('closed', 'بسته'), @@ -21,6 +20,7 @@ class Ticket(models.Model): ('other', 'سایر'), ] subject = models.CharField(max_length=255, verbose_name='موضوع') + content = models.TextField(verbose_name='جزيیات تیکت') ticket_category = models.CharField(max_length=30, verbose_name='دسته بندی تیکت', choices=CATEGORY_CHOICES) customer = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tickets", verbose_name='کاربر') admin = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_tickets", verbose_name='ادمین') diff --git a/backend/ticket/serializers.py b/backend/ticket/serializers.py index 362d18e..5c7c24f 100644 --- a/backend/ticket/serializers.py +++ b/backend/ticket/serializers.py @@ -13,4 +13,11 @@ class TicketSerializer(serializers.ModelSerializer): class Meta: model = Ticket exclude = ('customer', ) - read_only_fields = ('status', 'admin', ) \ No newline at end of file + read_only_fields = ('status', 'admin', ) + + +class TicketListSerializer(serializers.ModelSerializer): + class Meta: + model = Ticket + exclude = ('customer', 'admin', 'order', 'content') + read_only_fields = ('status',) \ No newline at end of file diff --git a/backend/ticket/urls.py b/backend/ticket/urls.py index 79730e1..7ad437e 100644 --- a/backend/ticket/urls.py +++ b/backend/ticket/urls.py @@ -11,6 +11,5 @@ urlpatterns = [ path('create', TicketCreateView.as_view(), name='ticket-create'), path('', TicketListView.as_view(), name='ticket-list'), path('', TicketDetailView.as_view(), name='ticket-detail'), - path('/messages', MessageCreateView.as_view(), name='message-create'), - path('/update-status', UpdateTicketStatusView.as_view(), name='update-ticket-status'), + path('message/', MessageCreateView.as_view(), name='message-create'), ] \ No newline at end of file diff --git a/backend/ticket/views.py b/backend/ticket/views.py index 4a5c0f3..d8a5e10 100644 --- a/backend/ticket/views.py +++ b/backend/ticket/views.py @@ -2,7 +2,9 @@ from rest_framework import generics, permissions from rest_framework.response import Response from rest_framework.views import APIView from .models import Ticket, Message -from .serializers import TicketSerializer, MessageSerializer +from .serializers import TicketListSerializer, MessageSerializer, TicketSerializer +from utils.pagination import StructurePagination +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes class TicketCreateView(generics.CreateAPIView): queryset = Ticket.objects.all() @@ -12,16 +14,65 @@ class TicketCreateView(generics.CreateAPIView): def perform_create(self, serializer): serializer.save(customer=self.request.user) + -class TicketListView(generics.ListAPIView): - serializer_class = TicketSerializer +class TicketListView(APIView): + serializer_class = TicketListSerializer permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - user = self.request.user - if user.is_staff: - return Ticket.objects.all() - return Ticket.objects.filter(customer=user) + pagination_class = StructurePagination + @extend_schema( + parameters=[ + OpenApiParameter( + name="limit", + description="لیمیتش", + required=False, + type=OpenApiTypes.INT, + ), + OpenApiParameter( + name="offset", + description="افستش", + required=False, + type=OpenApiTypes.INT, + ), + OpenApiParameter( + name="filter", + description=( + "filter results by one of the following fields:\n" + "`in_progress`, `closed`, `resolved`." + ), + required=False, + type=OpenApiTypes.STR, + ), + OpenApiParameter( + name="sort", + description=( + "Sort results by one of the following fields:\n" + " `created_at`, `-created_at`." + "\nPrefix with `-` for descending order." + ), + required=False, + type=OpenApiTypes.STR, + ), + ], + responses={ + 200: TicketListSerializer(many=True), + 404: OpenApiTypes.OBJECT, + }, + ) + def get(self, request): + tickets = Ticket.objects.filter(customer=request.user) + filter_by = request.query_params.get('filter', None) + sort = request.query_params.get('sort', None) + if filter_by: + tickets.filter(status=str(filter_by)) + if sort: + if sort not in ['created_at', '-created_at']: + return Response({'detail': 'wrong sort paramter'}, status=status.HTTP_400_BAD_REQUEST) + tickets.order_by(sort) + paginator = self.pagination_class() + paginated_tickets = paginator.paginate_queryset(tickets, request) + tickets_ser = self.serializer_class(instance=paginated_tickets, many=True, context={'request': request}) + return paginator.get_paginated_response(tickets_ser.data) class TicketDetailView(generics.RetrieveAPIView): diff --git a/backend/utils/admin.py b/backend/utils/admin.py index a86239c..1107fab 100644 --- a/backend/utils/admin.py +++ b/backend/utils/admin.py @@ -2,6 +2,7 @@ from order.models import OrderModel from product.models import DollorModel, CommentModel from ticket.models import Ticket from home.models import LearnVideoModel +from account.models import SecurityBreachAttemptModel def admin_pending_count(request): pending_count = OrderModel.objects.filter(status='ADMIN_PENDING').count() @@ -20,6 +21,8 @@ def new_ticket_count(request): def new_learn_video_count(request): return LearnVideoModel.objects.filter(viewd=False).count() +def new_attck_count(request): + return SecurityBreachAttemptModel.objects.filter(viewd=False).count() from django.contrib import admin, messages from unfold.admin import ModelAdmin diff --git a/frontend/app.vue b/frontend/app.vue index b08570b..8f275a0 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -4,21 +4,23 @@ import { VueQueryDevtools } from "@tanstack/vue-query-devtools"; diff --git a/frontend/components/global/Brands.vue b/frontend/components/global/Brands.vue index 515bd7f..135fa84 100644 --- a/frontend/components/global/Brands.vue +++ b/frontend/components/global/Brands.vue @@ -12,7 +12,7 @@ const {} = toRefs(props);