diff --git a/backend/.env.local b/backend/.env.local index 657f88f..e0f9789 100644 --- a/backend/.env.local +++ b/backend/.env.local @@ -28,4 +28,6 @@ REFRESH_TOKEN_LIFETIME = 5000 SMS_API_KEY = '' -VAPID_PRIVATE_KEY = 'NajogmGTsGsZ_dfURrjUpgsm5fui-s5AzruBQgMh_I4' \ No newline at end of file +VAPID_PRIVATE_KEY = 'NajogmGTsGsZ_dfURrjUpgsm5fui-s5AzruBQgMh_I4' + +OPENAI_API_KEY = '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' \ No newline at end of file diff --git a/backend/account/migrations/0024_alter_user_birth_date.py b/backend/account/migrations/0024_alter_user_birth_date.py new file mode 100644 index 0000000..ddafd05 --- /dev/null +++ b/backend/account/migrations/0024_alter_user_birth_date.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-02-22 16:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0023_alter_securitybreachattemptmodel_city_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='birth_date', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/backend/account/models.py b/backend/account/models.py index 73a1df3..b270618 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -47,7 +47,7 @@ class User(AbstractBaseUser, PermissionsMixin): ('زن', 'زن') ) gender = models.CharField(choices=gender_option, max_length=20, verbose_name='جنسیت') - birth_date = models.DateField() + birth_date = models.DateField(blank=True, null=True) date_joined = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ثبتنام') otp_hash = models.CharField(max_length=64, null=True, blank=True, verbose_name='کد یک بار مصرف') otp_expiry = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تمام شدن کد یک بار مصرف') diff --git a/backend/account/views.py b/backend/account/views.py index 4b95a25..ad91bc4 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -39,10 +39,10 @@ class SendOTPView(APIView): try: user, created = User.objects.get_or_create(phone=phone) otp = user.set_otp() - message = f"کد یک بار مصرف : {otp}" - - - sms_api = ghasedak_sms.Ghasedak(api_key="4dc844abd4409fe247ec73831aed2498ad3749c1945660cc252654371756b966vafe5d9LGgMbnfGn") + message = f"""به فروشگاه هی ملز خوش اومدی!!❤️🤖 +کد یک بار مصرف شما : +Code: {otp}""" + sms_api = ghasedak_sms.Ghasedak(api_key="1227eaaddcba72bcb0169b37032cf16ae9ac6ed8b3b7c2768b74e2ee351d1b52gyRe3AGomZRPTNEd") # response = sms_api.send_single_sms(ghasedak_sms.SendSingleSmsInput(message=message, receptor=phone, line_number='30005006006908', send_date='', client_reference_id='')) # print(response) @@ -53,8 +53,7 @@ class SendOTPView(APIView): ghasedak_sms.SendSingleSmsInput( message=message, receptor=phone, - line_number='50001212124889', - send_date='', + line_number='30005006004095', client_reference_id=str(user.pk) ) ) @@ -64,9 +63,8 @@ class SendOTPView(APIView): if response['statusCode'] == 200: 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': f'مشکلی در ارسال کد رخ داد'}, status=status.HTTP_200_OK) # return Response({'detail': response, 'otp_code': otp}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except User.DoesNotExist: diff --git a/backend/core/settings/base.py b/backend/core/settings/base.py index 8d4307b..c1d752b 100644 --- a/backend/core/settings/base.py +++ b/backend/core/settings/base.py @@ -1,5 +1,4 @@ from dotenv import load_dotenv -# from http.cookiejar import debug from pathlib import Path from datetime import timedelta import os @@ -7,15 +6,21 @@ from django.templatetags.static import static from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ +# Load environment variables load_dotenv(".env.local") +# ============================================================================== +# General Configuration +# ============================================================================== DOMAIN = os.getenv("DOMAIN") API_DOMAIN = os.getenv("API_DOMAIN") -OPENAI_API_KEY = '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' -# TODO update telegram bot token -TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") -# TODO update email bullshit +# API Keys and Tokens +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY") + +# Email Configuration EMAIL_BACKEND = os.getenv("EMAIL_BACKEND") EMAIL_HOST = os.getenv("EMAIL_HOST") EMAIL_PORT = 587 @@ -24,17 +29,16 @@ EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") DEFAULT_FROM_EMAIL = os.getenv("SECRET_KEY") +# Security and Debugging SECRET_KEY = os.getenv("SECRET_KEY") DEBUG = True BASE_DIR = Path(__file__).resolve().parent.parent.parent - -VAPID_PRIVATE_KEY = os.getenv('VAPID_PRIVATE_KEY') - -# Application definition - +# ============================================================================== +# Application Definition +# ============================================================================== INSTALLED_APPS = [ - # unfold theme + # Unfold Theme "unfold", "unfold.contrib.filters", "unfold.contrib.forms", @@ -42,37 +46,159 @@ INSTALLED_APPS = [ "unfold.contrib.import_export", "unfold.contrib.guardian", "unfold.contrib.simple_history", - # django - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - # thired party apps - 'corsheaders', - 'rest_framework', - 'drf_spectacular', - 'django_cleanup.apps.CleanupConfig', - 'django_filters', - 'rest_framework_simplejwt', - 'rest_framework_simplejwt.token_blacklist', - 'rest_framework.authtoken', - 'import_export', + # Django Core + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Third-Party Apps + "storages", + "corsheaders", + "rest_framework", + "drf_spectacular", + "django_cleanup.apps.CleanupConfig", + "django_filters", + "rest_framework_simplejwt", + "rest_framework_simplejwt.token_blacklist", + "rest_framework.authtoken", + "import_export", "django_jalali", - # custom apps - 'product', - 'account', - 'ticket', - 'chat', - 'order', - 'home', - 'blog', - + # Custom Apps + "product", + "account", + "ticket", + "chat", + "order", + "home", + "blog", ] +# ============================================================================== +# Middleware Configuration +# ============================================================================== +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "core.urls" + +# ============================================================================== +# Template Configuration +# ============================================================================== +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "core.wsgi.application" + +# ============================================================================== +# Authentication and Password Validation +# ============================================================================== +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +LANGUAGE_CODE = "fa" +TIME_ZONE = "UTC" +USE_I18N = True +USE_L10N = True +USE_TZ = True + +# ============================================================================== +# Static Files Configuration +# ============================================================================== +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "custom_static"), + BASE_DIR / "core" / "static", +] + + + + +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +AUTH_USER_MODEL = "account.User" + +# ============================================================================== +# REST Framework Configuration +# ============================================================================== +REST_FRAMEWORK = { + # "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + # "DEFAULT_PERMISSION_CLASSES": [ + # "rest_framework.permissions.IsAuthenticated", + # ], +} + +# ============================================================================== +# Simple JWT Configuration +# ============================================================================== +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(days=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, +} + +# ============================================================================== +# Spectacular (API Documentation) Configuration +# ============================================================================== +SPECTACULAR_SETTINGS = { + "TITLE": os.getenv("SITE_TITLE"), + "DESCRIPTION": os.getenv("SITE_TITLE"), + "VERSION": "2.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_REQUEST": True, + "SWAGGER_UI_SETTINGS": { + "persistAuthorization": True, + }, +} + +# ============================================================================== +# Persian Datetime Configuration +# ============================================================================== JALALI_SETTINGS = { - # JavaScript static files for the admin Jalali date widget "ADMIN_JS_STATIC_FILES": [ "admin/jquery.ui.datepicker.jalali/scripts/jquery-1.10.2.min.js", "admin/jquery.ui.datepicker.jalali/scripts/jquery.ui.core.js", @@ -81,7 +207,6 @@ JALALI_SETTINGS = { "admin/jquery.ui.datepicker.jalali/scripts/jquery.ui.datepicker-cc-fa.js", "admin/main.js", ], - # CSS static files for the admin Jalali date widget "ADMIN_CSS_STATIC_FILES": { "all": [ "admin/jquery.ui.datepicker.jalali/themes/base/jquery-ui.min.css", @@ -91,113 +216,19 @@ JALALI_SETTINGS = { } -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - "whitenoise.middleware.WhiteNoiseMiddleware", - 'django.contrib.sessions.middleware.SessionMiddleware', - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'core.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / "templates"], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'core.wsgi.application' - - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_TZ = True - - - -STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'custom_static'), - BASE_DIR / "core" / "static" -] - -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -AUTH_USER_MODEL = 'account.User' - - -REST_FRAMEWORK = { - # 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], - 'DEFAULT_RENDERER_CLASSES': [ - 'rest_framework.renderers.JSONRenderer', - 'rest_framework.renderers.BrowsableAPIRenderer', - - ], - 'DEFAULT_AUTHENTICATION_CLASSES': ( - - 'rest_framework_simplejwt.authentication.JWTAuthentication', - - ), - 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', - # 'DEFAULT_PERMISSION_CLASSES': [ - # 'rest_framework.permissions.IsAuthenticated', - # ], +# ============================================================================== +# AWS S3 setting for production +# ============================================================================== +AWS_ACCESS_KEY_ID = 'mtiSN2JWjWgyfr2u' +AWS_SECRET_ACCESS_KEY = 'ZGmOM6ekLJEswJS1kOLp49B8DQ3GT0HZ' +AWS_STORAGE_BUCKET_NAME = 'c262408' +AWS_S3_ENDPOINT_URL = 'https://parspack.net' +AWS_S3_REGION_NAME = 'default' +AWS_S3_SIGNATURE_VERSION = 's3' +AWS_S3_ADDRESSING_STYLE = 'virtual' +AWS_QUERYSTRING_AUTH = True +AWS_DEFAULT_ACL = None +AWS_S3_OBJECT_PARAMETERS = { + 'CacheControl': 'max-age=86400', } -SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), - 'ROTATE_REFRESH_TOKENS': True, - 'BLACKLIST_AFTER_ROTATION': True, -} - -SPECTACULAR_SETTINGS = { - 'TITLE': os.getenv("SITE_TITLE"), - 'DESCRIPTION': os.getenv("SITE_TITLE"), - 'VERSION': '2.0.0', - 'SERVE_INCLUDE_SCHEMA': False, - 'COMPONENT_SPLIT_REQUEST': True -} - - - - -def environment_callback(request): - return ["نسخه ی توسعه", "success"] - - diff --git a/backend/core/settings/production.py b/backend/core/settings/production.py index 4ce04ef..3ec44aa 100644 --- a/backend/core/settings/production.py +++ b/backend/core/settings/production.py @@ -1,5 +1,7 @@ from .base import * from .unfold_conf import * + + ALLOWED_HOSTS = ['127.0.0.1', 'localhost', DOMAIN, API_DOMAIN] CSRF_TRUSTED_ORIGINS = [ f"https://{DOMAIN}", @@ -31,7 +33,16 @@ DATABASES = { } } -MEDIA_URL = '/shop_media/' +STORAGES = { + "default": { + "BACKEND": 'core.storages.MediaStorage', + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} + +MEDIA_URL = 'https://c262408.parspack.net/' MEDIA_ROOT = '/app/media' STATIC_URL = '/shop_static/' diff --git a/backend/core/settings/unfold_conf.py b/backend/core/settings/unfold_conf.py index 0577431..045106b 100644 --- a/backend/core/settings/unfold_conf.py +++ b/backend/core/settings/unfold_conf.py @@ -28,7 +28,9 @@ UNFOLD = { "LOGIN": { "image": lambda request: static("favicon.png"), }, - + "STYLES": [ + lambda request: static("rtl.css"), + ], "BORDER_RADIUS": "20px", "SHOW_HISTORY": True, @@ -80,7 +82,7 @@ UNFOLD = { }, "SIDEBAR": { - "show_search": True, + "show_search": False, "show_all_applications": True, "navigation": [ { @@ -262,4 +264,7 @@ UNFOLD = { ], }, -} \ No newline at end of file +} + +def environment_callback(request): + return ["نسخه ی توسعه", "success"] \ No newline at end of file diff --git a/backend/core/static/rtl.css b/backend/core/static/rtl.css new file mode 100644 index 0000000..3ac4864 --- /dev/null +++ b/backend/core/static/rtl.css @@ -0,0 +1,310 @@ +/* Base RTL adjustments */ +[dir="rtl"] body { + direction: rtl; + text-align: right; +} + +/* Headers and titles */ +[dir="rtl"] .header, +[dir="rtl"] h1, +[dir="rtl"] h2, +[dir="rtl"] h3, +[dir="rtl"] .branding h1 { + text-align: right; +} + +/* Float adjustments */ +[dir="rtl"] .float-left { + float: right !important; +} +[dir="rtl"] .float-right { + float: left !important; +} + +/* Margins and paddings */ +[dir="rtl"] .margin-left-10 { + margin-right: 10px !important; + margin-left: 0 !important; +} +[dir="rtl"] .margin-right-10 { + margin-left: 10px !important; + margin-right: 0 !important; +} +[dir="rtl"] .padding-left-15 { + padding-right: 15px !important; + padding-left: 0 !important; +} +[dir="rtl"] .padding-right-15 { + padding-left: 15px !important; + padding-right: 0 !important; +} + +/* Form elements */ +[dir="rtl"] .aligned label { + padding: 0 0 3px 1em; + float: right !important; + text-align: right; +} +[dir="rtl"] .form-row { + direction: rtl; +} +[dir="rtl"] .form-row .field-box { + float: right; + margin-right: 0; + margin-left: 10px; +} +[dir="rtl"] input, +[dir="rtl"] select, +[dir="rtl"] textarea { + direction: rtl; +} + +/* Buttons and submit row */ +[dir="rtl"] .submit-row { + text-align: left; +} +[dir="rtl"] .submit-row input, +[dir="rtl"] .button { + margin-left: 0; + margin-right: 10px; +} + +/* Inline groups (e.g., tabular or stacked inlines) */ +[dir="rtl"] .inline-group { + direction: rtl; +} +[dir="rtl"] .inline-related h3 { + text-align: right; +} +[dir="rtl"] .inline-related .inline_label { + float: right; + padding-right: 0; + padding-left: 10px; +} + +/* Tables */ +[dir="rtl"] table { + direction: rtl; +} +[dir="rtl"] th, +[dir="rtl"] td { + text-align: right; +} +[dir="rtl"] .sortoptions { + float: left; +} + +/* Navigation and sidebar (Unfold-specific) */ +[dir="rtl"] .unfold-sidebar { + right: unset; + left: 0; +} +[dir="rtl"] .unfold-main { + margin-left: 0; + margin-right: 260px; /* Adjust based on sidebar width */ +} +[dir="rtl"] .unfold-nav { + direction: rtl; + text-align: right; +} +[dir="rtl"] .unfold-nav li { + text-align: right; +} +[dir="rtl"] .unfold-nav .dropdown-menu { + right: unset; + left: 0; +} + +/* Breadcrumbs */ +[dir="rtl"] .breadcrumbs { + direction: rtl; + text-align: right; +} +[dir="rtl"] .breadcrumbs a { + margin-right: 0; + margin-left: 5px; +} + +/* Filters (right sidebar) */ +[dir="rtl"] #changelist-filter { + float: left; + text-align: right; +} +[dir="rtl"] #changelist-filter h3 { + text-align: right; +} +[dir="rtl"] #changelist-filter li { + padding-right: 0; + padding-left: 10px; +} + +/* Miscellaneous */ +[dir="rtl"] .object-tools { + float: left; +} +[dir="rtl"] .paginator { + direction: rtl; + text-align: left; +} + +/* Enhanced RTL adjustments for Unfold navigation links (specific to navbar) */ +[dir="rtl"] .unfold-nav a { + direction: rtl; + justify-content: flex-end; /* Align flex items to the right */ +} + +[dir="rtl"] .unfold-nav .flex { + flex-direction: row-reverse !important; /* Reverse the order of flex items (icon, text, badge) for RTL */ +} + +[dir="rtl"] .unfold-nav .material-symbols-outlined { + margin-right: 0 !important; /* Remove default right margin */ + margin-left: 0.75rem !important; /* Equivalent to Tailwind’s mr-3 in RTL (12px or 0.75rem) */ + order: 2 !important; /* Place icon after text in flex direction */ +} + +[dir="rtl"] .unfold-nav .text-sm { + margin-right: 0.5rem !important; /* Space between text and badge (equivalent to Tailwind ml-2 in RTL) */ + text-align: right; + direction: rtl; + order: 1 !important; /* Place text before badge in flex direction */ +} + +[dir="rtl"] .unfold-nav .bg-red-600 { + margin-left: 0 !important; /* Remove default left margin */ + margin-right: 0 !important; /* No margin needed on right unless spacing is required */ + order: 0 !important; /* Place badge first in flex direction (on the right in RTL) */ +} + +/* Ensure text alignment and direction for Persian */ +[dir="rtl"] .unfold-nav .text-sm { + text-align: right; + direction: rtl; +} + +/* RTL adjustments for navbar headers and expandable sections (including arrow icon) */ +[dir="rtl"] .unfold-nav h2 { + direction: rtl; + text-align: right; +} + +[dir="rtl"] .unfold-nav .flex-row { + flex-direction: row-reverse !important; /* Reverse flex direction for headers */ +} + +[dir="rtl"] .unfold-nav .material-symbols-outlined.ml-auto { + margin-left: 0 !important; /* Remove default left margin (Tailwind ml-auto) */ + margin-right: auto !important; /* Push to the right in RTL */ + transform: rotate(180deg) !important; /* Flip chevron_right for RTL (pointing left) */ + order: 999 !important; /* Ensure it’s the last item in the flex order, on the right */ +} + +/* RTL adjustments for search bar and other navbar elements */ +[dir="rtl"] #nav-filter { + direction: rtl; + text-align: right; +} + +[dir="rtl"] .unfold-nav input[type="search"] { + padding-right: 0.75rem !important; /* Adjust padding for RTL */ + padding-left: 2rem !important; /* Space for the search icon on the left */ +} + +[dir="rtl"] .unfold-nav .material-symbols-outlined.pl-3 { + padding-left: 0 !important; /* Remove padding-left */ + padding-right: 0.75rem !important; /* Add padding-right for RTL */ +} + +[dir="rtl"] .mr-3 { + margin-left: .75rem !important; + margin-right: 0 !important; +} + +/* badge fix */ + +[dir="rtl"] .bg-red-600 { + margin-left: 0rem !important; + margin-right: .5rem !important; +} + +/* colapse fix */ +/* [dir="rtl"] .ml-auto { + margin-left: 0rem !important; + margin-right: 8rem !important; +} */ + + + +[dir="rtl"] .absolute.bottom-0.left-0.rounded.top-0 { + left: auto; + right: 0; +} + + +/* log out fix */ + +[dir="rtl"] nav.absolute.bg-white.border.flex.flex-col.leading-none.py-1.-right-2.rounded.shadow-lg.top-7.w-52.z-50.dark\:bg-base-800.dark\:border-base-700 { + right: auto; /* Remove -right-2 effect */ + left: 0; /* Anchor to right edge (left in RTL) */ +} + + +/* filter sprator fix */ + +[dir="rtl"] ul.dark\:bg-base-900.border.border-base-200.flex.min-w-20.rounded.shadow-sm.text-font-default-light.dark\:border-base-700.dark\:text-font-default-dark.w-full li { + border-right: none; + border-left: 1px solid #404040; /* Matches border-base-200 */ +} + +[dir="rtl"] ul.dark\:bg-base-900.border.border-base-200.flex.min-w-20.rounded.shadow-sm.text-font-default-light.dark\:border-base-700.dark\:text-font-default-dark.w-full li:last-child { + border-left: 0; +} + +/* Dark mode override */ +[dir="rtl"] .dark ul.dark\:bg-base-900.border.border-base-200.flex.min-w-20.rounded.shadow-sm.text-font-default-light.dark\:border-base-700.dark\:text-font-default-dark.w-full li { + border-left: 1px solid #374151; /* Matches dark:border-base-700 */ +} + + + + +/* import export sprator fix */ + +/* Desktop RTL: Swap right borders to left borders */ +[dir="rtl"] ul.border.flex.flex-col.font-medium.mb-4.mt-2.rounded.shadow-sm.md\:flex-row.md\:mb-2.md\:mt-0.dark\:border-base-700.max-md\:w-full li.md\:border-r { + border-right: none; /* Remove md:border-r */ + border-left: 1px solid #404040; /* Add left border, matching border-base-200 default */ +} + +[dir="rtl"] ul.border.flex.flex-col.font-medium.mb-4.mt-2.rounded.shadow-sm.md\:flex-row.md\:mb-2.md\:mt-0.dark\:border-base-700.max-md\:w-full li:last-child { + border-left: 0; /* No left border on last item */ +} + +/* Dark mode for desktop RTL */ +[dir="rtl"] .dark ul.border.flex.flex-col.font-medium.mb-4.mt-2.rounded.shadow-sm.md\:flex-row.md\:mb-2.md\:mt-0.dark\:border-base-700.max-md\:w-full li.md\:border-r { + border-left: 1px solid #374151; /* Matches dark:border-base-700 */ +} + +[dir="rtl"] h2.font-semibold.flex.flex-row.group.items-center.mb-1.mx-3.py-1\.5.px-3.select-none.text-font-important-light.text-sm.dark\:text-font-important-dark.cursor-pointer.hover\:text-primary-600.dark\:hover\:text-primary-500 { + justify-content: space-between; +} + +[dir="rtl"] span.material-symbols-outlined.ml-auto.text-base-400.transition-all.group-hover\:text-primary-600.dark\:group-hover\:text-primary-500 { + margin: 0 !important; + rotate: 90deg !important; +} + + +[dir="rtl"] div.overflow-hidden.relative.px-2.py-1.text-sm { + background: rgb(var(--color-primary-950)); + border-radius: var(--border-radius, 6px); + padding: 8px 16px; +} + +[dir="rtl"] div.flex.flex-row.relative.z-20 { + justify-content: space-between; +} + +[dir="rtl"] strong.font-semibold.text-font-important-light.ml-auto.dark\:text-font-important-dark { + margin: 0 !important; +} \ No newline at end of file diff --git a/backend/core/storages.py b/backend/core/storages.py new file mode 100644 index 0000000..766fd23 --- /dev/null +++ b/backend/core/storages.py @@ -0,0 +1,6 @@ +from storages.backends.s3boto3 import S3Boto3Storage + + +class MediaStorage(S3Boto3Storage): + location = 'media' + default_acl = 'private' \ No newline at end of file diff --git a/backend/core/urls.py b/backend/core/urls.py index b461d9c..9db83f5 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -16,8 +16,8 @@ urlpatterns = [ # path('auth/', include('djoser.urls.jwt')), path('home', HomeView.as_view()), - path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), - path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('token', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), + path('token/refresh', TokenRefreshView.as_view(), name='token_refresh'), path('admin/', FakeAdminLoginView.as_view()), # Fake admin path('secret-admin/', admin.site.urls), # Real admin path('schema/', SpectacularAPIView.as_view(), name='schema'), diff --git a/backend/core/views.py b/backend/core/views.py index ee1b6d0..6dbeb76 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -51,10 +51,10 @@ def random_data(): ], "kpi": [ { - "title": "IPhone 16 Pro Max", + "title": "گوشی Iphone 16 pro", "metric": f"${intcomma(f"{random.uniform(1000, 9999):.02f}")}", "footer": mark_safe( - f'+{intcomma(f"{random.uniform(1, 9):.02f}")}% progress from last week' + f'+{intcomma(f"{random.uniform(1, 9):.02f}")}% درصد فروش کل' ), "chart": json.dumps( { @@ -64,23 +64,23 @@ def random_data(): ), }, { - "title": "Macbook Pro M3", + "title": "لپ تاپ Macbook Pro M3", "metric": f"${intcomma(f"{random.uniform(1000, 9999):.02f}")}", "footer": mark_safe( - f'+{intcomma(f"{random.uniform(1, 9):.02f}")}% progress from last week' + f'+{intcomma(f"{random.uniform(1, 9):.02f}")}% درصد فروش کل' ), }, { - "title": "Apple Watch 8", + "title": "ساعت هوشمند Apple Watch 8", "metric": f"${intcomma(f"{random.uniform(1000, 9999):.02f}")}", "footer": mark_safe( - f'+{intcomma(f"{random.uniform(1, 9):.02f}")}% progress from last week' + f'+{intcomma(f"{random.uniform(1, 9):.02f}")}% درصد فروش کل' ), }, ], "progress": [ { - "title": "📱 Phone and Mobile", + "title": "📱 موبایل و گوشی", "description": "$2,499.99", "value": 20, }, @@ -155,10 +155,10 @@ def random_data(): ), "performance": [ { - "title": _("Last week revenue"), + "title": 'فروش ماه اخیر', "metric": "$1,234.56", "footer": mark_safe( - '+3.14% progress from last week' + '+3.14% درصد فروش کل' ), "chart": json.dumps( { @@ -173,10 +173,10 @@ def random_data(): ), }, { - "title": _("Last week expenses"), + "title": 'مخارج ماه اخیر', "metric": "$1,234.56", "footer": mark_safe( - '+3.14% progress from last week' + '+3.14% درصد فروش کل' ), "chart": json.dumps( { diff --git a/backend/order/models.py b/backend/order/models.py index 570351b..3f37b5b 100644 --- a/backend/order/models.py +++ b/backend/order/models.py @@ -59,21 +59,23 @@ class OrderModel(models.Model): print('didnt send') super().save(*args, **kwargs) - def total_with_discount(self): - total_with_item_discount = sum(item.total_with_discount() for item in self.items.all()) - if self.discount_code: - if not self.discount_code.is_valid(): - raise DiscountNotAvailableError('این کد تخفیف دیگر معتبر نیست') - discount_percent = self.discount_code.percent - return total_with_item_discount * ((100 - discount_percent) / 100) - return total_with_item_discount + def discount(self): + pass + # total_with_item_discount = sum(item.total_with_discount() for item in self.items.all()) + # if self.discount_code: + # if not self.discount_code.is_valid(): + # raise DiscountNotAvailableError('این کد تخفیف دیگر معتبر نیست') + # discount_percent = self.discount_code.percent + # return total_with_item_discount * ((100 - discount_percent) / 100) + # return total_with_item_discount def tax(self): return self.total_without_tax() * 0.2 def total(self): - return self.total_with_discount() + self.tax() + pass + # return self.total_with_discount() + self.tax() def remove_order_item(self, item_pk, quantity): pass diff --git a/backend/order/serializers.py b/backend/order/serializers.py index b9337c0..5950648 100644 --- a/backend/order/serializers.py +++ b/backend/order/serializers.py @@ -8,7 +8,20 @@ class OrderItemSerailzier(serializers.ModelSerializer): fields = "__all__" read_only_fields = ('order', 'product') -class OrderModelSerializer(serializers.ModelSerializer): +class CartSerializer(serializers.ModelSerializer): + items = OrderItemSerailzier(many=True) class Meta: model = OrderModel - fields = ['address', 'created_at', 'is_paid', 'status', 'discount_code'] \ No newline at end of file + fields = ['address', 'created_at', 'is_paid', 'status', 'discount_code', 'items'] + + +class OrderSerializer(serializers.ModelSerializer): + count = serializers.SerializerMethodField() + images = serializers.SerializerMethodField() + class Meta: + model = OrderModel + fields = ['address', 'created_at', 'is_paid', 'status', 'discount_code', "images", "count", "id"] + def get_count(self, obj): + return obj.items.all().count() + def get_images(self, obj): + return ["a" , "b" , "c"] \ No newline at end of file diff --git a/backend/order/urls.py b/backend/order/urls.py index 696981c..325e1a5 100644 --- a/backend/order/urls.py +++ b/backend/order/urls.py @@ -1,11 +1,12 @@ from django.conf.urls.static import static from django.contrib import admin from django.urls import path, include -from .views import CartItemViews, CartView +from .views import CartItemViews, CartView, OrderlistView urlpatterns = [ + path('list', OrderlistView.as_view(), name='order-list'), path('cart', CartView.as_view()), path('cart/item/', CartItemViews.as_view(), name='change-item-cart'), - path('payment', CartView.as_view()), - path('', CartView.as_view()), + # path('payment', CartView.as_view()), + # path('', CartView.as_view()), ] diff --git a/backend/order/views.py b/backend/order/views.py index 924f1a3..bb21f5a 100644 --- a/backend/order/views.py +++ b/backend/order/views.py @@ -4,7 +4,7 @@ from rest_framework.views import APIView, Response from django.shortcuts import get_object_or_404 from product.models import ProductVariant from rest_framework.permissions import IsAuthenticated -from .serializers import OrderItemSerailzier +from .serializers import * # from cart.models import from rest_framework import status from .models import OrderItemModel, OrderModel @@ -53,11 +53,6 @@ class CartItemViews(APIView): user=request.user, status='CART' ) - # order_item, created = OrderItemModel.objects.get_or_create( - # order=cart_order, - # product=product_variant, - # defaults={'quantity': request.data.get('quantity', 1)} - # ) order_item = get_object_or_404(OrderItemModel, order=cart_order, product=product_variant) order_item.delete() return Response({'detail': f'محصول {product_variant.product.name} از سبد خرید پاک شد'}, status=status.HTTP_204_NO_CONTENT) @@ -65,5 +60,20 @@ class CartItemViews(APIView): class CartView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = CartSerializer def get(self, request): - return Response({'detail': 'این بخش در حال توسعه می باشد تا اماده شدن این بخش به نقاشی خود ادامه دهید'}, status=status.HTTP_404_NOT_FOUND) + user = request.user + cart_instance, created = OrderModel.objects.get_or_create(user=user, status='CART') + cart_ser = self.serializer_class(instance=cart_instance, context={'request': request}) + return Response(cart_ser.data, status=status.HTTP_200_OK) + + +class OrderlistView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = OrderSerializer + def get(self, request): + user = request.user + orders = OrderModel.objects.filter(user=user).exclude(status="CART") + orders_ser = self.serializer_class(instance=orders, many=True, context={'request': request}) + return Response(orders_ser.data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/product/views.py b/backend/product/views.py index 68e06e8..6606516 100644 --- a/backend/product/views.py +++ b/backend/product/views.py @@ -170,14 +170,14 @@ class AllProductsView(APIView): if search_query: products = products.filter(Q(name__icontains=search_query) | Q(description__icontains=search_query)) - # Price filters - price_gte = request.query_params.get('price_gte', None) - price_lte = request.query_params.get('price_lte', None) + # # Price filters + # price_gte = request.query_params.get('price_gte', None) + # price_lte = request.query_params.get('price_lte', None) - if price_gte: - products = products.filter(variants__min_price__gte=price_gte) - if price_lte: - products = products.filter(variants__min_price__lte=price_lte) + # if price_gte: + # products = products.filter(variants__min_price__gte=price_gte) + # if price_lte: + # products = products.filter(variants__min_price__lte=price_lte) # Sorting sort_by = request.query_params.get('sort', None) diff --git a/backend/requirements.txt b/backend/requirements.txt index ca6be94..83cdbca 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,22 +5,33 @@ annotated-types==0.7.0 anyio==4.6.0 asgiref==3.8.1 attrs==24.2.0 +az-iranian-bank-gateways==2.0.5 +beautifulsoup4==4.12.3 +boto3==1.36.26 +botocore==1.36.26 branca==0.8.1 certifi==2024.8.30 cffi==1.17.1 charset-normalizer==3.3.2 +colorama==0.4.6 cryptography==44.0.1 defusedxml==0.8.0rc2 diff-match-patch==20230430 distro==1.9.0 Django==5.1.2 +django-admin-interface==0.28.5 +django-admin-persian-fonts==0.2 django-cleanup==8.1.0 +django-colorfield==0.11.0 django-cors-headers==4.4.0 +django-cron==0.6.0 django-dbbackup==4.2.1 +django-dirtyfields==1.9.3 django-filter==24.3 django-import-export==4.1.1 django-iranian-cities==1.0.2 django-jalali==7.3.0 +django-storages==1.14.5 django-unfold==0.48.0 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 @@ -28,6 +39,7 @@ djoser==2.3.1 dnspython==2.7.0 drf-spectacular==0.27.2 email_validator==2.2.0 +et-xmlfile==1.1.0 factory_boy==3.3.1 Faker==28.4.1 folium==0.19.4 @@ -43,29 +55,40 @@ httpcore==1.0.5 httpx==0.27.2 idna==3.10 inflection==0.5.1 +isodate==0.6.1 jalali_core==1.0.0 jdatetime==5.0.0 Jinja2==3.1.5 jiter==0.8.2 +jmespath==1.0.1 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 +lxml==5.2.2 +MarkupPy==1.14 MarkupSafe==3.0.2 maxminddb==2.6.2 multidict==6.1.0 numpy==2.2.3 oauthlib==3.2.2 +odfpy==1.4.1 openai==1.58.1 +openpyxl==3.1.2 pillow==10.4.0 +platformdirs==4.2.2 +propcache==0.2.0 psutil==6.0.0 psycopg2-binary==2.9.10 py-vapid==1.9.2 pycparser==2.22 +pycryptodome==3.20.0 pydantic==2.10.6 pydantic_core==2.27.2 PyJWT==2.10.1 pyTelegramBotAPI==4.23.0 python-dateutil==2.9.0.post0 +python-decouple==3.8 python-dotenv==1.0.1 +python-slugify==8.0.4 python-telegram-bot==21.6 python3-openid==3.2.0 pytz==2024.2 @@ -73,20 +96,29 @@ pywebpush==2.0.3 PyYAML==6.0.2 referencing==0.35.1 requests==2.32.3 +requests-file==2.1.0 requests-oauthlib==2.0.0 +requests-toolbelt==1.0.0 rpds-py==0.20.0 +s3transfer==0.11.2 setuptools==75.1.0 six==1.16.0 sniffio==1.3.1 social-auth-app-django==5.4.2 social-auth-core==4.5.4 +soupsieve==2.5 sqlparse==0.5.1 tablib==3.5.0 +telebot==0.0.5 +text-unidecode==1.3 tqdm==4.67.1 typing_extensions==4.12.2 tzdata==2024.1 uritemplate==4.1.1 urllib3==2.2.3 whitenoise==6.7.0 +xlrd==2.0.1 +xlwt==1.3.0 xyzservices==2025.1.0 yarl==1.11.1 +zeep==4.2.1 diff --git a/backend/templates/admin/base_site.html b/backend/templates/admin/base_site.html index 9f2851d..bab7144 100644 --- a/backend/templates/admin/base_site.html +++ b/backend/templates/admin/base_site.html @@ -1,6 +1,11 @@ {% extends "admin/base.html" %} {% load static %} +{% block html_attrs %} + lang="fa" dir="rtl" +{% endblock %} + + {% block extrastyle %}{{ block.super }} {% endblock %} diff --git a/backend/templates/admin/index.html b/backend/templates/admin/index.html index b17f7af..5920b9d 100644 --- a/backend/templates/admin/index.html +++ b/backend/templates/admin/index.html @@ -5,6 +5,10 @@ {% endblock %} {% load i18n unfold %} +{% block html_attrs %} + lang="fa" dir="rtl" +{% endblock %} + {% block breadcrumbs %}{% endblock %} {% block title %} @@ -30,7 +34,7 @@
{% for stats in kpi %} - {% component "unfold/components/card.html" with class="lg:w-1/3" label=_("Last 7 days") footer=stats.footer %} + {% component "unfold/components/card.html" with class="lg:w-1/3" footer=stats.footer %} {% component "unfold/components/text.html" %} {{ stats.title }} {% endcomponent %} @@ -44,12 +48,12 @@ - {% component "unfold/components/card.html" with title=_("Product performance in last 28 days") %} + {% component "unfold/components/card.html" with title='بازدید های وبسایت در ماه اخیر' %} {% component "unfold/components/chart/bar.html" with data=chart height=320 %}{% endcomponent %} {% endcomponent %}
- {% component "unfold/components/card.html" with class="lg:w-1/2" title=_("The most trending products in last 2 weeks") %} + {% component "unfold/components/card.html" with class="lg:w-1/2" title='محبوب ترین دسته بندی ها' %} {% component "unfold/components/title.html" with class="mb-2" %} $1,234,567.89 {% endcomponent %} diff --git a/backend/templates/formula/service.html b/backend/templates/formula/service.html index 664e402..57856a4 100644 --- a/backend/templates/formula/service.html +++ b/backend/templates/formula/service.html @@ -12,7 +12,7 @@
{% component "unfold/components/flex.html" with class="flex-col gap-4 lg:flex-row" %} - {% component "unfold/components/button.html" with href="/admin/order/ordermodel/" %} + {% component "unfold/components/button.html" with href="/secret-admin/order/ordermodel/" %} نمایش سفارشات {% endcomponent %} {% endcomponent %} @@ -33,7 +33,7 @@
{% component "unfold/components/flex.html" with class="flex-col gap-4 lg:flex-row" %} - {% component "unfold/components/button.html" with href="/admin/ticket/ticket/" %} + {% component "unfold/components/button.html" with href="/secret-admin/ticket/ticket/" %} نمایش تیکت ها {% endcomponent %} {% endcomponent %} diff --git a/backend/ticket/admin.py b/backend/ticket/admin.py index 5ae80c6..1426233 100644 --- a/backend/ticket/admin.py +++ b/backend/ticket/admin.py @@ -58,4 +58,8 @@ class MessageAdmin(ModelAdmin, ImportExportModelAdmin): } def content_display(self, obj): return obj.content[0:35] + '...' - content_display.short_description = 'محتوای پیام' \ No newline at end of file + content_display.short_description = 'محتوای پیام' + +@admin.register(Attachment) +class AttachmentAdmin(ModelAdmin, ImportExportModelAdmin): + list_display = ['name', 'uploaded_by'] \ No newline at end of file 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/migrations/0013_attachment_message_attachments_ticket_attachments.py b/backend/ticket/migrations/0013_attachment_message_attachments_ticket_attachments.py new file mode 100644 index 0000000..5057bad --- /dev/null +++ b/backend/ticket/migrations/0013_attachment_message_attachments_ticket_attachments.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.2 on 2025-02-22 22:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ticket', '0012_alter_ticket_status'), + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='attachments')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('size', models.PositiveIntegerField(blank=True, null=True)), + ('name', models.CharField(blank=True, max_length=400, null=True)), + ], + ), + migrations.AddField( + model_name='message', + name='attachments', + field=models.ManyToManyField(blank=True, related_name='messages', to='ticket.attachment'), + ), + migrations.AddField( + model_name='ticket', + name='attachments', + field=models.ManyToManyField(blank=True, related_name='tickets', to='ticket.attachment'), + ), + ] diff --git a/backend/ticket/migrations/0014_attachment_uploaded_by.py b/backend/ticket/migrations/0014_attachment_uploaded_by.py new file mode 100644 index 0000000..dc98d04 --- /dev/null +++ b/backend/ticket/migrations/0014_attachment_uploaded_by.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.2 on 2025-02-23 18:28 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ticket', '0013_attachment_message_attachments_ticket_attachments'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='uploaded_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/ticket/models.py b/backend/ticket/models.py index 3d74b4e..8ab3b90 100644 --- a/backend/ticket/models.py +++ b/backend/ticket/models.py @@ -3,10 +3,26 @@ from account.models import User from order.models import OrderModel from django_jalali.db import models as jmodels + + +class Attachment(models.Model): + file = models.FileField(upload_to='attachments') + created_at = models.DateTimeField(auto_now_add=True) + size = models.PositiveIntegerField(null=True, blank=True) + name = models.CharField(max_length=400, null=True, blank=True) + uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + def __str__(self): + return self.file.name + + def save(self, *args, **kwargs): + if self.file: + self.size = self.file.size + self.name = self.file.name + super(Attachment, self).save(*args, **kwargs) + class Ticket(models.Model): objects = jmodels.jManager() STATUS_CHOICES = [ - ('open', 'باز'), ('in_progress', 'در حال پردازش'), ('resolved', 'حل شده'), ('closed', 'بسته'), @@ -29,6 +45,7 @@ class Ticket(models.Model): created_at = jmodels.jDateTimeField(auto_now_add=True, verbose_name='ساخته شده در') updated_at = jmodels.jDateTimeField(auto_now=True, verbose_name='اپدیت شده در') order = models.ForeignKey(OrderModel ,blank=True, null=True, on_delete=models.SET_NULL) + attachments = models.ManyToManyField(Attachment, related_name='tickets', blank=True) def __str__(self): return self.subject @@ -45,6 +62,7 @@ class Message(models.Model): sender = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='ارسال کننده') content = models.TextField(verbose_name='محتوای پیام') created_at = jmodels.jDateTimeField(auto_now_add=True, verbose_name='ساخته شده در') + attachments = models.ManyToManyField(Attachment, related_name='messages', blank=True) def __str__(self): return f"Message by {self.sender.full_name} on {self.ticket.subject}" diff --git a/backend/ticket/serializers.py b/backend/ticket/serializers.py index fa3355c..bd7611f 100644 --- a/backend/ticket/serializers.py +++ b/backend/ticket/serializers.py @@ -1,7 +1,10 @@ from rest_framework import serializers -from .models import Ticket, Message +from .models import Ticket, Message, Attachment from django.utils.timezone import localtime from account.serializers import ProfileSerializer + + + class MessageSerializer(serializers.ModelSerializer): class Meta: model = Message @@ -17,8 +20,23 @@ class TicketSerializer(serializers.ModelSerializer): class TicketListSerializer(serializers.ModelSerializer): - admin = ProfileSerializer(read_only=True) class Meta: model = Ticket - exclude = ('customer', ) - read_only_fields = ('status', 'admin', ) \ No newline at end of file + exclude = ('customer', 'admin', 'order', 'content') + read_only_fields = ('status',) + + +class AttachmentSerializer(serializers.ModelSerializer): + file = serializers.FileField(write_only=True) + link = serializers.SerializerMethodField() + + class Meta: + model = Attachment + fields = ['id', 'name', 'file','link' , 'created_at', 'size'] + read_only_fields = ('size', 'name', ) + + def get_link(self, obj): + request = self.context.get('request') + if request is not None: + return request.build_absolute_uri(obj.file.url) + return obj.file.url \ No newline at end of file diff --git a/backend/ticket/urls.py b/backend/ticket/urls.py index 7ad437e..c3a47f5 100644 --- a/backend/ticket/urls.py +++ b/backend/ticket/urls.py @@ -1,15 +1,11 @@ from django.urls import path -from .views import ( - TicketCreateView, - TicketListView, - TicketDetailView, - MessageCreateView, - UpdateTicketStatusView -) +from . import views urlpatterns = [ - path('create', TicketCreateView.as_view(), name='ticket-create'), - path('', TicketListView.as_view(), name='ticket-list'), - path('', TicketDetailView.as_view(), name='ticket-detail'), - path('message/', MessageCreateView.as_view(), name='message-create'), + path('create', views.TicketCreateView.as_view(), name='ticket-create'), + path('', views.TicketListView.as_view(), name='ticket-list'), + path('', views.TicketDetailView.as_view(), name='ticket-detail'), + path('message/create', views.MessageCreateView.as_view(), name='message-create'), + path('attachment/create', views.AttachmentUploadView.as_view(), name='attachment-upload'), + path('attachment/delete/', views.AttachmentDeleteView.as_view(), name='attachment-upload'), ] \ No newline at end of file diff --git a/backend/ticket/views.py b/backend/ticket/views.py index 5315656..292b4f7 100644 --- a/backend/ticket/views.py +++ b/backend/ticket/views.py @@ -1,10 +1,44 @@ 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 TicketListSerializer, MessageSerializer, TicketSerializer +from .models import Ticket, Message, Attachment +from .serializers import TicketListSerializer, MessageSerializer, TicketSerializer, AttachmentSerializer from utils.pagination import StructurePagination from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from rest_framework.permissions import IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiExample, OpenApiTypes, OpenApiResponse +from rest_framework import status +from django.shortcuts import get_object_or_404 + + +class AttachmentDeleteView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = [AttachmentSerializer] + def delete(self, request, pk): + attachment_instance = get_object_or_404(Attachment, pk=pk) + if attachment_instance.uploaded_by == request.user: + attachment_instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + return Response({'detail': 'این فایل توسط شما اپلود نشده'}) + +class AttachmentUploadView(APIView): + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + @extend_schema( + request=AttachmentSerializer, + responses={201: AttachmentSerializer}, + description="upload an attachment (file).", + ) + def post(self, request, *args, **kwargs): + serializer = AttachmentSerializer(data=request.data, context={'request': request}) + if serializer.is_valid(): + serializer.save(uploaded_by=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + class TicketCreateView(generics.CreateAPIView): queryset = Ticket.objects.all() @@ -33,7 +67,26 @@ class TicketListView(APIView): 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), @@ -42,6 +95,14 @@ class TicketListView(APIView): ) 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}) diff --git a/backup/backup.sh b/backup/backup.sh new file mode 100644 index 0000000..4e0a1cb --- /dev/null +++ b/backup/backup.sh @@ -0,0 +1,39 @@ +#!/bin/sh + +# Load environment variables +. /etc/profile + +# Debugging output +echo "Cron environment:" >> /var/log/cron/cron.log +env >> /var/log/cron/cron.log + + +echo "Starting backup at $(date)" >> /var/log/cron/cron.log + +export PGPASSWORD=$PG_PASSWORD + +TELEGRAM_API="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="backup_heymlz_shop${TIMESTAMP}.sql" +ZIP_FILE="backup_heymlz_shop${TIMESTAMP}.zip" + + +pg_dump -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DATABASE -w > /backups/$BACKUP_FILE + +if [ $? -ne 0 ]; then + echo "Backup failed!" >> /var/log/cron/cron.log + exit 1 +fi + +zip -j /backups/$ZIP_FILE /backups/$BACKUP_FILE +curl -F chat_id=$TELEGRAM_CHAT_ID \ + -F document=@/backups/$ZIP_FILE \ + $TELEGRAM_API + +curl -F chat_id=$TELEGRAM_CHAT_ID2 \ + -F document=@/backups/$ZIP_FILE \ + $TELEGRAM_API + +ls -t /backups/*.zip | tail -n +4 | xargs rm -f +ls -t /backups/*.sql | tail -n +4 | xargs rm -f +echo "Backup completed at $(date)" >> /var/log/cron/cron.log \ No newline at end of file diff --git a/backup/crontab b/backup/crontab new file mode 100644 index 0000000..2341de2 --- /dev/null +++ b/backup/crontab @@ -0,0 +1 @@ +0 3 * * * /app/backup.sh >> /var/log/cron/cron.log 2>&1 \ No newline at end of file diff --git a/backup/dockerfile b/backup/dockerfile new file mode 100644 index 0000000..08b66d8 --- /dev/null +++ b/backup/dockerfile @@ -0,0 +1,15 @@ +FROM alpine:3.19 + +RUN apk add --no-cache postgresql-client curl zip tzdata +RUN ln -sf /usr/share/zoneinfo/UTC /etc/localtime +RUN mkdir -p /var/log/cron /backups + +WORKDIR /app +COPY backup.sh . +COPY crontab /etc/crontabs/root + +RUN chmod +x backup.sh && \ + chmod 0644 /etc/crontabs/root && \ + touch /var/log/cron/cron.log + +CMD ["busybox", "crond", "-f", "-L", "/var/log/cron/cron.log"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 6a21034..eded502 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,11 +42,28 @@ services: networks: - default - + db-backup: + build: + context: ./backup + depends_on: + - db + environment: + - PG_HOST=db + - PG_PORT=5432 + - PG_DATABASE=hshop + - PG_USER=byeto + - PG_PASSWORD=vuhbyq-cypMu0-sirbon + - TELEGRAM_BOT_TOKEN=7068288679:AAGecMnyt9A6R78OQu8nQeISMK1LepX718g + - TELEGRAM_CHAT_ID=1198382521 + - TELEGRAM_CHAT_ID2=5115366609 + volumes: + - backups:/backups + networks: + - default volumes: postgres_data: media_data: - + backups: networks: default: \ No newline at end of file diff --git a/frontend/components/global/FileInput.vue b/frontend/components/global/FileInput.vue index a2a0910..e0e3beb 100644 --- a/frontend/components/global/FileInput.vue +++ b/frontend/components/global/FileInput.vue @@ -6,11 +6,19 @@ import { useToast } from "~/composables/global/useToast"; // types type Props = { - modelValue: File[]; + modelValue: { + id: number; + file_link: string; + date: string; + size: number; + name: string; + }[]; + loading?: boolean; }; type Emits = { "update:modelValue": [value: any]; + change: [value: File]; }; // props @@ -50,7 +58,8 @@ const onDrop = (files: File[] | null) => { } else { if (modelValue.value.length + files.length <= 3) { files?.forEach((item) => { - emit("update:modelValue", [...modelValue.value, item]); + emit("change", item); + resetFileDialog(); }); } else { addToast({ @@ -78,22 +87,27 @@ const { isOverDropZone } = useDropZone(dropZoneRef, { dataTypes: ["image/jpeg", "image/png", "image/jpg"], }); -const { open: openDialog, onChange } = useFileDialog({ +const { + open: openDialog, + onChange, + reset: resetFileDialog, +} = useFileDialog({ accept: "image/*", directory: false, }); onChange((files: any) => { let arr: File[] = []; - Object.keys(files).forEach((item) => { - arr.push(files[item]); + Object.keys(files).forEach((_, index) => { + arr.push(files[index]); }); onDrop(arr); }); -const deleteFile = (index: number) => { +const removeAttachment = (id: number) => { + let target = modelValue.value.findIndex((i) => i.id == id); const clone = [...modelValue.value]; - clone.splice(index, 1); + clone.splice(target, 1); emit("update:modelValue", clone); }; @@ -103,10 +117,22 @@ const deleteFile = (index: number) => {
- +

برای آپلود کلیک کنید یا فایل خود را اینجا بیاندازید

@@ -121,24 +147,12 @@ const deleteFile = (index: number) => { v-auto-animate class="flex flex-row-reverse items-center justify-between w-full px-2 animate__animated animate__fadeIn" > -
  • -
    -

    {{ item.name }}

    -
    -
    -

    - {{ (item.size / 1024).toFixed(2) }}KB -

    -
    -
    - -
    -
  • +
    diff --git a/frontend/components/global/Pagination.vue b/frontend/components/global/Pagination.vue index 4a29a83..875201c 100644 --- a/frontend/components/global/Pagination.vue +++ b/frontend/components/global/Pagination.vue @@ -1,5 +1,4 @@ diff --git a/frontend/components/global/Placeholder.vue b/frontend/components/global/Placeholder.vue index bcb252f..019b32f 100644 --- a/frontend/components/global/Placeholder.vue +++ b/frontend/components/global/Placeholder.vue @@ -13,7 +13,7 @@ defineProps(); diff --git a/frontend/components/global/product-detail/Slider.vue b/frontend/components/global/product-detail/Slider.vue index e1110d2..41d0e00 100644 --- a/frontend/components/global/product-detail/Slider.vue +++ b/frontend/components/global/product-detail/Slider.vue @@ -1,11 +1,10 @@ \ No newline at end of file + diff --git a/frontend/components/product/ChatBox/ChatBoxContainer.vue b/frontend/components/product/ChatBox/ChatBoxContainer.vue index cedb98c..91eafa5 100644 --- a/frontend/components/product/ChatBox/ChatBoxContainer.vue +++ b/frontend/components/product/ChatBox/ChatBoxContainer.vue @@ -29,17 +29,17 @@ const { isPending: isChatPending, isFetchingNextPage: isNextChatPagePending, hasNextPage: hasMoreChat, - fetchNextPage: loadMoreChat + fetchNextPage: loadMoreChat, } = useGetChat(id, isOpen); const isCreateMessagePending = useIsMutating({ - mutationKey: [MUTATION_KEYS.create_chat] + mutationKey: [MUTATION_KEYS.create_chat], }); const canLoadMoreChat = ref(false); const isChatScrollLocked = useScrollLock(chatContainerEl); const { y: chatContainerScrollY } = useScroll(chatContainerEl, { - behavior: "smooth" + behavior: "smooth", }); useInfiniteScroll( @@ -56,11 +56,11 @@ useInfiniteScroll( distance: 10, direction: "top", throttle: 1000, - canLoadMore: () => canLoadMoreChat.value + canLoadMore: () => canLoadMoreChat.value, } ); -// method +// methods const scrollToBottom = () => { chatContainerScrollY.value = chatContainerEl.value?.scrollHeight ?? 0; @@ -116,7 +116,7 @@ whenever( }, 2000); }, { - once: true + once: true, } ); @@ -131,16 +131,15 @@ whenever( -
    +
    Please sign in first
    diff --git a/frontend/components/product/ChatBox/ChatButton.vue b/frontend/components/product/ChatBox/ChatButton.vue index 22ab002..114b7c2 100644 --- a/frontend/components/product/ChatBox/ChatButton.vue +++ b/frontend/components/product/ChatBox/ChatButton.vue @@ -1,24 +1,21 @@ diff --git a/frontend/components/product/ChatBox/ChatInput.vue b/frontend/components/product/ChatBox/ChatInput.vue index 69cfea4..d59a17c 100644 --- a/frontend/components/product/ChatBox/ChatInput.vue +++ b/frontend/components/product/ChatBox/ChatInput.vue @@ -1,5 +1,4 @@ @@ -303,4 +316,4 @@ const sendMessage = async () => { transform: translate(-50%, -50%) rotate(443deg); } } - \ No newline at end of file + diff --git a/frontend/components/product/ChatBox/ChatMessage.vue b/frontend/components/product/ChatBox/ChatMessage.vue index c142dce..97ecf3b 100644 --- a/frontend/components/product/ChatBox/ChatMessage.vue +++ b/frontend/components/product/ChatBox/ChatMessage.vue @@ -1,14 +1,13 @@ \ No newline at end of file + diff --git a/frontend/components/product/ProductComments.vue b/frontend/components/product/ProductComments.vue index 2d38b65..1b0bcd8 100644 --- a/frontend/components/product/ProductComments.vue +++ b/frontend/components/product/ProductComments.vue @@ -1,5 +1,4 @@