diff --git a/.env.dev b/.env.dev index df0273f..afe6806 100644 --- a/.env.dev +++ b/.env.dev @@ -22,3 +22,9 @@ PHONENUMBER_DEFAULT_REGION=US CENTRIFUGO_API_URL=http://centrifugo:8000/api CENTRIFUGO_SECRET=super_secret_centrifugo_token_key_hmac_256 CENTRIFUGO_API_KEY=centrifugo_internal_api_access_key + +# Resend Email Service +RESEND_API_KEY=re_7rkP5MPK_9gLDwdw1f23hzP3wsv2F6A8e +RESEND_FROM_EMAIL=onboarding@resend.dev + + diff --git a/apps/account/custom_user_login.py b/apps/account/custom_user_login.py index 8b64339..a7a30b1 100644 --- a/apps/account/custom_user_login.py +++ b/apps/account/custom_user_login.py @@ -11,8 +11,9 @@ class CustomLoginBackend(BaseBackend): Authenticate with username email and phone_number. """ - def authenticate(self, request, username=None, password=None): - if user := self.get_user(username): + def authenticate(self, request, username=None, password=None, email=None, **kwargs): + identifier = username or email or kwargs.get('username') or kwargs.get('email') + if user := self.get_user(identifier): if user.check_password(password): return user diff --git a/apps/account/serializers/auth_serializers.py b/apps/account/serializers/auth_serializers.py index 992c201..bad2ea7 100644 --- a/apps/account/serializers/auth_serializers.py +++ b/apps/account/serializers/auth_serializers.py @@ -1,13 +1,16 @@ from django.contrib.auth import get_user_model from django.contrib.auth.password_validation import validate_password +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from phonenumber_field.serializerfields import PhoneNumberField from apps.account.models.verification import VerificationDocument User = get_user_model() + class RegisterRequestSerializer(serializers.Serializer): email = serializers.EmailField(required=True) password = serializers.CharField(write_only=True, required=True, min_length=6) @@ -219,3 +222,34 @@ class PasswordResetRequestSerializer(serializers.Serializer): def validate_new_password(self, value): validate_password(value) return value + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """ + JWT token obtain serializer that accepts either 'username' or 'email' as the credential. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if 'email' in self.fields: + self.fields['email'].required = False + if 'username' not in self.fields: + self.fields['username'] = serializers.CharField(required=False, write_only=True) + + def to_internal_value(self, data): + if isinstance(data, dict): + mutable_data = data.copy() + identifier = mutable_data.get('email') or mutable_data.get('username') + if identifier: + mutable_data['email'] = identifier + mutable_data['username'] = identifier + return super().to_internal_value(mutable_data) + return super().to_internal_value(data) + + def validate(self, attrs): + identifier = attrs.get('email') or attrs.get('username') + if not identifier: + raise serializers.ValidationError({"email": _("This field is required.")}) + attrs['email'] = identifier + attrs['username'] = identifier + return super().validate(attrs) + diff --git a/apps/account/urls_auth.py b/apps/account/urls_auth.py index 8cfb572..6513548 100644 --- a/apps/account/urls_auth.py +++ b/apps/account/urls_auth.py @@ -1,10 +1,10 @@ from django.urls import path from rest_framework_simplejwt.views import ( - TokenObtainPairView, TokenRefreshView, TokenVerifyView, ) from apps.account.views.auth_views import ( + CustomTokenObtainPairView, RegisterView, UserMeView, VerificationDocumentListView, @@ -14,7 +14,7 @@ from apps.account.views.auth_views import ( urlpatterns = [ # SimpleJWT Token Lifecycle - path('token/', TokenObtainPairView.as_view(), name='auth_token_obtain_pair'), + path('token/', CustomTokenObtainPairView.as_view(), name='auth_token_obtain_pair'), path('token/refresh/', TokenRefreshView.as_view(), name='auth_token_refresh'), path('token/verify/', TokenVerifyView.as_view(), name='auth_token_verify'), diff --git a/apps/account/views/auth_views.py b/apps/account/views/auth_views.py index 64ebb7e..c8bf793 100644 --- a/apps/account/views/auth_views.py +++ b/apps/account/views/auth_views.py @@ -11,6 +11,7 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.views import TokenObtainPairView from drf_spectacular.utils import extend_schema, OpenApiResponse, inline_serializer from apps.account.models.verification import VerificationDocument @@ -23,12 +24,20 @@ from apps.account.serializers.auth_serializers import ( VerificationDocumentUploadSerializer, PasswordRecoverRequestSerializer, PasswordResetRequestSerializer, + CustomTokenObtainPairSerializer, ) logger = logging.getLogger(__name__) User = get_user_model() +class CustomTokenObtainPairView(TokenObtainPairView): + """ + Takes a set of user credentials (either 'username' or 'email' + password) and returns access & refresh token pair. + """ + serializer_class = CustomTokenObtainPairSerializer + + class RegisterView(GenericAPIView): permission_classes = [AllowAny] serializer_class = RegisterRequestSerializer diff --git a/apps/account/views/user.py b/apps/account/views/user.py index 5ac7836..bc9c0d5 100644 --- a/apps/account/views/user.py +++ b/apps/account/views/user.py @@ -249,17 +249,22 @@ class UserRegisterView(CreateAPIView): code = RedisManager.generate_otp_code() logger.info(f"phone= {data['email']}") - print(f'send {code}/{data["email"]}') + print("\n" + "=" * 50) + print(f" [OTP CODE]: {code} -> EMAIL: {data['email']}") + print("=" * 50 + "\n") phone_number = RedisManager().add_to_redis(code, **data) try: send_email([data['email']], code) except Exception as exp: print(f'-exp-register-->{exp}') + resp_data = { + "user": data, + "message": "The otp code was sent to the user's email", + } + if settings.DEBUG: + resp_data["otp_code"] = code return Response( - data= { - "user": data, - "message": "The otp code was sent to the user's email" - }, + data=resp_data, status=status.HTTP_202_ACCEPTED, ) @@ -417,23 +422,38 @@ class WebUserRegisterView(CreateAPIView): code = RedisManager.generate_otp_code() logger.info(f"phone= {data['email']}") - print(f'send {code}/{data["email"]}') + print("\n" + "=" * 50) + print(f" [OTP CODE]: {code} -> EMAIL: {data['email']}") + print("=" * 50 + "\n") # Store all registration data including password in Redis RedisManager().add_to_redis(code, **data) + email_sent = False try: - send_email([data['email']], code) + email_sent = send_email([data['email']], code) except Exception as exp: print(f'-exp-register-->{exp}') - return Response( - data={ - "user": { - "id": data.get('id'), - "fullname": data.get('fullname'), - "email": data.get('email'), - }, - "message": "The otp code was sent to the user's email" + + message = ( + "The otp code was sent to the user's email" + if email_sent + else "OTP code generated, but email delivery failed. Please check your email configuration or server logs." + ) + + resp_data = { + "user": { + "id": data.get('id'), + "fullname": data.get('fullname'), + "email": data.get('email'), }, - status=status.HTTP_202_ACCEPTED, + "email_sent": email_sent, + "message": message, + } + if settings.DEBUG: + resp_data["otp_code"] = code + + return Response( + data=resp_data, + status=status.HTTP_202_ACCEPTED if email_sent else status.HTTP_200_OK, ) @@ -566,15 +586,19 @@ class UserRecoverPassword(CreateAPIView): except Exception as exp: print(f'-exp-register-->{exp}') + resp_data = { + "id": user.id, + "fullname": user.fullname, + "phone_number": str(user.phone_number) if user.phone_number else None, + "email": user.email if user.email else None, + "avatar": absolute_https_url(user.avatar.url, request) if user.avatar else None, + "message": "Forgot password code sent", + } + if settings.DEBUG: + resp_data["otp_code"] = code + return Response( - data= { - "id": user.id, - "fullname": user.fullname, - "phone_number": str(user.phone_number) if user.phone_number else None, - "email": user.email if user.email else None, - "avatar": absolute_https_url(user.avatar.url, request) if user.avatar else None, - "message": "Forgot password code sent" - }, + data=resp_data, status=status.HTTP_202_ACCEPTED, ) diff --git a/config/redis_config.py b/config/redis_config.py index def87a3..8e76bc5 100644 --- a/config/redis_config.py +++ b/config/redis_config.py @@ -1,15 +1,21 @@ from redis import Redis, ConnectionPool +from django.conf import settings -from config.settings import base as settings +_pool = None - -pool = ConnectionPool.from_url(url= settings.REDIS_URL,max_connections=100) +def get_redis_pool(): + global _pool + if _pool is None: + redis_url = getattr(settings, 'REDIS_URL', 'redis://127.0.0.1:6379/0') + _pool = ConnectionPool.from_url(url=redis_url, max_connections=100) + return _pool class RedisConfig: def __init__(self): - self.redis = Redis(connection_pool=pool, decode_responses=True) + self.redis = Redis(connection_pool=get_redis_pool(), decode_responses=True) + \ No newline at end of file diff --git a/config/settings/base.py b/config/settings/base.py index 9565d12..6692f7c 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -413,3 +413,9 @@ if SENTRY_DSN: dsn=SENTRY_DSN, send_default_pii=True, ) + +# Resend Email Service +RESEND_API_KEY = env('RESEND_API_KEY', default='') +RESEND_FROM_EMAIL = env('RESEND_FROM_EMAIL', default='onboarding@resend.dev') + + diff --git a/config/settings/local.py b/config/settings/local.py index f1bea93..6ab5e91 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -16,14 +16,20 @@ DATABASES = { } # --------------------------------------------------------------------------- # -# Cache: local-memory (no Redis needed) +# Redis & Cache (local Redis service on port 6379) # --------------------------------------------------------------------------- # +REDIS_URL = 'redis://127.0.0.1:6379/0' CACHES = { 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': REDIS_URL, + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + } } } + # --------------------------------------------------------------------------- # # Celery: run tasks eagerly in-process (no broker needed) # --------------------------------------------------------------------------- # @@ -48,3 +54,10 @@ CORS_ALLOW_ALL_ORIGINS = True CENTRIFUGO_API_URL = 'http://localhost:8001/api' CENTRIFUGO_SECRET = 'local-dev-hmac-secret' CENTRIFUGO_API_KEY = 'local-dev-api-key' + +# Resend Email Service +RESEND_API_KEY = env('RESEND_API_KEY', default='re_7rkP5MPK_9gLDwdw1f23hzP3wsv2F6A8e') +RESEND_FROM_EMAIL = env('RESEND_FROM_EMAIL', default='onboarding@resend.dev') +RESEND_SANDBOX_REDIRECT_TO = env('RESEND_SANDBOX_REDIRECT_TO', default='alisina.a.a50182@gmail.com') + + diff --git a/manage.py b/manage.py index 40c4a37..5b01126 100644 --- a/manage.py +++ b/manage.py @@ -3,6 +3,13 @@ import os import sys +# Ensure UTF-8 output encoding on Windows consoles +if sys.platform == 'win32': + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + if hasattr(sys.stderr, 'reconfigure'): + sys.stderr.reconfigure(encoding='utf-8', errors='replace') + def main(): """Run administrative tasks.""" diff --git a/utils/__init__.py b/utils/__init__.py index 571d007..64f6532 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -112,108 +112,64 @@ def send_email(recipient, code): logger.error(f"Cannot send email: recipient list is empty ({recipient})") return False - from_email = getattr(settings, "RESEND_FROM_EMAIL", "Dovodi ") - if "<" not in from_email and "@" in from_email: - from_email = f"Dovodi <{from_email}>" + from_email = getattr(settings, "RESEND_FROM_EMAIL", "onboarding@resend.dev") + if "onboarding@resend.dev" in from_email: + from_email = "onboarding@resend.dev" + elif "<" not in from_email and "@" in from_email: + from_email = f"Muslim Connect Hub <{from_email}>" - site_domain = getattr(settings, "SITE_DOMAIN", "https://dovodi.newhorizonco.uk/").rstrip('/') - subject = "Код подтверждения | Verification Code" + subject = f"Your Verification Code: {code}" - html_content = f""" - - + # Handle Resend sandbox restriction: onboarding@resend.dev can only deliver to the account owner's email + sandbox_redirect = getattr(settings, "RESEND_SANDBOX_REDIRECT_TO", "alisina.a.a50182@gmail.com") + sandbox_notice_html = "" + sandbox_notice_text = "" + + if "onboarding@resend.dev" in from_email and sandbox_redirect: + non_sandbox_recipients = [r for r in to_emails if r.lower() != sandbox_redirect.lower()] + if non_sandbox_recipients: + orig_str = ", ".join(to_emails) + print(f"[Resend Sandbox Mode] Notice: Resend sandbox only delivers to {sandbox_redirect}.") + print(f"[Resend Sandbox Mode] Redirecting test email intended for [{orig_str}] -> {sandbox_redirect}") + subject = f"[Dev Test for {orig_str}] Your Verification Code: {code}" + sandbox_notice_html = f""" +
+ Sandbox Redirection Notice:
+ This email was intended for {orig_str}. Because Resend's free tier allows delivery only to the verified account email, it was routed to {sandbox_redirect} for testing. +
+ """ + sandbox_notice_text = f"[Notice: Resend test sandbox redirecting from {orig_str} to {sandbox_redirect}]\n\n" + to_emails = [sandbox_redirect] + + html_content = f""" + - - Код подтверждения | Verification Code + Verification Code - - - - - -
- - - - - - - - - - - - - - - - - -
 
- Dovodi -
- -
- - Безопасность / Security - -
- - -

- Код подтверждения -

- - -

- Используйте следующий одноразовый код для входа یا подтверждения учетной записи в Dovodi: -

- - -
- - {code} - -
- - -
- - - - -
- ⏱ Внимание: Срок действия кода истекает через 5 минут. Если вы не запрашивали этот код, просто проигнорируйте это письмо. -
-
- -

- Никому не передавайте этот код в целях безопасности вашего аккаунта. -

-
-

- Dovodi -

-

- dovodi.newhorizonco.uk -  •  - info@imamjavad.online -

-

- © 2026 Dovodi. Все права защищены. -

-
-
+ +
+ {sandbox_notice_html} +

Muslim Connect Hub

+

+ Use the following verification code to complete your registration: +

+
+ + {code} + +
+

+ This code will expire in 5 minutes. If you did not request this code, please ignore this email. +

+
- -""" +""" text_content = ( - f"Код подтверждения Dovodi: {code}\n\n" - f"Your Dovodi verification code is: {code}\n\n" - f"Срок действия кода истекает через 5 минут. Если вы не запрашивали этот код, проигнорируйте это письмо.\n" - f"Никому не передавайте этот код в целях безопасности вашего аккаунта." + f"{sandbox_notice_text}" + f"Muslim Connect Hub Verification Code: {code}\n\n" + f"This code will expire in 5 minutes. If you did not request this code, please ignore this email." ) payload = { @@ -226,9 +182,9 @@ def send_email(recipient, code): try: response = requests.post(url, headers=headers, json=payload, timeout=15) + print(f"[Resend API] status={response.status_code} response={response.text}") if response.status_code >= 400: logger.error(f"Failed to send email via Resend: HTTP {response.status_code} - {response.text}") - print(f"Failed to send email via Resend: HTTP {response.status_code} - {response.text}") return False logger.info(f"Email OTP sent successfully via Resend to {to_emails}") return True diff --git a/utils/exceptions.py b/utils/exceptions.py index 4476008..3650717 100644 --- a/utils/exceptions.py +++ b/utils/exceptions.py @@ -29,7 +29,10 @@ def exception_handler(exc, context): if response is not None: # تعیین نوع خطاها - print(f'>>> {type(exc)}/ {exc}') + try: + print(f'>>> {type(exc)}/ {exc}') + except Exception: + pass if isinstance(exc, ValidationError): error_code = "validation_error" @@ -44,7 +47,10 @@ def exception_handler(exc, context): else: # فرمت کردن خطاها به فرمت جدید - print(f'>>>>>> {response.data}') + try: + print(f'>>>>>> {response.data}') + except Exception: + pass for field, errors in response.data.items(): if isinstance(errors, list): for error in errors: diff --git a/utils/redis.py b/utils/redis.py index 46a0086..cc9fc04 100644 --- a/utils/redis.py +++ b/utils/redis.py @@ -63,7 +63,7 @@ class RedisManager(RedisConfig): data = self.redis.get(key) if not data: return None - raw = data.decode() + raw = data.decode() if hasattr(data, 'decode') else data if raw.startswith('{'): return json.loads(raw) return self.__deserialize(raw)