From c227fda538bf9bf2034353ef2e3d29785adae07e Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 28 Apr 2026 14:48:54 +0330 Subject: [PATCH 1/5] better exception --- utils/exceptions.py | 62 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/utils/exceptions.py b/utils/exceptions.py index 756d9a4..8e730cd 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -1,25 +1,49 @@ import logging +from django.conf import settings +from django.utils import timezone from rest_framework.exceptions import APIException from rest_framework.views import exception_handler as drf_exception_handler logger = logging.getLogger(__name__) + + def exception_handler(exc, context): + logger.exception(exc) # پاسخ پیش‌فرض DRF را دریافت می‌کنیم response = drf_exception_handler(exc, context) if response is not None: - # ساختار دلخواه خود را تعریف می‌کنیم response_data = { 'success': False, 'status_code': response.status_code, - 'message': 'An error occurred', - 'details': {} + 'status_message': str(getattr(exc, 'default_detail', exc)), + 'details': getattr(exc, 'detail', None), } if isinstance(exc, APIException): - response_data['message'] = exc.detail if isinstance(exc.detail, str) else "Validation error" - response_data['details'] = exc.detail if isinstance(exc.detail, dict) else {} + try: + error = getattr(getattr(exc, 'detail', None), 'code', None) or getattr(exc, 'code', None) + if not error: + error = getattr(exc, 'default_code') + except: + error = '' + + # if isinstance(exc.detail, str): + # message = exc.detail + # elif isinstance(exc.detail, dict): + # if 'message' in exc.detail or 'string' in exc.detail: + # message = exc.detail.get('message') or exc.detail.get('string') + # else: + # message = error + # + # else: + # message = error + # + # response_data['message'] = message + response_data['details'] = {"message": exc.detail} + response_data['details']['error'] = error + response_data['details']['timestamp'] = timezone.now().isoformat() response.data = response_data @@ -29,6 +53,7 @@ def exception_handler(exc, context): # در فایل middleware.py from django.http import JsonResponse + class ErrorMiddleware: def __init__(self, get_response): self.get_response = get_response @@ -43,14 +68,33 @@ class ErrorMiddleware: { "success": False, "status_code": 500, - "message": "Internal server error", - "details": str(exception) # فقط در حالت توسعه! در تولید بهتر است لاگ شود. + "status_message": "Internal server error", + "details": str(exception) if settings.DEBUG else None, }, status=500 ) +from rest_framework.exceptions import APIException +from rest_framework import status +from django.utils.translation import gettext_lazy as _ + + class UnprocessableEntity(APIException): - status_code = 422 + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY default_detail = 'The request was well-formed but cannot be processed due to semantic errors.' - default_code = 'unprocessable_entity' \ No newline at end of file + default_code = 'unprocessable_entity' + + +class ServiceUnavailable(APIException): + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_ = _('SERVICE_UNAVAILABLE') + default_code = 'service_unavailable' + default_detail = 'Service Unavailable' + + +class Conflict(APIException): + status_code = status.HTTP_409_CONFLICT + default_ = _('CONFLICT') + default_code = 'conflict' + default_detail = 'Conflict' From 1f44f80a798b3df740bdfa9fae527bf026a8b739 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 28 Apr 2026 14:53:37 +0330 Subject: [PATCH 2/5] better exception --- apps/users/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/users/serializers.py b/apps/users/serializers.py index ff7b3e0..9f1ba07 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -73,12 +73,12 @@ class RequestOTPSerializer(serializers.ModelSerializer): try: user.set_otp() except Exception as e: - raise UnprocessableEntity(_('otp expire time not reached')) + raise UnprocessableEntity(_('otp expire time not reached'), code='opt_not_expired') user.send_otp() else: - raise UnprocessableEntity(_('otp expire time not reached yet')) + raise UnprocessableEntity(_('otp expire time not reached yet'), code='opt_not_expired') self.instance = user return user From 280b8f1c2c211ed5caf772cd8930dd83f686cdaa Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 28 Apr 2026 15:59:56 +0330 Subject: [PATCH 3/5] throttles --- .gitignore | 1 + apps/users/models.py | 3 - apps/users/serializers.py | 9 +- apps/users/views.py | 7 +- {accounts => main}/__init__.py | 0 {accounts => main}/asgi.py | 2 +- {accounts => main}/broker_rpc.py | 2 +- {accounts => main}/celery.py | 2 +- main/other_settings/__init__.py | 0 main/other_settings/logging.py | 141 +++++++++++++++++++++++++++++++ {accounts => main}/settings.py | 43 ++-------- {accounts => main}/urls.py | 0 {accounts => main}/wsgi.py | 2 +- manage.py | 2 +- run.sh | 4 +- utils/throttles.py | 24 ++++++ 16 files changed, 185 insertions(+), 57 deletions(-) rename {accounts => main}/__init__.py (100%) rename {accounts => main}/asgi.py (82%) rename {accounts => main}/broker_rpc.py (63%) rename {accounts => main}/celery.py (90%) create mode 100644 main/other_settings/__init__.py create mode 100644 main/other_settings/logging.py rename {accounts => main}/settings.py (89%) rename {accounts => main}/urls.py (100%) rename {accounts => main}/wsgi.py (82%) diff --git a/.gitignore b/.gitignore index 0d9702f..a637850 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ delme*.py .env /venv/ oidc.key +/log/ diff --git a/apps/users/models.py b/apps/users/models.py index 4da2cee..16fadab 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -161,9 +161,6 @@ class User(AbstractUser): return state.get_province_by_id(self.province) def set_otp(self): - if self.otp_expire and (self.otp_expire + timedelta(seconds=MAX_OTP_VALID_DURATION)) > timezone.now(): - raise Exception(_('otp expire time not reached.')) - if not settings.SMS_SEND: self._otp = '77501' elif self.phone_number in settings.TEST_PHONENUMBERS: diff --git a/apps/users/serializers.py b/apps/users/serializers.py index 9f1ba07..c4b5e72 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -70,16 +70,9 @@ class RequestOTPSerializer(serializers.ModelSerializer): user = User.objects.create_user(**validated_data) if not user.otp_is_valid(): - try: - user.set_otp() - except Exception as e: - raise UnprocessableEntity(_('otp expire time not reached'), code='opt_not_expired') - + user.set_otp() user.send_otp() - else: - raise UnprocessableEntity(_('otp expire time not reached yet'), code='opt_not_expired') - self.instance = user return user diff --git a/apps/users/views.py b/apps/users/views.py index a5fca61..94dafbe 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -21,7 +21,7 @@ from apps.users.models import User from apps.users.provinces_and_cities import State from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \ ChangePasswordSerializer, UserInquirySerializer -from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle +from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle, NumberedRequestOTPDayRateThrottle, NumberedRequestOTPMinRateThrottle UserModel = get_user_model() @@ -90,7 +90,10 @@ class RequestOTPView(generics.CreateAPIView): permission_classes = [] serializer_class = RequestOTPSerializer required_scopes = [] - throttle_classes = [RequestOTPMinRateThrottle, RequestOTPDayRateThrottle] + throttle_classes = [RequestOTPMinRateThrottle, + RequestOTPDayRateThrottle, + NumberedRequestOTPDayRateThrottle, + NumberedRequestOTPMinRateThrottle] class RequestOTTView(generics.CreateAPIView): permission_classes = [IsAuthenticatedOrTokenHasScope] diff --git a/accounts/__init__.py b/main/__init__.py similarity index 100% rename from accounts/__init__.py rename to main/__init__.py diff --git a/accounts/asgi.py b/main/asgi.py similarity index 82% rename from accounts/asgi.py rename to main/asgi.py index 3116ceb..f41decb 100644 --- a/accounts/asgi.py +++ b/main/asgi.py @@ -11,6 +11,6 @@ import os from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') application = get_asgi_application() diff --git a/accounts/broker_rpc.py b/main/broker_rpc.py similarity index 63% rename from accounts/broker_rpc.py rename to main/broker_rpc.py index 6b322c4..30950c1 100644 --- a/accounts/broker_rpc.py +++ b/main/broker_rpc.py @@ -1,6 +1,6 @@ #!/usr/bin/env python import os -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') from utils.broker import get_rpc_broker_consumer diff --git a/accounts/celery.py b/main/celery.py similarity index 90% rename from accounts/celery.py rename to main/celery.py index 6b7fc88..697b017 100644 --- a/accounts/celery.py +++ b/main/celery.py @@ -6,7 +6,7 @@ # from django.conf import settings # # # set the default Django settings module for the 'celery' program. -# # os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') +# # os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') # # app = Celery('notification_service') # diff --git a/main/other_settings/__init__.py b/main/other_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main/other_settings/logging.py b/main/other_settings/logging.py new file mode 100644 index 0000000..74038a3 --- /dev/null +++ b/main/other_settings/logging.py @@ -0,0 +1,141 @@ +import os + +def get_settings(key): + from django.conf import settings + return getattr(settings, 'BASE_DIR') + +BASE_DIR = get_settings('BASE_DIR') + +LOG_DIR = BASE_DIR / 'log' +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{asctime} [{levelname}] {name} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'standard': { + 'format': '{asctime} [{levelname}] {name}: {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'filters': { + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + }, + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse', + }, + }, + 'handlers': { + # هندلر برای نوشتن در فایل + 'file': { + 'level': 'INFO', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOG_DIR, 'accounts.log'), + # 'maxBytes': 1024 * 1024 * 10, # 10 MB + 'when': 'midnight', + 'interval': 5, + 'backupCount': 30, + 'formatter': 'verbose', + 'encoding': 'utf-8', + }, + # هندلر برای خطاها در فایل جداگانه + 'error_file': { + 'level': 'ERROR', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOG_DIR, 'errors.log'), + # 'maxBytes': 1024 * 1024 * 10, + 'when': 'midnight', + 'interval': 5, + 'backupCount': 30, + 'formatter': 'verbose', + 'encoding': 'utf-8', + }, + # هندلر برای کنسول (فقط در حالت DEBUG) + 'console': { + 'level': 'DEBUG', + 'filters': ['require_debug_true'], + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + # هندلر برای کنسول همیشه فعال (حتی در production) + 'console_always': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'standard', + }, + }, + 'loggers': { + # لاگر ریشه + '': { # empty string = root logger + 'handlers': ['console', 'file', 'error_file'], + 'level': 'INFO', + 'propagate': True, + }, + # لاگر اختصاصی برای Django + 'django': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': False, + }, + # لاگر اختصاصی برای درخواست‌ها + 'django.request': { + 'handlers': ['file', 'error_file', 'console'], + 'level': 'ERROR', + 'propagate': False, + }, + # # لاگر اختصاصی برای برنامه خودتان + # 'root': { + # 'handlers': ['console', 'file', 'error_file'], + # 'level': 'DEBUG', + # 'propagate': False, + # }, + }, +} + +# LOKI_BASE_PUBLIC_URL = config('LOKI_BASE_PUBLIC_URL', default=None, cast=str) +# { +# 'version': 1, +# 'disable_existing_loggers': False, +# 'formatters': { +# 'loki': { +# 'class': 'utils.logs.LokiFormatter', # required +# }, +# }, +# +# 'handlers': { +# 'loki': { +# 'level': 'DEBUG', # Log level. Required +# 'class': 'utils.logs.LokiHandler', # Required +# 'formatter': 'loki', # Loki formatter. Required +# 'timeout': 2, # Post request timeout, default is 0.5. Optional +# 'url': f'{LOKI_BASE_PUBLIC_URL}/loki/api/v1/push', # Loki url. Defaults to localhost. Optional. +# # 'auth': ("user", "password"), # Basic auth to authenticate with loki. Default is None (i.e. no auth). Optional +# 'tags': {"app": "accounts"}, # Tags / Labels to attach to the log. Optional, but strongly encoraged to use. +# 'mode': 'thread', +# # Push mode. Can be 'sync' or 'thread'. Sync is blocking, thread is non-blocking. Defaults to sync. Optional. +# }, +# 'console': { +# 'level': 'DEBUG', +# 'class': 'logging.StreamHandler', +# # 'formatter': 'verbose', +# }, +# }, +# 'loggers': { +# '': { +# 'handlers': ['console', 'loki'], +# 'level': 'INFO', +# 'propagate': True, +# }, +# }, +# } \ No newline at end of file diff --git a/accounts/settings.py b/main/settings.py similarity index 89% rename from accounts/settings.py rename to main/settings.py index 460f1ad..13d8103 100644 --- a/accounts/settings.py +++ b/main/settings.py @@ -121,7 +121,9 @@ REST_FRAMEWORK = { # ], 'DEFAULT_THROTTLE_RATES': { 'otp_min': '2/min', - 'otp_day': '200/day', + 'otp_day': '50/day', + 'numbered_otp_min': '1/min', + 'numbered_otp_day': '10/day', }, 'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler', } @@ -135,7 +137,7 @@ SPECTACULAR_SETTINGS = { "PARSER_WHITELIST": ["rest_framework.parsers.JSONParser"], } -ROOT_URLCONF = 'accounts.urls' +ROOT_URLCONF = 'main.urls' TEMPLATES = [ { @@ -159,7 +161,7 @@ AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', ) -WSGI_APPLICATION = 'accounts.wsgi.application' +WSGI_APPLICATION = 'main.wsgi.application' # Database # https://docs.djangoproject.com/en/5.0/ref/settings/#databases @@ -293,40 +295,7 @@ CACHES = { LOKI_BASE_PUBLIC_URL = config('LOKI_BASE_PUBLIC_URL', default=None, cast=str) -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'loki': { - 'class': 'utils.logs.LokiFormatter', # required - }, - }, - - 'handlers': { - 'loki': { - 'level': 'DEBUG', # Log level. Required - 'class': 'utils.logs.LokiHandler', # Required - 'formatter': 'loki', # Loki formatter. Required - 'timeout': 2, # Post request timeout, default is 0.5. Optional - 'url': f'{LOKI_BASE_PUBLIC_URL}/loki/api/v1/push', # Loki url. Defaults to localhost. Optional. - # 'auth': ("user", "password"), # Basic auth to authenticate with loki. Default is None (i.e. no auth). Optional - 'tags': {"app": "accounts"}, # Tags / Labels to attach to the log. Optional, but strongly encoraged to use. - 'mode': 'thread', # Push mode. Can be 'sync' or 'thread'. Sync is blocking, thread is non-blocking. Defaults to sync. Optional. - }, - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - # 'formatter': 'verbose', - }, - }, - 'loggers': { - '': { - 'handlers': ['console', 'loki'], - 'level': 'INFO', - 'propagate': True, - }, - }, -} +from main.other_settings.logging import LOGGING from datetime import timedelta from typing import List, Tuple diff --git a/accounts/urls.py b/main/urls.py similarity index 100% rename from accounts/urls.py rename to main/urls.py diff --git a/accounts/wsgi.py b/main/wsgi.py similarity index 82% rename from accounts/wsgi.py rename to main/wsgi.py index d4e6b0e..2d4a6a6 100644 --- a/accounts/wsgi.py +++ b/main/wsgi.py @@ -11,6 +11,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') application = get_wsgi_application() diff --git a/manage.py b/manage.py index 7a22e43..063eacc 100644 --- a/manage.py +++ b/manage.py @@ -5,7 +5,7 @@ import sys def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/run.sh b/run.sh index 80e55fd..0eb9a8e 100755 --- a/run.sh +++ b/run.sh @@ -5,5 +5,5 @@ while ! nc -z $DB_HOST 5432 ; do done #python3 manage.py collectstatic --noinput python3 manage.py migrate -#gunicorn accounts.wsgi:application --bind 0.0.0.0:8000 -w 4 -daphne -b 0.0.0.0 -p 8000 accounts.asgi:application \ No newline at end of file +gunicorn main.wsgi:application --bind 0.0.0.0:8000 -w 4 +#daphne -b 0.0.0.0 -p 8000 main.asgi:application \ No newline at end of file diff --git a/utils/throttles.py b/utils/throttles.py index 33b9e9c..fb4f5ee 100644 --- a/utils/throttles.py +++ b/utils/throttles.py @@ -16,9 +16,33 @@ class RequestOTPDayRateThrottle(SimpleRateThrottle): } + class RequestOTPMinRateThrottle(RequestOTPDayRateThrottle): scope = 'otp_min' +class NumberedRequestOTPDayRateThrottle(SimpleRateThrottle): + scope = 'numbered_otp_day' + + def get_ident(self, request): + return request.data.get('phone_number') + + def get_cache_key(self, request, view): + if request.user and request.user.is_authenticated: + ident = request.user.pk + else: + ident = self.get_ident(request) + + return self.cache_format % { + 'scope': self.scope, + 'ident': ident + } + + + +class NumberedRequestOTPMinRateThrottle(NumberedRequestOTPDayRateThrottle): + scope = 'numbered_otp_min' + + From 28ae006b5d642ad675d4757fb594626b61436c47 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 28 Apr 2026 16:42:17 +0330 Subject: [PATCH 4/5] int ttl --- apps/users/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/users/serializers.py b/apps/users/serializers.py index 07e5211..0a8a52b 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -80,7 +80,7 @@ class RequestOTPSerializer(serializers.ModelSerializer): def get_ttl(self, obj: User): if obj.otp_expire > timezone.now(): ttl = obj.otp_expire - timezone.now() - result = ttl.total_seconds() + result = int(ttl.total_seconds()) else: result = 0 From 1981122f2833cf4a68a85a620b2f36ec904e4b34 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Wed, 29 Apr 2026 17:00:56 +0330 Subject: [PATCH 5/5] float ttl --- apps/users/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/users/serializers.py b/apps/users/serializers.py index 0a8a52b..07e5211 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -80,7 +80,7 @@ class RequestOTPSerializer(serializers.ModelSerializer): def get_ttl(self, obj: User): if obj.otp_expire > timezone.now(): ttl = obj.otp_expire - timezone.now() - result = int(ttl.total_seconds()) + result = ttl.total_seconds() else: result = 0