feat(backend): migrate auth and notifications off email
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
"""Authentication-related API schemas."""
|
||||
|
||||
from ninja import Schema, ModelSchema
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
from apps.users.models import User
|
||||
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
|
||||
|
||||
|
||||
class UserRegistrationSchema(Schema):
|
||||
username: str
|
||||
email: str
|
||||
mobile: str
|
||||
code: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
@@ -18,10 +22,71 @@ class UserRegistrationSchema(Schema):
|
||||
year_of_study: Optional[int] = None
|
||||
major: Optional[str] = None
|
||||
|
||||
|
||||
class UserLoginSchema(Schema):
|
||||
email: str
|
||||
identifier: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserOtpLoginSchema(Schema):
|
||||
mobile: str
|
||||
code: str
|
||||
|
||||
|
||||
class RegisterOtpVerifySchema(Schema):
|
||||
mobile: str
|
||||
code: str
|
||||
|
||||
|
||||
class OtpSendSchema(Schema):
|
||||
mobile: str
|
||||
mode: str
|
||||
|
||||
|
||||
class MobileOtpSendSchema(Schema):
|
||||
mobile: str
|
||||
|
||||
|
||||
class MobileOtpVerifySchema(Schema):
|
||||
mobile: str
|
||||
code: str
|
||||
|
||||
|
||||
class GoogleFlowSchema(Schema):
|
||||
flow: str
|
||||
|
||||
|
||||
class GoogleClaimVerifySchema(Schema):
|
||||
flow: str
|
||||
code: str
|
||||
|
||||
|
||||
class GoogleCompleteSchema(Schema):
|
||||
flow: str
|
||||
mobile: str
|
||||
username: Optional[str] = None
|
||||
student_id: Optional[str] = None
|
||||
year_of_study: Optional[int] = None
|
||||
major: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
|
||||
class GoogleFlowResponseSchema(Schema):
|
||||
status: str
|
||||
email: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
resolution: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
mobile_hint: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
access_token: Optional[str] = None
|
||||
refresh_token: Optional[str] = None
|
||||
|
||||
|
||||
class UserProfileSchema(ModelSchema):
|
||||
profile_picture: Optional[str] = None
|
||||
profile_picture_thumbnail_url: Optional[str] = None
|
||||
@@ -29,27 +94,32 @@ class UserProfileSchema(ModelSchema):
|
||||
student_id: Optional[str] = None
|
||||
major: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
requires_mobile_verification: bool
|
||||
has_google_link: bool
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id',
|
||||
'username',
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'student_id',
|
||||
'year_of_study',
|
||||
'major',
|
||||
'university',
|
||||
'bio',
|
||||
'date_joined',
|
||||
'is_email_verified',
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'is_deleted',
|
||||
'deleted_at',
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"mobile",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"student_id",
|
||||
"year_of_study",
|
||||
"major",
|
||||
"university",
|
||||
"bio",
|
||||
"date_joined",
|
||||
"is_email_verified",
|
||||
"is_mobile_verified",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"is_deleted",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -60,14 +130,18 @@ class UserProfileSchema(ModelSchema):
|
||||
def resolve_university(obj):
|
||||
return obj.get_university_display()
|
||||
|
||||
@staticmethod
|
||||
def resolve_requires_mobile_verification(obj):
|
||||
return obj.requires_mobile_verification
|
||||
|
||||
@staticmethod
|
||||
def resolve_has_google_link(obj):
|
||||
return obj.has_google_link
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture(obj, context):
|
||||
"""
|
||||
Resolves the absolute URL for the profile picture.
|
||||
`context` contains the request object, which is needed for build_absolute_uri.
|
||||
"""
|
||||
request = context['request']
|
||||
if obj.profile_picture and hasattr(obj.profile_picture, 'url'):
|
||||
request = context["request"]
|
||||
if obj.profile_picture and hasattr(obj.profile_picture, "url"):
|
||||
return request.build_absolute_uri(obj.profile_picture.url)
|
||||
return None
|
||||
|
||||
@@ -87,27 +161,26 @@ class UserProfileSchema(ModelSchema):
|
||||
class UserListSchema(ModelSchema):
|
||||
major: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id',
|
||||
'username',
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'date_joined',
|
||||
'major',
|
||||
'university',
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"mobile",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"date_joined",
|
||||
"major",
|
||||
"university",
|
||||
"is_mobile_verified",
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_full_name(obj):
|
||||
return obj.get_full_name()
|
||||
|
||||
@staticmethod
|
||||
def resolve_major(obj):
|
||||
return obj.get_major_display()
|
||||
@@ -116,7 +189,9 @@ class UserListSchema(ModelSchema):
|
||||
def resolve_university(obj):
|
||||
return obj.get_university_display()
|
||||
|
||||
|
||||
class UserUpdateSchema(Schema):
|
||||
email: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
@@ -125,20 +200,33 @@ class UserUpdateSchema(Schema):
|
||||
university: Optional[str] = None
|
||||
student_id: Optional[str] = None
|
||||
|
||||
|
||||
class TokenSchema(Schema):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenRefreshIn(Schema):
|
||||
refresh_token: str
|
||||
|
||||
class PasswordResetRequestSchema(Schema):
|
||||
email: str
|
||||
|
||||
class PasswordResetConfirmSchema(Schema):
|
||||
token: str
|
||||
class PasswordResetSchema(Schema):
|
||||
mobile: str
|
||||
code: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UsernameCheckSchema(Schema):
|
||||
exists: bool
|
||||
|
||||
|
||||
class MobileLookupSchema(Schema):
|
||||
exists: bool
|
||||
has_password: bool
|
||||
|
||||
|
||||
class OtpSendResponseSchema(Schema):
|
||||
message: str
|
||||
expires_in_seconds: int
|
||||
expires_at: datetime
|
||||
|
||||
@@ -1,36 +1,80 @@
|
||||
from typing import List
|
||||
from __future__ import annotations
|
||||
|
||||
import jwt
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import uuid
|
||||
import jwt
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Query, Router
|
||||
|
||||
from core.media import delete_image_derivatives
|
||||
from apps.users.models import User, Major, University
|
||||
from apps.users.tasks import send_verification_email, send_password_reset_email
|
||||
from apps.users.api.schemas import (
|
||||
PasswordResetConfirmSchema,
|
||||
PasswordResetRequestSchema,
|
||||
GoogleClaimVerifySchema,
|
||||
GoogleCompleteSchema,
|
||||
GoogleFlowResponseSchema,
|
||||
GoogleFlowSchema,
|
||||
MobileLookupSchema,
|
||||
MobileOtpSendSchema,
|
||||
MobileOtpVerifySchema,
|
||||
OtpSendResponseSchema,
|
||||
OtpSendSchema,
|
||||
PasswordResetSchema,
|
||||
RegisterOtpVerifySchema,
|
||||
TokenRefreshIn,
|
||||
TokenSchema,
|
||||
UserListSchema,
|
||||
UserLoginSchema,
|
||||
UserOtpLoginSchema,
|
||||
UserProfileSchema,
|
||||
UserRegistrationSchema,
|
||||
UserUpdateSchema,
|
||||
UsernameCheckSchema,
|
||||
)
|
||||
from apps.users.email_identity import normalize_email_identity
|
||||
from apps.users.models import Major, University, User
|
||||
from apps.users.services.auth import (
|
||||
AuthServiceError,
|
||||
RegistrationPayload,
|
||||
generate_and_send_otp,
|
||||
get_tokens_for_user,
|
||||
login_with_otp,
|
||||
login_with_password,
|
||||
lookup_mobile_registration_state,
|
||||
register_user,
|
||||
reset_password_with_otp,
|
||||
send_authenticated_mobile_otp,
|
||||
verify_register_otp,
|
||||
verify_authenticated_mobile_otp,
|
||||
)
|
||||
from apps.users.services.google_oauth import (
|
||||
GoogleOAuthFlowError,
|
||||
build_authenticated_flow_payload,
|
||||
build_google_authorization_url,
|
||||
build_google_callback_redirect_url,
|
||||
build_pending_google_flow_payload,
|
||||
complete_google_signup,
|
||||
consume_google_state,
|
||||
create_google_flow,
|
||||
exchange_code_for_google_profile,
|
||||
find_social_account_for_profile,
|
||||
get_google_flow,
|
||||
send_google_claim_otp,
|
||||
sync_user_from_google_profile,
|
||||
verify_google_claim,
|
||||
)
|
||||
from core.api.schemas import ErrorSchema, MessageSchema
|
||||
from core.authentication import create_jwt_token, create_refresh_token, jwt_auth
|
||||
from core.media import delete_image_derivatives
|
||||
|
||||
auth_router = Router()
|
||||
|
||||
|
||||
def _error_response(exc: AuthServiceError | GoogleOAuthFlowError):
|
||||
return exc.status_code, {"error": exc.message}
|
||||
|
||||
|
||||
def _get_major_from_code(code: str | None):
|
||||
if not code:
|
||||
return None
|
||||
@@ -45,84 +89,90 @@ def _get_university_from_code(code: str | None):
|
||||
|
||||
@auth_router.post("/register", response={201: MessageSchema, 400: ErrorSchema})
|
||||
def register(request, data: UserRegistrationSchema):
|
||||
"""Register a new user"""
|
||||
try:
|
||||
if data.student_id and len(str(data.student_id)) < 10:
|
||||
return 400, {"error": "Student ID must be at least 10 characters long."}
|
||||
|
||||
major_obj = None
|
||||
if data.major:
|
||||
major_obj = _get_major_from_code(data.major)
|
||||
if not major_obj:
|
||||
return 400, {"error": "Selected major is not recognized."}
|
||||
|
||||
university_obj = None
|
||||
if data.university:
|
||||
university_obj = _get_university_from_code(data.university)
|
||||
if not university_obj:
|
||||
return 400, {"error": "Selected university is not recognized."}
|
||||
|
||||
if User.objects.filter(username=data.username).exists():
|
||||
return 400, {"error": "Username is already in use."}
|
||||
|
||||
if User.objects.filter(email=data.email).exists():
|
||||
return 400, {"error": "Email is already registered."}
|
||||
|
||||
if (
|
||||
data.student_id
|
||||
and university_obj
|
||||
and User.objects.filter(
|
||||
university=university_obj, student_id=data.student_id
|
||||
).exists()
|
||||
):
|
||||
return 400, {"error": "This student ID is already registered at that university."}
|
||||
|
||||
User.objects.create_user(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
password=data.password,
|
||||
student_id=data.student_id,
|
||||
first_name=data.first_name or "",
|
||||
last_name=data.last_name or "",
|
||||
year_of_study=data.year_of_study,
|
||||
major=major_obj,
|
||||
university=university_obj,
|
||||
user = register_user(
|
||||
RegistrationPayload(
|
||||
username=data.username,
|
||||
mobile=data.mobile,
|
||||
password=data.password,
|
||||
code=data.code,
|
||||
email=data.email,
|
||||
first_name=data.first_name or "",
|
||||
last_name=data.last_name or "",
|
||||
university=data.university,
|
||||
student_id=data.student_id,
|
||||
year_of_study=data.year_of_study,
|
||||
major=data.major,
|
||||
)
|
||||
)
|
||||
except AuthServiceError as exc:
|
||||
return _error_response(exc)
|
||||
return 201, {"message": f"ثبتنام با موفقیت انجام شد. خوش آمدید {user.get_full_name() or user.username}."}
|
||||
|
||||
return 201, {"message": "Registration successful. Please check your inbox to verify your email."}
|
||||
|
||||
except Exception as e:
|
||||
return 400, {
|
||||
"error": "Unable to register user.",
|
||||
"details": str(e),
|
||||
}
|
||||
@auth_router.post("/otp/send", response={200: OtpSendResponseSchema, 400: ErrorSchema})
|
||||
def send_otp(request, data: OtpSendSchema):
|
||||
try:
|
||||
payload = generate_and_send_otp(data.mobile, data.mode)
|
||||
except AuthServiceError as exc:
|
||||
return _error_response(exc)
|
||||
return 200, payload
|
||||
|
||||
@auth_router.post("/login", response={200: TokenSchema, 401: ErrorSchema})
|
||||
|
||||
@auth_router.post("/otp/verify-register", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def verify_register_otp_view(request, data: RegisterOtpVerifySchema):
|
||||
try:
|
||||
verify_register_otp(data.mobile, data.code)
|
||||
except AuthServiceError as exc:
|
||||
return _error_response(exc)
|
||||
return 200, {"message": "کد تایید با موفقیت تایید شد."}
|
||||
|
||||
|
||||
@auth_router.post("/login", response={200: TokenSchema, 401: ErrorSchema, 400: ErrorSchema})
|
||||
def login(request, data: UserLoginSchema):
|
||||
"""Login user and return JWT tokens"""
|
||||
user = authenticate(email=data.email, password=data.password)
|
||||
|
||||
if not user:
|
||||
return 401, {"error": "ایمیل یا رمز عبور نادرست است."}
|
||||
|
||||
if not user.is_email_verified:
|
||||
return 401, {"error": "برای ورود، ابتدا ایمیل خود را تأیید کنید."}
|
||||
|
||||
if not user.is_active:
|
||||
return 401, {"error": "حساب کاربری شما غیرفعال است."}
|
||||
|
||||
access_token = create_jwt_token(user)
|
||||
refresh_token = create_refresh_token(user)
|
||||
|
||||
return 200, {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
try:
|
||||
return 200, login_with_password(data.identifier, data.password)
|
||||
except AuthServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@auth_router.post("/login/otp", response={200: TokenSchema, 401: ErrorSchema, 400: ErrorSchema})
|
||||
def login_otp(request, data: UserOtpLoginSchema):
|
||||
try:
|
||||
return 200, login_with_otp(data.mobile, data.code)
|
||||
except AuthServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@auth_router.post("/reset-password", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def reset_password(request, data: PasswordResetSchema):
|
||||
try:
|
||||
reset_password_with_otp(data.mobile, data.code, data.new_password)
|
||||
except AuthServiceError as exc:
|
||||
return _error_response(exc)
|
||||
return 200, {"message": "رمز عبور با موفقیت تغییر کرد."}
|
||||
|
||||
|
||||
@auth_router.post("/mobile/send-otp", response={200: OtpSendResponseSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def send_mobile_verify_otp(request, data: MobileOtpSendSchema):
|
||||
try:
|
||||
payload = send_authenticated_mobile_otp(request.auth, data.mobile)
|
||||
except AuthServiceError as exc:
|
||||
return _error_response(exc)
|
||||
return 200, payload
|
||||
|
||||
|
||||
@auth_router.post("/mobile/verify", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def verify_mobile(request, data: MobileOtpVerifySchema):
|
||||
try:
|
||||
user = verify_authenticated_mobile_otp(request.auth, data.mobile, data.code)
|
||||
except AuthServiceError as exc:
|
||||
return _error_response(exc)
|
||||
return 200, user
|
||||
|
||||
|
||||
@auth_router.post("/refresh", response={200: TokenSchema, 401: ErrorSchema})
|
||||
def refresh_tokens(request, data: TokenRefreshIn):
|
||||
"""Exchange a valid refresh token for a new access (and refresh) token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
data.refresh_token,
|
||||
@@ -136,77 +186,137 @@ def refresh_tokens(request, data: TokenRefreshIn):
|
||||
if not user_id:
|
||||
return 401, {"error": "دادههای توکن نامعتبر است."}
|
||||
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
|
||||
if not user.is_email_verified:
|
||||
return 401, {"error": "برای استفاده، ابتدا ایمیل خود را تأیید کنید."}
|
||||
|
||||
if not user.is_active:
|
||||
return 401, {"error": "حساب کاربری شما غیرفعال است."}
|
||||
|
||||
user = get_object_or_404(User, id=user_id, is_active=True)
|
||||
except jwt.ExpiredSignatureError:
|
||||
return 401, {"error": "رفرشتوکن منقضی شده است."}
|
||||
|
||||
except jwt.InvalidTokenError:
|
||||
return 401, {"error": "رفرشتوکن نامعتبر است."}
|
||||
|
||||
access_token = create_jwt_token(user)
|
||||
refresh_token = create_refresh_token(user)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"access_token": create_jwt_token(user),
|
||||
"refresh_token": create_refresh_token(user),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
@auth_router.get("/verify-email/{token}", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def verify_email(request, token: str):
|
||||
"""Verify user email with token"""
|
||||
try:
|
||||
user = get_object_or_404(User, email_verification_token=token)
|
||||
|
||||
if user.is_email_verified:
|
||||
return 400, {"error": "ایمیل قبلاً تأیید شده است."}
|
||||
|
||||
user.is_email_verified = True
|
||||
user.save(update_fields=['is_email_verified'])
|
||||
|
||||
return 200, {"message": "ایمیل شما با موفقیت تأیید شد."}
|
||||
|
||||
except User.DoesNotExist:
|
||||
return 400, {"error": "توکن تأیید نامعتبر است."}
|
||||
|
||||
@auth_router.post("/resend-verification", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def resend_verification(request, email: str):
|
||||
"""Resend verification email"""
|
||||
@auth_router.get("/oauth/google/start")
|
||||
def google_oauth_start(request):
|
||||
return HttpResponseRedirect(build_google_authorization_url())
|
||||
|
||||
|
||||
@auth_router.get("/oauth/google/callback")
|
||||
def google_oauth_callback(request):
|
||||
if request.GET.get("error"):
|
||||
flow = create_google_flow(
|
||||
{
|
||||
"status": "error",
|
||||
"detail": request.GET.get("error_description") or "Google sign-in was cancelled.",
|
||||
}
|
||||
)
|
||||
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
|
||||
|
||||
try:
|
||||
user = get_object_or_404(User, email=email)
|
||||
|
||||
if user.is_email_verified:
|
||||
return 400, {"error": "ایمیل قبلاً تأیید شده است."}
|
||||
|
||||
# Generate new token
|
||||
user.regenerate_verification_token()
|
||||
user.email_verification_sent_at = timezone.now()
|
||||
user.save(update_fields=['email_verification_sent_at'])
|
||||
|
||||
# Send verification email
|
||||
verification_url = f"{settings.FRONTEND_ROOT}verify-email/{user.email_verification_token}"
|
||||
send_verification_email.delay(user.id, verification_url)
|
||||
|
||||
return 200, {"message": "ایمیل تأیید برای شما ارسال شد."}
|
||||
|
||||
except User.DoesNotExist:
|
||||
return 400, {"error": "کاربر یافت نشد."}
|
||||
consume_google_state(request.GET.get("state"))
|
||||
profile = exchange_code_for_google_profile(request.GET.get("code"))
|
||||
social_account = find_social_account_for_profile(profile)
|
||||
|
||||
if social_account and social_account.user.is_mobile_verified:
|
||||
sync_user_from_google_profile(social_account.user, profile)
|
||||
flow_payload = build_authenticated_flow_payload(social_account.user)
|
||||
elif social_account:
|
||||
sync_user_from_google_profile(social_account.user, profile)
|
||||
flow_payload = build_pending_google_flow_payload(profile)
|
||||
flow_payload["resolution"] = "existing_email_claim"
|
||||
flow_payload["target_user_id"] = social_account.user.id
|
||||
flow_payload["mobile_hint"] = social_account.user.mobile
|
||||
else:
|
||||
flow_payload = build_pending_google_flow_payload(profile)
|
||||
|
||||
flow = create_google_flow(flow_payload)
|
||||
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
|
||||
except GoogleOAuthFlowError as exc:
|
||||
flow = create_google_flow({"status": "error", "detail": exc.message})
|
||||
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
|
||||
|
||||
|
||||
@auth_router.get("/oauth/google/flow", response={200: GoogleFlowResponseSchema, 400: ErrorSchema})
|
||||
def google_oauth_flow(request, flow: str):
|
||||
try:
|
||||
return 200, get_google_flow(flow)
|
||||
except GoogleOAuthFlowError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@auth_router.post("/oauth/google/complete", response={200: GoogleFlowResponseSchema, 400: ErrorSchema})
|
||||
def google_oauth_complete(request, data: GoogleCompleteSchema):
|
||||
try:
|
||||
payload = complete_google_signup(
|
||||
flow=data.flow,
|
||||
mobile=data.mobile,
|
||||
username=data.username,
|
||||
student_id=data.student_id,
|
||||
year_of_study=data.year_of_study,
|
||||
major=data.major,
|
||||
university=data.university,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
)
|
||||
return 200, payload
|
||||
except (GoogleOAuthFlowError, AuthServiceError) as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@auth_router.post("/oauth/google/claim/send-otp", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def google_oauth_claim_send_otp(request, data: GoogleFlowSchema):
|
||||
try:
|
||||
return 200, send_google_claim_otp(data.flow)
|
||||
except (GoogleOAuthFlowError, AuthServiceError) as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@auth_router.post("/oauth/google/claim/verify", response={200: GoogleFlowResponseSchema, 400: ErrorSchema})
|
||||
def google_oauth_claim_verify(request, data: GoogleClaimVerifySchema):
|
||||
try:
|
||||
return 200, verify_google_claim(data.flow, data.code)
|
||||
except (GoogleOAuthFlowError, AuthServiceError) as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
|
||||
@auth_router.get("/verify-email/{token}", response=MessageSchema)
|
||||
def verify_email_legacy_guidance(request, token: str):
|
||||
return {
|
||||
"message": "تایید ایمیل غیرفعال شده است. برای بازیابی حساب از ورود با گوگل یا تایید شماره موبایل استفاده کنید."
|
||||
}
|
||||
|
||||
|
||||
@auth_router.post("/resend-verification", response=MessageSchema)
|
||||
def resend_verification_legacy_guidance(request, email: str):
|
||||
return {
|
||||
"message": "ارسال ایمیل تایید غیرفعال شده است. برای ادامه، شماره موبایل خود را ثبت یا از ورود با گوگل استفاده کنید."
|
||||
}
|
||||
|
||||
|
||||
@auth_router.post("/request-password-reset", response=MessageSchema)
|
||||
def request_password_reset_legacy_guidance(request):
|
||||
return {
|
||||
"message": "بازیابی رمز عبور با ایمیل غیرفعال شده است. از بازیابی با کد پیامکی یا ورود با گوگل استفاده کنید."
|
||||
}
|
||||
|
||||
|
||||
@auth_router.post("/reset-password-confirm", response=MessageSchema)
|
||||
def reset_password_confirm_legacy_guidance(request):
|
||||
return {
|
||||
"message": "لینک بازیابی ایمیلی غیرفعال شده است. لطفاً از بازیابی رمز عبور با موبایل استفاده کنید."
|
||||
}
|
||||
|
||||
|
||||
@auth_router.get("/profile", response=UserProfileSchema, auth=jwt_auth)
|
||||
def get_profile(request):
|
||||
"""Get current user profile"""
|
||||
return request.auth
|
||||
|
||||
|
||||
@auth_router.put("/profile", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def update_profile(request, data: UserUpdateSchema):
|
||||
"""Update current user profile"""
|
||||
user = request.auth
|
||||
payload = data.dict(exclude_unset=True)
|
||||
|
||||
@@ -215,7 +325,7 @@ def update_profile(request, data: UserUpdateSchema):
|
||||
if code:
|
||||
major_obj = _get_major_from_code(code)
|
||||
if not major_obj:
|
||||
return 400, {"error": "UcO_ O<>OrU?UOU? O<>U^UcU+ O<>O<EFBFBD>UOUOO_."}
|
||||
return 400, {"error": "رشته انتخابی معتبر نیست."}
|
||||
payload["major"] = major_obj
|
||||
else:
|
||||
payload["major"] = None
|
||||
@@ -225,108 +335,61 @@ def update_profile(request, data: UserUpdateSchema):
|
||||
if code:
|
||||
uni_obj = _get_university_from_code(code)
|
||||
if not uni_obj:
|
||||
return 400, {"error": "UcO U.U^OO<>O_ O<>U^UcU+ O<>O<EFBFBD>UOUOO_."}
|
||||
return 400, {"error": "دانشگاه انتخابی معتبر نیست."}
|
||||
payload["university"] = uni_obj
|
||||
else:
|
||||
payload["university"] = None
|
||||
|
||||
if "email" in payload:
|
||||
normalized_email = normalize_email_identity(payload["email"])
|
||||
existing = User.objects.filter(email=normalized_email).exclude(pk=user.pk) if normalized_email else User.objects.none()
|
||||
if existing.exists():
|
||||
return 400, {"error": "این ایمیل قبلاً ثبت شده است."}
|
||||
payload["email"] = normalized_email
|
||||
payload["is_email_verified"] = False if normalized_email else False
|
||||
|
||||
for field, value in payload.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
user.save()
|
||||
return 200, user
|
||||
|
||||
|
||||
@auth_router.post("/profile/picture", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def upload_profile_picture(request):
|
||||
"""Upload profile picture"""
|
||||
if 'file' not in request.FILES:
|
||||
if "file" not in request.FILES:
|
||||
return 400, {"error": "فایلی ارسال نشده است."}
|
||||
|
||||
file = request.FILES['file']
|
||||
|
||||
# Validate file type
|
||||
if not file.content_type.startswith('image/'):
|
||||
|
||||
file = request.FILES["file"]
|
||||
if not file.content_type.startswith("image/"):
|
||||
return 400, {"error": "فایل باید از نوع تصویر باشد."}
|
||||
|
||||
# Validate file size (5MB max)
|
||||
if file.size > 5 * 1024 * 1024:
|
||||
return 400, {"error": "حجم فایل باید کمتر از ۵ مگابایت باشد."}
|
||||
|
||||
|
||||
user = request.auth
|
||||
|
||||
# Save new profile picture
|
||||
filename = f"profile_pictures/{user.id}_{uuid.uuid4().hex}.{file.name.split('.')[-1]}"
|
||||
user.profile_picture.save(filename, ContentFile(file.read()))
|
||||
|
||||
return 200, {"message": "تصویر پروفایل با موفقیت بهروزرسانی شد."}
|
||||
|
||||
|
||||
@auth_router.delete("/profile/picture", response={200: MessageSchema}, auth=jwt_auth)
|
||||
def delete_profile_picture(request):
|
||||
"""Delete current user's profile picture"""
|
||||
user = request.auth
|
||||
|
||||
if user.profile_picture:
|
||||
delete_image_derivatives(user.profile_picture, "profile_picture", delete_original=True)
|
||||
user.profile_picture = None
|
||||
user.save(update_fields=['profile_picture'])
|
||||
|
||||
user.save(update_fields=["profile_picture"])
|
||||
return 200, {"message": "تصویر پروفایل با موفقیت حذف شد."}
|
||||
|
||||
@auth_router.post("/request-password-reset", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def request_password_reset(request, data: PasswordResetRequestSchema):
|
||||
"""Request a password reset email"""
|
||||
try:
|
||||
user = get_object_or_404(User, email=data.email)
|
||||
user.set_password_reset_token()
|
||||
|
||||
reset_url = f"{settings.FRONTEND_PASSWORD_RESET_PAGE}/{user.password_reset_token}"
|
||||
send_password_reset_email.delay(user.id, reset_url)
|
||||
|
||||
# پیام عمومیِ یکسان برای جلوگیری از افشای وجود/عدم وجود ایمیل
|
||||
return 200, {"message": "اگر حسابی با این ایمیل وجود داشته باشد، لینک بازنشانی رمز عبور ارسال خواهد شد."}
|
||||
|
||||
except User.DoesNotExist:
|
||||
return 200, {"message": "اگر حسابی با این ایمیل وجود داشته باشد، لینک بازنشانی رمز عبور ارسال خواهد شد."}
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "درخواست بازنشانی رمز عبور انجام نشد.", "details": str(e)}
|
||||
|
||||
@auth_router.post("/reset-password-confirm", response={200: MessageSchema, 400: ErrorSchema})
|
||||
def reset_password_confirm(request, data: PasswordResetConfirmSchema):
|
||||
"""Confirm password reset with token and new password"""
|
||||
try:
|
||||
user = get_object_or_404(User, password_reset_token=data.token)
|
||||
|
||||
if user.password_reset_token_expires_at < timezone.now():
|
||||
user.password_reset_token = None
|
||||
user.password_reset_token_expires_at = None
|
||||
user.save(update_fields=['password_reset_token', 'password_reset_token_expires_at'])
|
||||
return 400, {"error": "زمان استفاده از لینک تغییر رمز عبور به پایان رسیده است. لطفاً دوباره اقدام کنید."}
|
||||
|
||||
user.set_password(data.new_password)
|
||||
user.password_reset_token = None
|
||||
user.password_reset_token_expires_at = None
|
||||
user.save(update_fields=['password', 'password_reset_token', 'password_reset_token_expires_at'])
|
||||
|
||||
return 200, {"message": "رمز عبور شما با موفقیت تغییر کرد."}
|
||||
|
||||
except User.DoesNotExist:
|
||||
return 400, {"error": "توکن بازنشانی رمز عبور نامعتبر یا منقضی شده است."}
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "تغییر رمز عبور انجام نشد.", "details": str(e)}
|
||||
|
||||
@auth_router.get("/users/deleted", response={200: List[UserProfileSchema], 403: ErrorSchema}, auth=jwt_auth)
|
||||
@auth_router.get("/users/deleted", response={200: list[UserProfileSchema], 403: ErrorSchema}, auth=jwt_auth)
|
||||
def list_deleted_users(request):
|
||||
"""List soft-deleted users via the dedicated manager (Admin/Committee only)."""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
return User.deleted_objects.all()
|
||||
|
||||
|
||||
@auth_router.post("/users/{user_id}/restore", response={200: MessageSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
||||
def restore_user(request, user_id: int):
|
||||
"""Restore a soft-deleted user (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
@@ -336,10 +399,9 @@ def restore_user(request, user_id: int):
|
||||
return 200, {"message": f"کاربر {user.username} با موفقیت بازیابی شد."}
|
||||
except User.DoesNotExist:
|
||||
return 400, {"error": "کاربر یافت نشد یا حذف نرم نشده است."}
|
||||
except Exception as e:
|
||||
return 400, {"error": "بازیابی کاربر انجام نشد.", "details": str(e)}
|
||||
|
||||
@auth_router.get("/users", response={200: List[UserListSchema], 403: ErrorSchema}, auth=jwt_auth)
|
||||
|
||||
@auth_router.get("/users", response={200: list[UserListSchema], 403: ErrorSchema}, auth=jwt_auth)
|
||||
def list_users(
|
||||
request,
|
||||
search: str | None = Query(None),
|
||||
@@ -356,43 +418,42 @@ def list_users(
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
queryset = User.objects.order_by("-date_joined")
|
||||
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(username__icontains=search)
|
||||
| Q(email__icontains=search)
|
||||
| Q(mobile__icontains=search)
|
||||
| Q(first_name__icontains=search)
|
||||
| Q(last_name__icontains=search)
|
||||
)
|
||||
|
||||
if role == "staff":
|
||||
queryset = queryset.filter(is_staff=True)
|
||||
elif role == "superuser":
|
||||
queryset = queryset.filter(is_superuser=True)
|
||||
|
||||
if student_id:
|
||||
queryset = queryset.filter(student_id__icontains=student_id)
|
||||
|
||||
if university:
|
||||
queryset = queryset.filter(
|
||||
Q(university__code__icontains=university) | Q(university__name__icontains=university)
|
||||
)
|
||||
|
||||
if major:
|
||||
queryset = queryset.filter(
|
||||
Q(major__code__icontains=major) | Q(major__name__icontains=major)
|
||||
)
|
||||
|
||||
queryset = queryset.filter(Q(major__code__icontains=major) | Q(major__name__icontains=major))
|
||||
if is_active is not None:
|
||||
if is_active.lower() in ("true", "1"):
|
||||
queryset = queryset.filter(is_active=True)
|
||||
elif is_active.lower() in ("false", "0"):
|
||||
queryset = queryset.filter(is_active=False)
|
||||
|
||||
return queryset[offset : offset + limit]
|
||||
|
||||
|
||||
@auth_router.get("/check-username", response=UsernameCheckSchema)
|
||||
def check_username_availability(request, username: str):
|
||||
"""Check if a username is available for registration"""
|
||||
exists = User.objects.filter(username=username).exists()
|
||||
return {"exists": exists}
|
||||
return {"exists": User.objects.filter(username=username).exists()}
|
||||
|
||||
|
||||
@auth_router.get("/check-mobile", response={200: MobileLookupSchema, 400: ErrorSchema})
|
||||
def check_mobile_availability(request, mobile: str):
|
||||
try:
|
||||
return 200, lookup_mobile_registration_state(mobile)
|
||||
except AuthServiceError as exc:
|
||||
return _error_response(exc)
|
||||
|
||||
35
apps/users/email_identity.py
Normal file
35
apps/users/email_identity.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
PLACEHOLDER_EMAIL_SUFFIX = "@noemail.local"
|
||||
|
||||
|
||||
def normalize_email_identity(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def normalize_mobile_number(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = "".join(ch for ch in value if ch.isdigit())
|
||||
return normalized or None
|
||||
|
||||
|
||||
def is_valid_mobile_number(value: str | None) -> bool:
|
||||
normalized = normalize_mobile_number(value)
|
||||
return bool(normalized and len(normalized) == 11 and normalized.startswith("09"))
|
||||
|
||||
|
||||
def mask_mobile(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
if len(value) <= 4:
|
||||
return value
|
||||
return f"{value[:2]}{'*' * max(len(value) - 6, 1)}{value[-4:]}"
|
||||
|
||||
|
||||
def is_placeholder_email(value: str | None) -> bool:
|
||||
normalized = normalize_email_identity(value)
|
||||
return bool(normalized and normalized.endswith(PLACEHOLDER_EMAIL_SUFFIX))
|
||||
1
apps/users/management/__init__.py
Normal file
1
apps/users/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""User management commands."""
|
||||
1
apps/users/management/commands/__init__.py
Normal file
1
apps/users/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""User management commands package."""
|
||||
@@ -0,0 +1,42 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.users.email_identity import is_placeholder_email, normalize_email_identity
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Audit legacy email identities for Google auto-link safety."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
duplicate_map: dict[str, list[User]] = defaultdict(list)
|
||||
placeholder_users: list[User] = []
|
||||
email_only_users: list[User] = []
|
||||
|
||||
for user in User.all_objects.all().order_by("id"):
|
||||
normalized_email = normalize_email_identity(user.email)
|
||||
if normalized_email:
|
||||
duplicate_map[normalized_email].append(user)
|
||||
if is_placeholder_email(user.email):
|
||||
placeholder_users.append(user)
|
||||
if normalized_email and not user.mobile:
|
||||
email_only_users.append(user)
|
||||
|
||||
duplicates = {email: users for email, users in duplicate_map.items() if len(users) > 1}
|
||||
|
||||
self.stdout.write(self.style.WARNING(f"Case-insensitive duplicate emails: {len(duplicates)}"))
|
||||
for email, users in duplicates.items():
|
||||
user_ids = ", ".join(str(user.id) for user in users)
|
||||
self.stdout.write(f" {email}: user_ids=[{user_ids}]")
|
||||
|
||||
self.stdout.write(self.style.WARNING(f"Placeholder emails: {len(placeholder_users)}"))
|
||||
for user in placeholder_users[:50]:
|
||||
self.stdout.write(f" user_id={user.id} email={user.email}")
|
||||
|
||||
self.stdout.write(self.style.WARNING(f"Email-only users needing mobile bind: {len(email_only_users)}"))
|
||||
for user in email_only_users[:100]:
|
||||
self.stdout.write(f" user_id={user.id} email={user.email}")
|
||||
|
||||
if not duplicates and not placeholder_users:
|
||||
self.stdout.write(self.style.SUCCESS("No blocking legacy email issues were found."))
|
||||
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 5.2.5 on 2026-05-20 18:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0006_remove_legacy_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='is_mobile_verified',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='mobile',
|
||||
field=models.CharField(blank=True, default=None, max_length=11, null=True, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='email',
|
||||
field=models.EmailField(blank=True, default=None, max_length=254, null=True, unique=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserSocialAccount',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('provider', models.CharField(choices=[('google', 'google')], max_length=32)),
|
||||
('provider_user_id', models.CharField(max_length=255)),
|
||||
('email', models.EmailField(blank=True, default=None, max_length=254, null=True)),
|
||||
('email_verified', models.BooleanField(default=False)),
|
||||
('avatar_url', models.URLField(blank=True, default='')),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_accounts', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User Social Account',
|
||||
'verbose_name_plural': 'User Social Accounts',
|
||||
'db_table': 'user_social_accounts',
|
||||
'ordering': ['-updated_at', '-created_at'],
|
||||
'indexes': [models.Index(fields=['provider', 'provider_user_id'], name='user_social_provider_uid_idx'), models.Index(fields=['provider', 'email'], name='user_social_provider_email_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('provider', 'provider_user_id'), name='user_social_account_provider_uid_uniq')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -11,6 +11,7 @@ from core.media import (
|
||||
safe_process_public_image,
|
||||
)
|
||||
from core.models import BaseModel
|
||||
from apps.users.email_identity import normalize_email_identity, normalize_mobile_number
|
||||
|
||||
|
||||
class University(BaseModel):
|
||||
@@ -38,7 +39,8 @@ class Major(BaseModel):
|
||||
|
||||
|
||||
class User(AbstractUser, BaseModel):
|
||||
email = models.EmailField(unique=True)
|
||||
email = models.EmailField(unique=True, null=True, blank=True, default=None)
|
||||
mobile = models.CharField(max_length=11, unique=True, null=True, blank=True, default=None)
|
||||
bio = models.TextField(null=True, blank=True)
|
||||
profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True)
|
||||
|
||||
@@ -59,14 +61,15 @@ class User(AbstractUser, BaseModel):
|
||||
related_name='users',
|
||||
)
|
||||
is_email_verified = models.BooleanField(default=False)
|
||||
is_mobile_verified = models.BooleanField(default=False)
|
||||
email_verification_token = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
password_reset_token = models.UUIDField(null=True, blank=True, unique=True)
|
||||
password_reset_token_expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username']
|
||||
USERNAME_FIELD = 'username'
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
@@ -74,7 +77,8 @@ class User(AbstractUser, BaseModel):
|
||||
verbose_name_plural = 'Users'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_full_name()} ({self.email})"
|
||||
identity = self.mobile or self.email or self.username
|
||||
return f"{self.get_full_name() or self.username} ({identity})"
|
||||
|
||||
def get_full_name(self):
|
||||
return f"{self.first_name} {self.last_name}".strip()
|
||||
@@ -89,6 +93,17 @@ class User(AbstractUser, BaseModel):
|
||||
return self.university.name
|
||||
return None
|
||||
|
||||
@property
|
||||
def requires_mobile_verification(self):
|
||||
return not self.is_mobile_verified
|
||||
|
||||
@property
|
||||
def has_google_link(self):
|
||||
return self.social_accounts.filter(
|
||||
provider=UserSocialAccount.ProviderType.GOOGLE,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
def regenerate_verification_token(self):
|
||||
self.email_verification_token = uuid.uuid4()
|
||||
self.save(update_fields=['email_verification_token'])
|
||||
@@ -102,12 +117,8 @@ class User(AbstractUser, BaseModel):
|
||||
def save(self, *args, **kwargs):
|
||||
previous_image_name = get_image_previous_name(self, "profile_picture")
|
||||
current_image_name = self.profile_picture.name if self.profile_picture else None
|
||||
send_verified_success = False
|
||||
|
||||
if self.pk is not None:
|
||||
prev = type(self).objects.filter(pk=self.pk).values_list('is_email_verified', flat=True).first()
|
||||
if prev is not None and prev is False and self.is_email_verified is True:
|
||||
send_verified_success = True
|
||||
self.email = normalize_email_identity(self.email)
|
||||
self.mobile = normalize_mobile_number(self.mobile)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -122,9 +133,42 @@ class User(AbstractUser, BaseModel):
|
||||
if previous_image_name != current_image_name and self.profile_picture:
|
||||
safe_process_public_image(self.profile_picture, "profile_picture")
|
||||
|
||||
if send_verified_success:
|
||||
try:
|
||||
from apps.users.tasks import send_email_verified_success
|
||||
send_email_verified_success.delay(self.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
class UserSocialAccount(BaseModel):
|
||||
class ProviderType(models.TextChoices):
|
||||
GOOGLE = "google", "google"
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="social_accounts",
|
||||
)
|
||||
provider = models.CharField(max_length=32, choices=ProviderType.choices)
|
||||
provider_user_id = models.CharField(max_length=255)
|
||||
email = models.EmailField(blank=True, null=True, default=None)
|
||||
email_verified = models.BooleanField(default=False)
|
||||
avatar_url = models.URLField(blank=True, default="")
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User Social Account"
|
||||
verbose_name_plural = "User Social Accounts"
|
||||
db_table = "user_social_accounts"
|
||||
ordering = ["-updated_at", "-created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("provider", "provider_user_id"),
|
||||
name="user_social_account_provider_uid_uniq",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["provider", "provider_user_id"], name="user_social_provider_uid_idx"),
|
||||
models.Index(fields=["provider", "email"], name="user_social_provider_email_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.provider}:{self.provider_user_id}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email = normalize_email_identity(self.email)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
1
apps/users/services/__init__.py
Normal file
1
apps/users/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""User authentication and OAuth services."""
|
||||
309
apps/users/services/auth.py
Normal file
309
apps/users/services/auth.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import password_validation
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
from apps.users.email_identity import (
|
||||
is_valid_mobile_number,
|
||||
normalize_email_identity,
|
||||
normalize_mobile_number,
|
||||
)
|
||||
from apps.users.models import Major, University, User
|
||||
from apps.users.tasks import send_critical_sms
|
||||
from core.authentication import create_jwt_token, create_refresh_token
|
||||
|
||||
OTP_EXPIRY_SECONDS = 120
|
||||
VERIFIED_OTP_EXPIRY_SECONDS = 900
|
||||
OTP_KEY_PREFIX = "auth_otp"
|
||||
OTP_VERIFIED_KEY_PREFIX = "auth_otp_verified"
|
||||
SMS_KIND_REGISTER = "auth_register_otp"
|
||||
SMS_KIND_LOGIN = "auth_login_otp"
|
||||
SMS_KIND_RESET_PASSWORD = "auth_reset_password_otp"
|
||||
SMS_KIND_VERIFY_MOBILE = "auth_verify_mobile_otp"
|
||||
|
||||
OTP_MODE_SMS_KIND = {
|
||||
"register": SMS_KIND_REGISTER,
|
||||
"login": SMS_KIND_LOGIN,
|
||||
"reset_password": SMS_KIND_RESET_PASSWORD,
|
||||
"verify_mobile": SMS_KIND_VERIFY_MOBILE,
|
||||
"google_claim": SMS_KIND_VERIFY_MOBILE,
|
||||
}
|
||||
|
||||
|
||||
class AuthServiceError(Exception):
|
||||
def __init__(self, message: str, *, field: str = "detail", status_code: int = 400):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.field = field
|
||||
self.status_code = status_code
|
||||
|
||||
def to_response(self) -> dict[str, str]:
|
||||
return {"error": self.message}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistrationPayload:
|
||||
username: str
|
||||
mobile: str
|
||||
password: str
|
||||
code: str
|
||||
email: str | None = None
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
university: str | None = None
|
||||
student_id: str | None = None
|
||||
year_of_study: int | None = None
|
||||
major: str | None = None
|
||||
|
||||
|
||||
def _otp_key(mode: str, mobile: str) -> str:
|
||||
return f"{OTP_KEY_PREFIX}:{mode}:{mobile}"
|
||||
|
||||
|
||||
def _verified_otp_key(mode: str, mobile: str) -> str:
|
||||
return f"{OTP_VERIFIED_KEY_PREFIX}:{mode}:{mobile}"
|
||||
|
||||
|
||||
def _redis():
|
||||
return get_redis_connection("default")
|
||||
|
||||
|
||||
def _validate_new_password(password: str, *, user: User | None = None, field_name: str = "password") -> None:
|
||||
try:
|
||||
password_validation.validate_password(password, user=user)
|
||||
except DjangoValidationError as exc:
|
||||
message = exc.messages[0] if len(exc.messages) == 1 else exc.messages[0]
|
||||
raise AuthServiceError(message, field=field_name)
|
||||
|
||||
|
||||
def _resolve_user_by_identifier(identifier: str) -> User | None:
|
||||
normalized_mobile = normalize_mobile_number(identifier)
|
||||
if is_valid_mobile_number(normalized_mobile):
|
||||
return User.objects.filter(mobile=normalized_mobile).first()
|
||||
normalized_email = normalize_email_identity(identifier)
|
||||
if normalized_email:
|
||||
return User.objects.filter(email=normalized_email).first()
|
||||
return None
|
||||
|
||||
|
||||
def _get_major_from_code(code: str | None) -> Major | None:
|
||||
if not code:
|
||||
return None
|
||||
return Major.objects.filter(code=code, is_deleted=False).first()
|
||||
|
||||
|
||||
def _get_university_from_code(code: str | None) -> University | None:
|
||||
if not code:
|
||||
return None
|
||||
return University.objects.filter(code=code, is_deleted=False).first()
|
||||
|
||||
|
||||
def _normalize_otp_code(code: str) -> str:
|
||||
normalized = (
|
||||
str(code or "")
|
||||
.strip()
|
||||
.translate(str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789"))
|
||||
)
|
||||
if len(normalized) != 5 or not normalized.isdigit():
|
||||
raise AuthServiceError("کد تایید باید شامل ۵ رقم باشد.", field="code")
|
||||
return normalized
|
||||
|
||||
|
||||
def _issue_otp(mobile: str, mode: str) -> dict[str, str | int]:
|
||||
verification_code = "".join(random.choices(string.digits, k=5))
|
||||
redis_conn = _redis()
|
||||
redis_conn.setex(_otp_key(mode, mobile), OTP_EXPIRY_SECONDS, verification_code)
|
||||
send_critical_sms.delay(mobile, OTP_MODE_SMS_KIND.get(mode, SMS_KIND_VERIFY_MOBILE), verification_code)
|
||||
expires_at = timezone.now() + timedelta(seconds=OTP_EXPIRY_SECONDS)
|
||||
return {
|
||||
"message": "کد تایید با موفقیت ارسال شد.",
|
||||
"expires_in_seconds": OTP_EXPIRY_SECONDS,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def verify_otp_code(*, mobile: str, code: str, mode: str) -> None:
|
||||
normalized_code = _normalize_otp_code(code)
|
||||
redis_conn = _redis()
|
||||
stored_code = redis_conn.get(_otp_key(mode, mobile))
|
||||
if not stored_code or stored_code.decode("utf-8") != normalized_code:
|
||||
raise AuthServiceError("کد تایید نامعتبر است یا منقضی شده است.", field="code")
|
||||
redis_conn.delete(_otp_key(mode, mobile))
|
||||
|
||||
|
||||
def verify_register_otp(mobile: str, code: str) -> None:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
verify_otp_code(mobile=normalized_mobile, code=code, mode="register")
|
||||
_redis().setex(_verified_otp_key("register", normalized_mobile), VERIFIED_OTP_EXPIRY_SECONDS, "1")
|
||||
|
||||
|
||||
def _consume_verified_otp(mobile: str, mode: str) -> bool:
|
||||
redis_conn = _redis()
|
||||
key = _verified_otp_key(mode, mobile)
|
||||
if redis_conn.get(key):
|
||||
redis_conn.delete(key)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_tokens_for_user(user: User) -> dict[str, str]:
|
||||
return {
|
||||
"access_token": create_jwt_token(user),
|
||||
"refresh_token": create_refresh_token(user),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
def generate_and_send_otp(mobile: str, mode: str) -> dict[str, str | int]:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
|
||||
user_exists = User.objects.filter(mobile=normalized_mobile).exists()
|
||||
|
||||
if mode == "register" and user_exists:
|
||||
raise AuthServiceError("این شماره قبلاً ثبت شده است.", field="mobile")
|
||||
if mode in {"login", "reset_password"} and not user_exists:
|
||||
raise AuthServiceError("کاربری با این شماره موبایل یافت نشد.", field="mobile")
|
||||
|
||||
return _issue_otp(normalized_mobile, mode)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def register_user(payload: RegistrationPayload) -> User:
|
||||
if payload.student_id and len(str(payload.student_id)) < 10:
|
||||
raise AuthServiceError("شماره دانشجویی باید حداقل ۱۰ رقم باشد.", field="student_id")
|
||||
|
||||
if User.objects.filter(username=payload.username).exists():
|
||||
raise AuthServiceError("نام کاربری قبلاً استفاده شده است.", field="username")
|
||||
|
||||
normalized_email = normalize_email_identity(payload.email)
|
||||
if normalized_email and User.objects.filter(email=normalized_email).exists():
|
||||
raise AuthServiceError("این ایمیل قبلاً ثبت شده است.", field="email")
|
||||
|
||||
normalized_mobile = normalize_mobile_number(payload.mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
|
||||
if not _consume_verified_otp(normalized_mobile, "register"):
|
||||
verify_otp_code(mobile=normalized_mobile, code=payload.code, mode="register")
|
||||
_validate_new_password(payload.password)
|
||||
|
||||
major = _get_major_from_code(payload.major)
|
||||
if payload.major and not major:
|
||||
raise AuthServiceError("رشته انتخابی معتبر نیست.", field="major")
|
||||
|
||||
university = _get_university_from_code(payload.university)
|
||||
if payload.university and not university:
|
||||
raise AuthServiceError("دانشگاه انتخابی معتبر نیست.", field="university")
|
||||
|
||||
if payload.student_id and university and User.objects.filter(
|
||||
university=university,
|
||||
student_id=payload.student_id,
|
||||
).exists():
|
||||
raise AuthServiceError(
|
||||
"این شماره دانشجویی در دانشگاه انتخابی قبلاً ثبت شده است.",
|
||||
field="student_id",
|
||||
)
|
||||
|
||||
user = User.objects.create_user(
|
||||
username=payload.username,
|
||||
email=normalized_email,
|
||||
mobile=normalized_mobile,
|
||||
password=payload.password,
|
||||
first_name=payload.first_name or "",
|
||||
last_name=payload.last_name or "",
|
||||
student_id=payload.student_id,
|
||||
year_of_study=payload.year_of_study,
|
||||
major=major,
|
||||
university=university,
|
||||
is_email_verified=bool(normalized_email),
|
||||
is_mobile_verified=True,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def login_with_password(identifier: str, password: str) -> dict[str, str]:
|
||||
user = _resolve_user_by_identifier(identifier)
|
||||
if not user or not user.check_password(password):
|
||||
raise AuthServiceError("شناسه ورود یا رمز عبور نادرست است.", status_code=401)
|
||||
if not user.is_active:
|
||||
raise AuthServiceError("حساب کاربری شما غیرفعال است.", status_code=401)
|
||||
return get_tokens_for_user(user)
|
||||
|
||||
|
||||
def login_with_otp(mobile: str, code: str) -> dict[str, str]:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
user = User.objects.filter(mobile=normalized_mobile).first()
|
||||
if not user:
|
||||
raise AuthServiceError("کاربری با این شماره موبایل یافت نشد.", field="mobile")
|
||||
if not user.is_active:
|
||||
raise AuthServiceError("حساب کاربری شما غیرفعال است.", status_code=401)
|
||||
verify_otp_code(mobile=normalized_mobile, code=code, mode="login")
|
||||
if not user.is_mobile_verified:
|
||||
user.is_mobile_verified = True
|
||||
user.save(update_fields=["is_mobile_verified"])
|
||||
return get_tokens_for_user(user)
|
||||
|
||||
|
||||
def reset_password_with_otp(mobile: str, code: str, password: str) -> None:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
user = User.objects.filter(mobile=normalized_mobile).first()
|
||||
if not user:
|
||||
raise AuthServiceError("کاربری با این شماره موبایل یافت نشد.", field="mobile")
|
||||
|
||||
verify_otp_code(mobile=normalized_mobile, code=code, mode="reset_password")
|
||||
_validate_new_password(password, user=user)
|
||||
if user.check_password(password):
|
||||
raise AuthServiceError("رمز عبور جدید نباید با رمز قبلی یکسان باشد.", field="password")
|
||||
user.set_password(password)
|
||||
user.save(update_fields=["password"])
|
||||
|
||||
|
||||
def send_authenticated_mobile_otp(user: User, mobile: str) -> dict[str, str | int]:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
|
||||
conflict = User.objects.filter(mobile=normalized_mobile).exclude(pk=user.pk).exists()
|
||||
if conflict:
|
||||
raise AuthServiceError("این شماره موبایل قبلاً به حساب دیگری متصل شده است.", field="mobile")
|
||||
return _issue_otp(normalized_mobile, "verify_mobile")
|
||||
|
||||
|
||||
def verify_authenticated_mobile_otp(user: User, mobile: str, code: str) -> User:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
conflict = User.objects.filter(mobile=normalized_mobile).exclude(pk=user.pk).exists()
|
||||
if conflict:
|
||||
raise AuthServiceError("این شماره موبایل قبلاً به حساب دیگری متصل شده است.", field="mobile")
|
||||
verify_otp_code(mobile=normalized_mobile, code=code, mode="verify_mobile")
|
||||
user.mobile = normalized_mobile
|
||||
user.is_mobile_verified = True
|
||||
user.save(update_fields=["mobile", "is_mobile_verified"])
|
||||
return user
|
||||
|
||||
|
||||
def lookup_mobile_registration_state(mobile: str) -> dict[str, bool]:
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
|
||||
|
||||
user = User.objects.filter(mobile=normalized_mobile).first()
|
||||
return {
|
||||
"exists": bool(user),
|
||||
"has_password": bool(user and user.has_usable_password()),
|
||||
}
|
||||
559
apps/users/services/google_oauth.py
Normal file
559
apps/users/services/google_oauth.py
Normal file
@@ -0,0 +1,559 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from apps.users.email_identity import (
|
||||
is_placeholder_email,
|
||||
is_valid_mobile_number,
|
||||
mask_mobile,
|
||||
normalize_email_identity,
|
||||
normalize_mobile_number,
|
||||
)
|
||||
from apps.users.models import Major, University, User, UserSocialAccount
|
||||
from apps.users.services.auth import (
|
||||
AuthServiceError,
|
||||
RegistrationPayload,
|
||||
generate_and_send_otp,
|
||||
get_tokens_for_user,
|
||||
verify_otp_code,
|
||||
)
|
||||
|
||||
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||
GOOGLE_STATE_TTL_SECONDS = 300
|
||||
GOOGLE_FLOW_TTL_SECONDS = 900
|
||||
GOOGLE_STATE_CACHE_PREFIX = "google_oauth_state"
|
||||
GOOGLE_FLOW_CACHE_PREFIX = "google_oauth_flow"
|
||||
|
||||
|
||||
class GoogleOAuthFlowError(AuthServiceError):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str = "google_flow_error",
|
||||
status_code: int = 409,
|
||||
extra: dict[str, Any] | None = None,
|
||||
):
|
||||
super().__init__(message, field="detail", status_code=status_code)
|
||||
self.code = code
|
||||
self.extra = extra or {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoogleProfile:
|
||||
provider_user_id: str
|
||||
email: str
|
||||
email_verified: bool
|
||||
first_name: str
|
||||
last_name: str
|
||||
avatar_url: str
|
||||
|
||||
|
||||
def _cache_key(prefix: str, token: str) -> str:
|
||||
return f"{prefix}:{token}"
|
||||
|
||||
|
||||
def _create_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _required_setting(name: str) -> str:
|
||||
value = getattr(settings, name, "")
|
||||
if not value:
|
||||
raise GoogleOAuthFlowError(f"{name} is not configured.", status_code=500)
|
||||
return value
|
||||
|
||||
|
||||
def _public_flow_payload(flow_payload: dict[str, Any]) -> dict[str, Any]:
|
||||
status = flow_payload.get("status")
|
||||
base = {"status": status}
|
||||
for key in (
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"avatar_url",
|
||||
"resolution",
|
||||
"mobile",
|
||||
"mobile_hint",
|
||||
"detail",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
):
|
||||
if key in flow_payload:
|
||||
base[key] = flow_payload[key]
|
||||
return base
|
||||
|
||||
|
||||
def _profile_payload(profile: GoogleProfile) -> dict[str, Any]:
|
||||
return asdict(profile)
|
||||
|
||||
|
||||
def _profile_from_payload(payload: dict[str, Any]) -> GoogleProfile:
|
||||
raw = payload.get("google_profile")
|
||||
if not isinstance(raw, dict):
|
||||
raise GoogleOAuthFlowError(
|
||||
"Google flow profile data is missing.",
|
||||
code="google_flow_invalid_state",
|
||||
status_code=400,
|
||||
)
|
||||
return GoogleProfile(**raw)
|
||||
|
||||
|
||||
def create_google_state() -> str:
|
||||
state = _create_token()
|
||||
cache.set(_cache_key(GOOGLE_STATE_CACHE_PREFIX, state), {"valid": True}, GOOGLE_STATE_TTL_SECONDS)
|
||||
return state
|
||||
|
||||
|
||||
def consume_google_state(state: str) -> None:
|
||||
key = _cache_key(GOOGLE_STATE_CACHE_PREFIX, state or "")
|
||||
payload = cache.get(key)
|
||||
cache.delete(key)
|
||||
if not payload:
|
||||
raise GoogleOAuthFlowError(
|
||||
"Google sign-in state is invalid or expired.",
|
||||
code="google_flow_invalid_state",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
def create_google_flow(payload: dict[str, Any]) -> str:
|
||||
flow = _create_token()
|
||||
cache.set(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow), payload, GOOGLE_FLOW_TTL_SECONDS)
|
||||
return flow
|
||||
|
||||
|
||||
def get_google_flow_payload(flow: str) -> dict[str, Any]:
|
||||
payload = cache.get(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow))
|
||||
if not payload:
|
||||
raise GoogleOAuthFlowError(
|
||||
"Google sign-in flow is invalid or expired.",
|
||||
code="google_flow_invalid_state",
|
||||
status_code=400,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def get_google_flow(flow: str) -> dict[str, Any]:
|
||||
return _public_flow_payload(get_google_flow_payload(flow))
|
||||
|
||||
|
||||
def update_google_flow(flow: str, payload: dict[str, Any]) -> None:
|
||||
cache.set(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow), payload, GOOGLE_FLOW_TTL_SECONDS)
|
||||
|
||||
|
||||
def build_google_authorization_url() -> str:
|
||||
state = create_google_state()
|
||||
params = {
|
||||
"client_id": _required_setting("GOOGLE_OAUTH_CLIENT_ID"),
|
||||
"redirect_uri": _required_setting("GOOGLE_OAUTH_REDIRECT_URI"),
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"access_type": "online",
|
||||
"prompt": "select_account",
|
||||
}
|
||||
return f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
|
||||
|
||||
|
||||
def exchange_code_for_google_profile(code: str) -> GoogleProfile:
|
||||
if not code:
|
||||
raise GoogleOAuthFlowError("Missing Google authorization code.", status_code=400)
|
||||
|
||||
try:
|
||||
token_response = requests.post(
|
||||
GOOGLE_TOKEN_URL,
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": _required_setting("GOOGLE_OAUTH_CLIENT_ID"),
|
||||
"client_secret": _required_setting("GOOGLE_OAUTH_CLIENT_SECRET"),
|
||||
"redirect_uri": _required_setting("GOOGLE_OAUTH_REDIRECT_URI"),
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
token_payload = token_response.json()
|
||||
except requests.RequestException as exc:
|
||||
raise GoogleOAuthFlowError("Google token exchange failed.", status_code=400) from exc
|
||||
|
||||
access_token = token_payload.get("access_token")
|
||||
if not access_token:
|
||||
raise GoogleOAuthFlowError("Google did not return an access token.", status_code=400)
|
||||
|
||||
try:
|
||||
userinfo_response = requests.get(
|
||||
GOOGLE_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
userinfo_response.raise_for_status()
|
||||
userinfo = userinfo_response.json()
|
||||
except requests.RequestException as exc:
|
||||
raise GoogleOAuthFlowError("Google user profile lookup failed.", status_code=400) from exc
|
||||
|
||||
email = normalize_email_identity(userinfo.get("email"))
|
||||
if not userinfo.get("sub") or not email or not userinfo.get("email_verified"):
|
||||
raise GoogleOAuthFlowError(
|
||||
"Google account must have a verified email address.",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return GoogleProfile(
|
||||
provider_user_id=userinfo.get("sub"),
|
||||
email=email,
|
||||
email_verified=bool(userinfo.get("email_verified")),
|
||||
first_name=userinfo.get("given_name", "") or "",
|
||||
last_name=userinfo.get("family_name", "") or "",
|
||||
avatar_url=userinfo.get("picture", "") or "",
|
||||
)
|
||||
|
||||
|
||||
def build_google_callback_redirect_url(flow: str) -> str:
|
||||
callback = _required_setting("GOOGLE_OAUTH_FRONTEND_CALLBACK_URL")
|
||||
sep = "&" if "?" in callback else "?"
|
||||
return f"{callback}{sep}flow={flow}"
|
||||
|
||||
|
||||
def find_social_account_for_profile(profile: GoogleProfile) -> UserSocialAccount | None:
|
||||
return (
|
||||
UserSocialAccount.objects.select_related("user")
|
||||
.filter(provider=UserSocialAccount.ProviderType.GOOGLE, provider_user_id=profile.provider_user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _avatar_file_extension(profile: GoogleProfile) -> str:
|
||||
path = urlparse(profile.avatar_url or "").path
|
||||
if "." in path:
|
||||
suffix = path.rsplit(".", 1)[-1].lower()
|
||||
if suffix in {"jpg", "jpeg", "png", "webp", "gif"}:
|
||||
return suffix
|
||||
return "jpg"
|
||||
|
||||
|
||||
def sync_user_from_google_profile(user: User, profile: GoogleProfile) -> None:
|
||||
update_fields: list[str] = []
|
||||
if not user.first_name and profile.first_name:
|
||||
user.first_name = profile.first_name
|
||||
update_fields.append("first_name")
|
||||
if not user.last_name and profile.last_name:
|
||||
user.last_name = profile.last_name
|
||||
update_fields.append("last_name")
|
||||
if not user.email and profile.email:
|
||||
user.email = profile.email
|
||||
user.is_email_verified = True
|
||||
update_fields.extend(["email", "is_email_verified"])
|
||||
if not user.profile_picture and profile.avatar_url:
|
||||
try:
|
||||
avatar_response = requests.get(profile.avatar_url, timeout=10)
|
||||
avatar_response.raise_for_status()
|
||||
except requests.RequestException:
|
||||
avatar_response = None
|
||||
if avatar_response and avatar_response.content:
|
||||
filename = f"google-{profile.provider_user_id}.{_avatar_file_extension(profile)}"
|
||||
user.profile_picture.save(filename, ContentFile(avatar_response.content), save=False)
|
||||
update_fields.append("profile_picture")
|
||||
if update_fields:
|
||||
user.save(update_fields=update_fields)
|
||||
|
||||
|
||||
def build_authenticated_flow_payload(user: User) -> dict[str, Any]:
|
||||
tokens = get_tokens_for_user(user)
|
||||
return {
|
||||
"status": "authenticated",
|
||||
"access_token": tokens["access_token"],
|
||||
"refresh_token": tokens["refresh_token"],
|
||||
}
|
||||
|
||||
|
||||
def _is_google_claim_candidate(user: User | None) -> bool:
|
||||
if user is None:
|
||||
return False
|
||||
if is_placeholder_email(user.email):
|
||||
return False
|
||||
normalized_email = normalize_email_identity(user.email)
|
||||
return bool(normalized_email)
|
||||
|
||||
|
||||
def build_pending_google_flow_payload(profile: GoogleProfile) -> dict[str, Any]:
|
||||
existing_email_user = None
|
||||
if not is_placeholder_email(profile.email):
|
||||
existing_email_user = User.objects.filter(email=profile.email).first()
|
||||
|
||||
resolution = "existing_email_claim" if _is_google_claim_candidate(existing_email_user) else "new_account"
|
||||
mobile_hint = mask_mobile(existing_email_user.mobile) if existing_email_user else None
|
||||
has_verified_mobile = bool(existing_email_user and existing_email_user.mobile and existing_email_user.is_mobile_verified)
|
||||
|
||||
return {
|
||||
"status": "collect_profile",
|
||||
"google_profile": _profile_payload(profile),
|
||||
"email": profile.email,
|
||||
"first_name": profile.first_name,
|
||||
"last_name": profile.last_name,
|
||||
"avatar_url": profile.avatar_url,
|
||||
"resolution": resolution,
|
||||
"target_user_id": existing_email_user.id if existing_email_user else None,
|
||||
"mobile_hint": mobile_hint,
|
||||
"has_verified_mobile": has_verified_mobile,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_major(code: str | None) -> Major | None:
|
||||
if not code:
|
||||
return None
|
||||
return Major.objects.filter(code=code, is_deleted=False).first()
|
||||
|
||||
|
||||
def _resolve_university(code: str | None) -> University | None:
|
||||
if not code:
|
||||
return None
|
||||
return University.objects.filter(code=code, is_deleted=False).first()
|
||||
|
||||
|
||||
def complete_google_signup(
|
||||
*,
|
||||
flow: str,
|
||||
mobile: str,
|
||||
username: str | None = None,
|
||||
student_id: str | None = None,
|
||||
year_of_study: int | None = None,
|
||||
major: str | None = None,
|
||||
university: str | None = None,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
flow_payload = get_google_flow_payload(flow)
|
||||
if flow_payload.get("status") != "collect_profile":
|
||||
raise GoogleOAuthFlowError(
|
||||
"Google sign-in flow is in an unexpected state.",
|
||||
code="google_flow_invalid_state",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
normalized_mobile = normalize_mobile_number(mobile)
|
||||
if not is_valid_mobile_number(normalized_mobile):
|
||||
raise GoogleOAuthFlowError("شماره موبایل معتبر نیست.", status_code=400)
|
||||
|
||||
profile = _profile_from_payload(flow_payload)
|
||||
resolution = flow_payload.get("resolution", "new_account")
|
||||
target_user_id = flow_payload.get("target_user_id")
|
||||
target_user = User.objects.filter(pk=target_user_id).first() if target_user_id else None
|
||||
|
||||
if resolution == "existing_email_claim":
|
||||
if target_user is None:
|
||||
raise GoogleOAuthFlowError(
|
||||
"Target account could not be found.",
|
||||
code="google_flow_invalid_state",
|
||||
status_code=400,
|
||||
)
|
||||
conflict = User.objects.filter(mobile=normalized_mobile).exclude(pk=target_user.pk).first()
|
||||
if conflict and normalize_email_identity(conflict.email) not in (None, profile.email):
|
||||
raise GoogleOAuthFlowError(
|
||||
"این شماره موبایل قبلاً به حساب دیگری متصل شده است.",
|
||||
code="google_mobile_belongs_to_other_email",
|
||||
status_code=409,
|
||||
)
|
||||
generate_and_send_otp(normalized_mobile, "google_claim")
|
||||
claim_payload = {
|
||||
"status": "claim_required",
|
||||
"google_profile": _profile_payload(profile),
|
||||
"resolution": resolution,
|
||||
"target_user_id": target_user.id,
|
||||
"mobile": normalized_mobile,
|
||||
"email": profile.email,
|
||||
"mobile_hint": mask_mobile(normalized_mobile),
|
||||
"detail": "مالکیت شماره موبایل را تایید کنید تا ورود با گوگل تکمیل شود.",
|
||||
}
|
||||
update_google_flow(flow, claim_payload)
|
||||
return _public_flow_payload(claim_payload)
|
||||
|
||||
if not username:
|
||||
raise GoogleOAuthFlowError("نام کاربری الزامی است.", code="google_missing_username", status_code=400)
|
||||
if User.objects.filter(username=username).exists():
|
||||
raise GoogleOAuthFlowError("نام کاربری قبلاً استفاده شده است.", code="google_username_taken", status_code=400)
|
||||
if student_id and len(str(student_id)) < 10:
|
||||
raise GoogleOAuthFlowError("شماره دانشجویی باید حداقل ۱۰ رقم باشد.", code="google_invalid_student_id", status_code=400)
|
||||
|
||||
major_obj = _resolve_major(major)
|
||||
if major and not major_obj:
|
||||
raise GoogleOAuthFlowError("رشته انتخابی معتبر نیست.", code="google_invalid_major", status_code=400)
|
||||
university_obj = _resolve_university(university)
|
||||
if university and not university_obj:
|
||||
raise GoogleOAuthFlowError("دانشگاه انتخابی معتبر نیست.", code="google_invalid_university", status_code=400)
|
||||
|
||||
if student_id and university_obj and User.objects.filter(university=university_obj, student_id=student_id).exists():
|
||||
raise GoogleOAuthFlowError(
|
||||
"این شماره دانشجویی در دانشگاه انتخابی قبلاً ثبت شده است.",
|
||||
code="google_student_id_taken",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
conflict = User.objects.filter(mobile=normalized_mobile).first()
|
||||
if conflict:
|
||||
existing_email = normalize_email_identity(conflict.email)
|
||||
if existing_email not in (None, profile.email):
|
||||
raise GoogleOAuthFlowError(
|
||||
"این شماره موبایل قبلاً به حساب دیگری متصل شده است.",
|
||||
code="google_mobile_belongs_to_other_email",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
generate_and_send_otp(normalized_mobile, "google_claim")
|
||||
claim_payload = {
|
||||
"status": "claim_required",
|
||||
"google_profile": _profile_payload(profile),
|
||||
"resolution": "new_account",
|
||||
"mobile": normalized_mobile,
|
||||
"email": profile.email,
|
||||
"username": username,
|
||||
"student_id": student_id,
|
||||
"year_of_study": year_of_study,
|
||||
"major": major,
|
||||
"university": university,
|
||||
"first_name": first_name or profile.first_name,
|
||||
"last_name": last_name or profile.last_name,
|
||||
"detail": "کد تایید موبایل را وارد کنید تا حساب جدید شما ساخته شود.",
|
||||
}
|
||||
update_google_flow(flow, claim_payload)
|
||||
return _public_flow_payload(claim_payload)
|
||||
|
||||
|
||||
def send_google_claim_otp(flow: str) -> dict[str, str]:
|
||||
flow_payload = get_google_flow_payload(flow)
|
||||
if flow_payload.get("status") != "claim_required":
|
||||
raise GoogleOAuthFlowError(
|
||||
"Google sign-in flow is in an unexpected state.",
|
||||
code="google_flow_invalid_state",
|
||||
status_code=400,
|
||||
)
|
||||
mobile = flow_payload.get("mobile")
|
||||
if not isinstance(mobile, str) or not mobile:
|
||||
raise GoogleOAuthFlowError("Claim mobile number is missing.", status_code=400)
|
||||
generate_and_send_otp(mobile, "google_claim")
|
||||
return {"message": "کد تایید مجدداً ارسال شد."}
|
||||
|
||||
|
||||
def _link_google_account(*, user: User, profile: GoogleProfile) -> None:
|
||||
social = find_social_account_for_profile(profile)
|
||||
if social and social.user_id != user.id:
|
||||
raise GoogleOAuthFlowError(
|
||||
"این حساب گوگل قبلاً به کاربر دیگری متصل شده است.",
|
||||
code="google_already_linked",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
sync_user_from_google_profile(user, profile)
|
||||
|
||||
if social:
|
||||
social.email = profile.email
|
||||
social.email_verified = profile.email_verified
|
||||
social.avatar_url = profile.avatar_url
|
||||
social.is_active = True
|
||||
social.save(update_fields=["email", "email_verified", "avatar_url", "is_active"])
|
||||
return
|
||||
|
||||
UserSocialAccount.objects.create(
|
||||
user=user,
|
||||
provider=UserSocialAccount.ProviderType.GOOGLE,
|
||||
provider_user_id=profile.provider_user_id,
|
||||
email=profile.email,
|
||||
email_verified=profile.email_verified,
|
||||
avatar_url=profile.avatar_url,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
def verify_google_claim(flow: str, code: str) -> dict[str, Any]:
|
||||
flow_payload = get_google_flow_payload(flow)
|
||||
if flow_payload.get("status") != "claim_required":
|
||||
raise GoogleOAuthFlowError(
|
||||
"Google sign-in flow is in an unexpected state.",
|
||||
code="google_flow_invalid_state",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
mobile = flow_payload.get("mobile")
|
||||
if not isinstance(mobile, str) or not mobile:
|
||||
raise GoogleOAuthFlowError("Claim mobile number is missing.", status_code=400)
|
||||
|
||||
verify_otp_code(mobile=mobile, code=code, mode="google_claim")
|
||||
profile = _profile_from_payload(flow_payload)
|
||||
resolution = flow_payload.get("resolution", "new_account")
|
||||
|
||||
if resolution == "existing_email_claim":
|
||||
target_user_id = flow_payload.get("target_user_id")
|
||||
user = User.objects.filter(pk=target_user_id).first()
|
||||
if user is None or normalize_email_identity(user.email) != profile.email:
|
||||
raise GoogleOAuthFlowError(
|
||||
"The matching account could not be verified.",
|
||||
code="google_email_claim_failed",
|
||||
status_code=409,
|
||||
)
|
||||
user.mobile = mobile
|
||||
user.is_mobile_verified = True
|
||||
user.is_email_verified = True
|
||||
user.save(update_fields=["mobile", "is_mobile_verified", "is_email_verified"])
|
||||
_link_google_account(user=user, profile=profile)
|
||||
authenticated = build_authenticated_flow_payload(user)
|
||||
update_google_flow(flow, authenticated)
|
||||
return _public_flow_payload(authenticated)
|
||||
|
||||
normalized_email = normalize_email_identity(profile.email)
|
||||
if normalized_email and User.objects.filter(email=normalized_email).exists():
|
||||
raise GoogleOAuthFlowError(
|
||||
"این ایمیل قبلاً به حساب دیگری متصل شده است.",
|
||||
code="google_email_taken",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
payload = RegistrationPayload(
|
||||
username=flow_payload.get("username", ""),
|
||||
mobile=mobile,
|
||||
password=_create_token(),
|
||||
code=code,
|
||||
email=normalized_email,
|
||||
first_name=flow_payload.get("first_name") or profile.first_name,
|
||||
last_name=flow_payload.get("last_name") or profile.last_name,
|
||||
university=flow_payload.get("university"),
|
||||
student_id=flow_payload.get("student_id"),
|
||||
year_of_study=flow_payload.get("year_of_study"),
|
||||
major=flow_payload.get("major"),
|
||||
)
|
||||
|
||||
if not payload.username:
|
||||
raise GoogleOAuthFlowError("نام کاربری برای ساخت حساب جدید الزامی است.", status_code=400)
|
||||
|
||||
major_obj = _resolve_major(payload.major)
|
||||
university_obj = _resolve_university(payload.university)
|
||||
user = User.objects.create_user(
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
mobile=payload.mobile,
|
||||
password=None,
|
||||
first_name=payload.first_name or "",
|
||||
last_name=payload.last_name or "",
|
||||
student_id=payload.student_id,
|
||||
year_of_study=payload.year_of_study,
|
||||
major=major_obj,
|
||||
university=university_obj,
|
||||
is_mobile_verified=True,
|
||||
is_email_verified=bool(payload.email),
|
||||
)
|
||||
user.set_unusable_password()
|
||||
user.save(update_fields=["password"])
|
||||
_link_google_account(user=user, profile=profile)
|
||||
authenticated = build_authenticated_flow_payload(user)
|
||||
update_google_flow(flow, authenticated)
|
||||
return _public_flow_payload(authenticated)
|
||||
@@ -2,26 +2,12 @@ import uuid
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
from apps.users.models import User
|
||||
from apps.users.tasks import send_verification_email
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def send_verification_email_on_registration(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
if not instance.username:
|
||||
instance.username = str(uuid.uuid4())[:10]
|
||||
instance.save(update_fields=['username'])
|
||||
|
||||
if not instance.is_email_verified and instance.email:
|
||||
# Update the email verification sent timestamp
|
||||
instance.email_verification_sent_at = timezone.now()
|
||||
instance.save(update_fields=['email_verification_sent_at'])
|
||||
|
||||
# Generate verification URL (you'll need to adjust this based on your frontend)
|
||||
verification_url = f"{settings.FRONTEND_ROOT}verify-email/{instance.email_verification_token}"
|
||||
|
||||
# Send verification email asynchronously
|
||||
send_verification_email.delay(instance.id, verification_url)
|
||||
def ensure_username_on_registration(sender, instance, created, **kwargs):
|
||||
if created and not instance.username:
|
||||
instance.username = str(uuid.uuid4())[:10]
|
||||
instance.save(update_fields=["username"])
|
||||
|
||||
@@ -1,99 +1,90 @@
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.conf import settings
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
from celery import shared_task
|
||||
import logging
|
||||
|
||||
from apps.users.models import User
|
||||
import requests
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SMS_ENDPOINT = "https://api.sms.ir/v1/send/verify"
|
||||
|
||||
SMS_TEMPLATE_MAP = {
|
||||
"auth_register_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
|
||||
"auth_login_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
|
||||
"auth_reset_password_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
|
||||
"auth_verify_mobile_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
|
||||
"event_cancellation": "SMS_EVENT_CANCELLATION_TEMPLATE_ID",
|
||||
"event_reschedule": "SMS_EVENT_RESCHEDULE_TEMPLATE_ID",
|
||||
"payment_status": "SMS_PAYMENT_STATUS_TEMPLATE_ID",
|
||||
}
|
||||
|
||||
|
||||
def _template_id_for_kind(kind: str) -> str:
|
||||
setting_name = SMS_TEMPLATE_MAP.get(kind, "")
|
||||
return getattr(settings, setting_name, "") if setting_name else ""
|
||||
|
||||
|
||||
def _send_sms(receptor: str, template_id: str | int, variables: list[dict] | None = None):
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"x-api-key": settings.SMS_APIKEY,
|
||||
}
|
||||
payload = {
|
||||
"mobile": receptor,
|
||||
"templateId": int(template_id),
|
||||
"parameters": variables or [],
|
||||
}
|
||||
response = requests.post(
|
||||
SMS_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_critical_sms(self, mobile: str, kind: str, code_or_message: str):
|
||||
try:
|
||||
template_id = _template_id_for_kind(kind)
|
||||
if not template_id or not settings.SMS_APIKEY:
|
||||
logger.info(
|
||||
"SMS skipped for mobile=%s kind=%s template=%s configured=%s payload=%s",
|
||||
mobile,
|
||||
kind,
|
||||
bool(template_id),
|
||||
bool(settings.SMS_APIKEY),
|
||||
code_or_message,
|
||||
)
|
||||
return {"mobile": mobile, "kind": kind, "sent": False}
|
||||
|
||||
variables = [{"name": "OTP", "value": str(code_or_message)}]
|
||||
if kind in {"event_cancellation", "event_reschedule", "payment_status"}:
|
||||
variables = [{"name": "MESSAGE", "value": str(code_or_message)}]
|
||||
|
||||
_send_sms(mobile, template_id, variables=variables)
|
||||
logger.info("SMS sent to %s for kind=%s", mobile, kind)
|
||||
return {"mobile": mobile, "kind": kind, "sent": True}
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send SMS to %s for kind=%s: %s", mobile, kind, exc)
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=1)
|
||||
def send_verification_email(self, user_id, verification_url):
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
subject = 'تایید ایمیل | انجمن علمی مهندسی کامپیوتر'
|
||||
html_message = render_to_string('emails/verification_email.html', {
|
||||
'user': user,
|
||||
'verification_url': verification_url,
|
||||
})
|
||||
plain_message = strip_tags(html_message)
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
html_message=html_message,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Verification email sent to {user.email}")
|
||||
return f"Verification email sent to {user.email}"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send verification email: {exc}")
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
logger.info("Legacy verification email task skipped for user=%s url=%s", user_id, verification_url)
|
||||
return {"skipped": True}
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
|
||||
@shared_task(bind=True, max_retries=1)
|
||||
def send_password_reset_email(self, user_id, reset_url):
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
subject = 'بازیابی رمز عبور | انجمن علمی مهندسی کامپیوتر'
|
||||
html_message = render_to_string('emails/password_reset_email.html', {
|
||||
'user': user,
|
||||
'reset_url': reset_url,
|
||||
})
|
||||
plain_message = strip_tags(html_message)
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
html_message=html_message,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Password reset email sent to {user.email}")
|
||||
return f"Password reset email sent to {user.email}"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send password reset email: {exc}")
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
logger.info("Legacy password reset email task skipped for user=%s url=%s", user_id, reset_url)
|
||||
return {"skipped": True}
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
@shared_task(bind=True, max_retries=1)
|
||||
def send_email_verified_success(self, user_id: int):
|
||||
"""
|
||||
ارسال ایمیل «ایمیل شما با موفقیت تأیید شد» پس از تغییر وضعیت تأیید.
|
||||
"""
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
|
||||
subject = "تأیید ایمیل شما با موفقیت انجام شد"
|
||||
context = {
|
||||
"user": user,
|
||||
"home_url": getattr(settings, "FRONTEND_ROOT", "/"),
|
||||
}
|
||||
html_message = render_to_string("emails/verification_success.html", context)
|
||||
plain_message = strip_tags(html_message)
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
html_message=html_message,
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info(f"verified success email sent to {user.email}")
|
||||
return f"verified success email sent to {user.email}"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send verified success email: {exc}")
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
logger.info("Legacy verification success email task skipped for user=%s", user_id)
|
||||
return {"skipped": True}
|
||||
|
||||
Reference in New Issue
Block a user