This commit is contained in:
2026-08-24 15:54:08 +03:30
commit 236002a87c
133 changed files with 5946 additions and 0 deletions
View File
+57
View File
@@ -0,0 +1,57 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.shortcuts import redirect
from .models import TermsAndConditions, User
@admin.register(User)
class UserAdmin(BaseUserAdmin):
model = User
list_display = ("phone", "name", "state", "gem_balance", "referral_code", "is_active", "is_staff", "created_at")
list_filter = ("state", "is_active", "is_staff")
search_fields = ("phone", "name", "referral_code")
ordering = ("-created_at",)
readonly_fields = (
"otp_code",
"otp_expiry",
"created_at",
"updated_at",
"last_login",
"gem_balance",
"referral_code",
"referral_bonus_granted",
)
autocomplete_fields = ["referred_by"]
fieldsets = (
(None, {"fields": ("phone", "password")}),
("اطلاعات شخصی", {"fields": ("name",)}),
("وضعیت", {"fields": ("state", "is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
(
"جم و معرفی",
{"fields": ("gem_balance", "trial_expires_at", "referral_code", "referred_by", "referral_bonus_granted")},
),
("OTP", {"fields": ("otp_code", "otp_expiry")}),
("تاریخ‌ها", {"fields": ("created_at", "updated_at", "last_login")}),
)
add_fieldsets = (
(None, {"classes": ("wide",), "fields": ("phone", "password1", "password2")}),
)
@admin.register(TermsAndConditions)
class TermsAndConditionsAdmin(admin.ModelAdmin):
"""تک‌ردیفی — لیست را رد می‌کنیم و مستقیم فرم ویرایش همان یک ردیف را نشان می‌دهیم."""
list_display = ("updated_at",)
def has_add_permission(self, request):
return not TermsAndConditions.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
def changelist_view(self, request, extra_context=None):
obj = TermsAndConditions.get_solo()
return redirect("admin:account_termsandconditions_change", obj.pk)
View File
+31
View File
@@ -0,0 +1,31 @@
from rest_framework_simplejwt.tokens import RefreshToken
from apps.account.models import User
class UserSelector:
@staticmethod
def get_user_profile(user: User) -> dict:
return {
"id": user.id,
"phone": user.phone,
"name": user.name,
"state": user.state,
"created_at": user.created_at,
}
@staticmethod
def get_tokens_for_user(user: User) -> dict:
refresh = RefreshToken.for_user(user)
return {
"refresh": str(refresh),
"access": str(refresh.access_token),
}
@staticmethod
def get_token_response(user: User) -> dict:
return {
"message": "ورود با موفقیت انجام شد.",
"tokens": UserSelector.get_tokens_for_user(user),
"user": UserSelector.get_user_profile(user),
}
+40
View File
@@ -0,0 +1,40 @@
from rest_framework import serializers
from apps.account.models import phone_validator
class RequestOTPSerializer(serializers.Serializer):
phone = serializers.CharField(max_length=15, required=True, label="شماره موبایل")
referral_code = serializers.CharField(max_length=8, required=False, allow_blank=True, label="کد معرف")
def validate_phone(self, value):
phone_validator(value)
return value
class VerifyOTPSerializer(serializers.Serializer):
phone = serializers.CharField(max_length=15, required=True, label="شماره موبایل")
otp = serializers.CharField(max_length=6, required=True, label="کد تایید")
def validate_phone(self, value):
phone_validator(value)
return value
def validate(self, attrs):
from .services import UserService
user = UserService.verify_user(attrs["phone"], attrs["otp"])
attrs["user"] = user
return attrs
class LogoutSerializer(serializers.Serializer):
refresh = serializers.CharField(required=True, label="توکن رفرش")
class ConfirmWithOTPSerializer(serializers.Serializer):
otp = serializers.CharField(max_length=6, required=True, label="کد تایید")
class ProfileUpdateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100, allow_blank=True, required=False)
+135
View File
@@ -0,0 +1,135 @@
import hashlib
import hmac
import logging
import random
import string
from datetime import timedelta
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError as DRFValidationError
from apps.account.models import User, phone_validator
from apps.account.send_sms import send_otp_message
logger = logging.getLogger(__name__)
def _hash_otp(otp: str) -> str:
secret_key = settings.SECRET_KEY.encode("utf-8")
signature = hmac.new(secret_key, msg=otp.encode("utf-8"), digestmod=hashlib.sha256)
return signature.hexdigest()
class UserService:
OTP_EXPIRY_SECONDS = 120
OTP_LENGTH = 4
# فقط برای توسعه/تست — بدون نیاز به سرویس پیامک واقعی (که هنوز در .env
# تنظیم نشده)، کد تایید همیشه همین است. فقط زمانی که DEBUG=True باشد
# فعال است؛ در پروداکشن (DEBUG=False) خودکار به کد تصادفی واقعی برمی‌گردد
# — وگرنه هرکسی با دانستن شماره‌ی هرکس می‌توانست وارد حسابش شود.
DEV_FIXED_OTP = "1111"
@staticmethod
def _generate_otp() -> str:
if settings.DEBUG:
return UserService.DEV_FIXED_OTP
return "".join(random.choices(string.digits, k=UserService.OTP_LENGTH))
@staticmethod
def send_login_code(phone: str, referral_code: str | None = None) -> dict:
"""ساخت/یافتن کاربر بر اساس شماره و ارسال کد تایید"""
phone_validator(phone)
with transaction.atomic():
user, created = User.objects.get_or_create(
phone=phone,
defaults={"state": User.State.INACTIVE, "is_active": True},
)
if created and referral_code:
from apps.billing.api.services import ReferralService
ReferralService.link_referrer(user, referral_code)
if not created and user.state == User.State.BLOCKED:
raise DRFValidationError({"phone": "حساب کاربری مسدود شده است"})
if not created and user.otp_code and user.otp_expiry and timezone.now() < user.otp_expiry:
remaining = (user.otp_expiry - timezone.now()).seconds
raise DRFValidationError(
{"phone": f"کد تایید قبلی هنوز معتبر است. {remaining} ثانیه دیگر منقضی می‌شود."}
)
otp = UserService._generate_otp()
user.otp_code = _hash_otp(otp)
user.otp_expiry = timezone.now() + timedelta(seconds=UserService.OTP_EXPIRY_SECONDS)
user.save(update_fields=["otp_code", "otp_expiry"])
send_otp_message(phone, otp)
logger.info(f"OTP requested for {phone}")
return {"message": "کد تایید با موفقیت ارسال شد"}
@staticmethod
def verify_user(phone: str, otp: str) -> User:
try:
user = User.objects.get(phone=phone)
except User.DoesNotExist:
raise DRFValidationError({"phone": "کاربر یافت نشد"})
if not user.otp_code or not user.otp_expiry:
raise DRFValidationError({"otp": "کد تایید درخواست نشده است"})
if timezone.now() > user.otp_expiry:
user.otp_code = None
user.otp_expiry = None
user.save(update_fields=["otp_code", "otp_expiry"])
raise DRFValidationError({"otp": "کد تایید منقضی شده است"})
if not hmac.compare_digest(user.otp_code, _hash_otp(otp.strip())):
raise DRFValidationError({"otp": "کد وارد شده نامعتبر است"})
user.state = User.State.ACTIVE
user.last_login = timezone.now()
user.otp_code = None
user.otp_expiry = None
user.save(update_fields=["state", "last_login", "otp_code", "otp_expiry"])
from apps.billing.api.services import ReferralService
ReferralService.grant_bonus_if_needed(user)
logger.info(f"User {user.phone} verified successfully")
return user
@staticmethod
def verify_otp_for_action(user: User, otp: str) -> bool:
"""تایید OTP برای عملیات حساس (مثل حذف حساب)"""
if not user.otp_code or not user.otp_expiry:
raise DRFValidationError({"otp": "ابتدا کد تایید درخواست کنید"})
if timezone.now() > user.otp_expiry:
user.otp_code = None
user.otp_expiry = None
user.save(update_fields=["otp_code", "otp_expiry"])
raise DRFValidationError({"otp": "کد تایید منقضی شده است"})
if not hmac.compare_digest(user.otp_code, _hash_otp(otp.strip())):
raise DRFValidationError({"otp": "کد وارد شده نامعتبر است"})
user.otp_code = None
user.otp_expiry = None
user.save(update_fields=["otp_code", "otp_expiry"])
return True
@staticmethod
def update_user_profile(user: User, validated_data: dict) -> User:
update_fields = []
for key in ("name",):
if key in validated_data:
setattr(user, key, validated_data[key])
update_fields.append(key)
if update_fields:
user.save(update_fields=update_fields)
return user
+25
View File
@@ -0,0 +1,25 @@
from rest_framework.throttling import SimpleRateThrottle
class PhoneRateThrottle(SimpleRateThrottle):
"""محدودیت درخواست OTP بر اساس شماره موبایل (نه IP) تا با یک شماره ثابت اسپم نشود"""
scope = "otp_send"
def get_cache_key(self, request, view):
phone = request.data.get("phone")
if not phone:
return None
return self.cache_format % {"scope": self.scope, "ident": phone}
class OTPVerifyThrottle(SimpleRateThrottle):
"""محدودیت تلاش برای تایید OTP بر اساس شماره موبایل، برای جلوگیری از brute force"""
scope = "otp_verify"
def get_cache_key(self, request, view):
phone = request.data.get("phone")
if not phone:
return None
return self.cache_format % {"scope": self.scope, "ident": phone}
+100
View File
@@ -0,0 +1,100 @@
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from apps.account.models import TermsAndConditions
from .selectors import UserSelector
from .serializers import (
ConfirmWithOTPSerializer,
LogoutSerializer,
ProfileUpdateSerializer,
RequestOTPSerializer,
VerifyOTPSerializer,
)
from .services import UserService
from .throttles import OTPVerifyThrottle, PhoneRateThrottle
class RequestOTPView(APIView):
"""ارسال کد تایید به شماره موبایل (کاربر جدید هم همینجا خودکار ساخته می‌شود)"""
permission_classes = [AllowAny]
throttle_classes = [PhoneRateThrottle]
def post(self, request):
serializer = RequestOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
result = UserService.send_login_code(
serializer.validated_data["phone"], referral_code=serializer.validated_data.get("referral_code")
)
return Response(result, status=status.HTTP_200_OK)
class TermsView(APIView):
"""متن قوانین و مقررات — قبل از لاگین هم باید قابل‌خواندن باشد."""
permission_classes = [AllowAny]
def get(self, request):
return Response({"content": TermsAndConditions.get_solo().content})
class VerifyOTPView(APIView):
"""تایید کد و دریافت توکن‌های JWT"""
permission_classes = [AllowAny]
throttle_classes = [OTPVerifyThrottle]
def post(self, request):
serializer = VerifyOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data["user"]
return Response(UserSelector.get_token_response(user), status=status.HTTP_200_OK)
class LogoutView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = LogoutSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
RefreshToken(serializer.validated_data["refresh"]).blacklist()
except Exception:
pass
return Response({"message": "خروج با موفقیت انجام شد."})
class ProfileView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
return Response({"success": True, "data": UserSelector.get_user_profile(request.user)})
def patch(self, request):
serializer = ProfileUpdateSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
user = UserService.update_user_profile(request.user, serializer.validated_data)
return Response(
{
"success": True,
"message": "پروفایل با موفقیت بروزرسانی شد.",
"data": UserSelector.get_user_profile(user),
}
)
class ProfileDeleteView(APIView):
"""حذف حساب کاربری — نیاز به تایید OTP دارد (اول /auth/request-otp/ بزن)"""
permission_classes = [IsAuthenticated]
def delete(self, request):
serializer = ConfirmWithOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
UserService.verify_otp_for_action(request.user, serializer.validated_data["otp"])
request.user.delete()
return Response({"message": "حساب کاربری با موفقیت حذف شد."})
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.account"
verbose_name = "حساب کاربری"
+41
View File
@@ -0,0 +1,41 @@
# Generated by Django 5.1.15 on 2026-08-22 15:31
import apps.account.models
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('phone', models.CharField(max_length=11, unique=True, validators=[apps.account.models.phone_validator], verbose_name='شماره موبایل')),
('name', models.CharField(blank=True, max_length=100, verbose_name='نام')),
('state', models.CharField(choices=[('inactive', 'غیرفعال'), ('active', 'فعال'), ('blocked', 'مسدود شده')], default='inactive', max_length=10, verbose_name='وضعیت کاربر')),
('otp_code', models.CharField(blank=True, max_length=128, null=True, verbose_name='کد OTP (هش\u200cشده)')),
('otp_expiry', models.DateTimeField(blank=True, null=True, verbose_name='انقضای OTP')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_staff', models.BooleanField(default=False, verbose_name='کارمند')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='تاریخ ایجاد')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'کاربر',
'verbose_name_plural': 'کاربران',
},
),
]
@@ -0,0 +1,40 @@
# Generated by Django 5.1.15 on 2026-08-23 12:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='gem_balance',
field=models.PositiveIntegerField(default=0, verbose_name='موجودی جم'),
),
migrations.AddField(
model_name='user',
name='referral_bonus_granted',
field=models.BooleanField(default=False, verbose_name='پاداش معرفی داده\u200cشد'),
),
migrations.AddField(
model_name='user',
name='referral_code',
field=models.CharField(blank=True, max_length=8, unique=True, verbose_name='کد معرف'),
),
migrations.AddField(
model_name='user',
name='referred_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referrals', to=settings.AUTH_USER_MODEL, verbose_name='معرف'),
),
migrations.AddField(
model_name='user',
name='trial_expires_at',
field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='پایان دوره\u200cی رایگان'),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.1.15 on 2026-08-23 20:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0002_user_gem_balance_user_referral_bonus_granted_and_more'),
]
operations = [
migrations.CreateModel(
name='TermsAndConditions',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, help_text='این متن در دیالوگ «قوانین و مقررات» صفحه\u200cی ورود نشان داده می\u200cشود.', verbose_name='متن قوانین و مقررات')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'قوانین و مقررات',
'verbose_name_plural': 'قوانین و مقررات',
},
),
]
View File
+184
View File
@@ -0,0 +1,184 @@
import random
import string
from datetime import timedelta
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
TRIAL_DAYS = 7
_REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
# -------------------- Manager --------------------
class UserManager(BaseUserManager):
def create_user(self, phone, password=None, **extra_fields):
if not phone:
raise ValueError(_("شماره موبایل الزامی است"))
phone = self.normalize_phone(phone)
user = self.model(phone=phone, **extra_fields)
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.save(using=self._db)
return user
def create_superuser(self, phone, password, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("is_active", True)
extra_fields.setdefault("state", User.State.ACTIVE)
if not password:
raise ValueError(_("سوپریوزر باید رمز عبور داشته باشد"))
return self.create_user(phone, password, **extra_fields)
@staticmethod
def normalize_phone(phone: str) -> str:
phone = phone.strip().replace(" ", "").replace("-", "")
if phone.startswith("+98"):
phone = "0" + phone[3:]
elif phone.startswith("98") and len(phone) == 12:
phone = "0" + phone[2:]
return phone
# -------------------- Validators --------------------
def phone_validator(phone: str):
"""شماره موبایل: فقط عدد، شروع با 09، طول ۱۱ رقم"""
if not phone.isdigit():
raise ValidationError(_("شماره موبایل باید فقط شامل اعداد باشد"))
if not phone.startswith("09"):
raise ValidationError(_("شماره موبایل باید با 09 شروع شود"))
if len(phone) != 11:
raise ValidationError(_("شماره موبایل باید ۱۱ رقم باشد"))
return phone
# -------------------- User Model --------------------
class User(AbstractBaseUser, PermissionsMixin):
class State(models.TextChoices):
INACTIVE = "inactive", _("غیرفعال")
ACTIVE = "active", _("فعال")
BLOCKED = "blocked", _("مسدود شده")
phone = models.CharField(
max_length=11,
unique=True,
validators=[phone_validator],
verbose_name=_("شماره موبایل"),
)
name = models.CharField(max_length=100, blank=True, verbose_name=_("نام"))
state = models.CharField(
max_length=10,
choices=State.choices,
default=State.INACTIVE,
verbose_name=_("وضعیت کاربر"),
)
# OTP — کد به‌صورت هش‌شده ذخیره می‌شود، هرگز متن ساده
otp_code = models.CharField(max_length=128, blank=True, null=True, verbose_name=_("کد OTP (هش‌شده)"))
otp_expiry = models.DateTimeField(blank=True, null=True, verbose_name=_("انقضای OTP"))
is_active = models.BooleanField(default=True, verbose_name=_("فعال"))
is_staff = models.BooleanField(default=False, verbose_name=_("کارمند"))
created_at = models.DateTimeField(default=timezone.now, verbose_name=_("تاریخ ایجاد"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("تاریخ بروزرسانی"))
# -------------------- جم / اشتراک / معرفی --------------------
gem_balance = models.PositiveIntegerField(default=0, verbose_name=_("موجودی جم"))
trial_expires_at = models.DateTimeField(
default=None, null=True, blank=True, verbose_name=_("پایان دوره‌ی رایگان")
)
referral_code = models.CharField(
max_length=8, unique=True, blank=True, verbose_name=_("کد معرف")
)
referred_by = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="referrals",
verbose_name=_("معرف"),
)
# جلوگیری از دوبار پاداش‌دادن وقتی همان کاربر چندبار verify می‌شود —
# پاداشِ معرف فقط یک‌بار، دقیقا سر اولین verify موفق کاربر جدید.
referral_bonus_granted = models.BooleanField(default=False, verbose_name=_("پاداش معرفی داده‌شد"))
objects = UserManager()
USERNAME_FIELD = "phone"
REQUIRED_FIELDS = []
class Meta:
verbose_name = _("کاربر")
verbose_name_plural = _("کاربران")
def __str__(self):
return f"{self.phone} - {self.name}".strip(" -")
def save(self, *args, **kwargs):
if not self.referral_code:
self.referral_code = self._generate_referral_code()
if self.trial_expires_at is None and self._state.adding:
self.trial_expires_at = timezone.now() + timedelta(days=TRIAL_DAYS)
super().save(*args, **kwargs)
@staticmethod
def _generate_referral_code() -> str:
for _attempt in range(20):
code = "".join(random.choices(_REFERRAL_CODE_ALPHABET, k=6))
if not User.objects.filter(referral_code=code).exists():
return code
# عملا در این مقیاس هرگز رخ نمی‌دهد؛ فقط یک fallback امن
return "".join(random.choices(_REFERRAL_CODE_ALPHABET, k=8))
@property
def is_in_trial(self) -> bool:
return bool(self.trial_expires_at and timezone.now() < self.trial_expires_at)
def activate(self):
self.state = self.State.ACTIVE
self.is_active = True
self.otp_code = None
self.otp_expiry = None
self.save(update_fields=["state", "is_active", "otp_code", "otp_expiry"])
def block(self):
self.state = self.State.BLOCKED
self.is_active = False
self.save(update_fields=["state", "is_active"])
class TermsAndConditions(models.Model):
"""متن قوانین و مقررات — تک‌ردیفی (singleton)، از پنل ادمین ویرایش
می‌شود و صفحه‌ی ورود قبل از لاگین آن را می‌خواند (بدون نیاز به توکن)."""
content = models.TextField(
blank=True, verbose_name=_("متن قوانین و مقررات"), help_text=_("این متن در دیالوگ «قوانین و مقررات» صفحه‌ی ورود نشان داده می‌شود.")
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("قوانین و مقررات")
verbose_name_plural = _("قوانین و مقررات")
def __str__(self):
return "قوانین و مقررات"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_solo(cls) -> "TermsAndConditions":
obj, _created = cls.objects.get_or_create(pk=1)
return obj
+59
View File
@@ -0,0 +1,59 @@
# apps/account/send_sms.py
"""
ارسال پیامک OTP.
فقط دو مقدار از .env خوانده می‌شود (تنظیمات در config/settings.py):
- SMS_PATTERN_NAME: نام/کد قالبی که نزد سرویس‌دهنده‌ی پیامک ثبت شده
- SMS_TOKEN_URL: آدرسی که با یک درخواست POST، قالب+شماره+کد را برایش
می‌فرستیم (سرویس‌دهنده‌ی تو باید چنین آدرسی در اختیارت گذاشته باشد)
اگر ساختار درخواست سرویس‌دهنده‌ی واقعی‌ات با payload زیر فرق دارد، فقط
تابع `_call_token_url` را با مستندات سرویس‌دهنده‌ات هماهنگ کن؛ بقیه‌ی
پروژه با این ماژول کاری ندارد و فقط send_otp_message را صدا می‌زند.
"""
import logging
from threading import Thread
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 10
def _call_token_url(phone: str, code: str) -> bool:
token_url = settings.SMS_TOKEN_URL
pattern_name = settings.SMS_PATTERN_NAME
if not token_url or not pattern_name:
logger.error("SMS_TOKEN_URL یا SMS_PATTERN_NAME در .env تنظیم نشده است")
return False
payload = {
"pattern": pattern_name,
"receptor": phone,
"token": code,
}
try:
response = requests.post(token_url, json=payload, timeout=REQUEST_TIMEOUT_SECONDS)
response.raise_for_status()
logger.info(f"✅ OTP sent to {phone} via {token_url}")
return True
except requests.RequestException as exc:
logger.error(f"❌ خطا در ارسال پیامک به {phone}: {exc}")
return False
def send_otp_message(phone: str, code: str) -> None:
"""ارسال غیربلاکینگ کد تایید (شکست ارسال، درخواست HTTP کاربر را کند نمی‌کند)"""
def _send():
try:
_call_token_url(phone, code)
except Exception as exc: # noqa: BLE001 - لاگ و رد شدن، نباید کل ترد را بترکاند
logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}")
Thread(target=_send, daemon=True).start()
+15
View File
@@ -0,0 +1,15 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from .api import views
app_name = "account"
urlpatterns = [
path("terms/", views.TermsView.as_view(), name="terms"),
path("auth/request-otp/", views.RequestOTPView.as_view(), name="request-otp"),
path("auth/verify-otp/", views.VerifyOTPView.as_view(), name="verify-otp"),
path("auth/token/refresh/", TokenRefreshView.as_view(), name="token-refresh"),
path("auth/logout/", views.LogoutView.as_view(), name="logout"),
path("auth/profile/", views.ProfileView.as_view(), name="profile"),
path("auth/profile/delete/", views.ProfileDeleteView.as_view(), name="profile-delete"),
]