adding
This commit is contained in:
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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": "حساب کاربری با موفقیت حذف شد."})
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.account"
|
||||
verbose_name = "حساب کاربری"
|
||||
@@ -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': 'قوانین و مقررات',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
]
|
||||
Reference in New Issue
Block a user