Initial commit

This commit is contained in:
2026-02-27 15:01:06 +03:00
commit fc06bb6fcd
52 changed files with 1355 additions and 0 deletions
View File
+33
View File
@@ -0,0 +1,33 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from apps.accounts.models import PhoneOTP, User
@admin.register(User)
class UserAdmin(DjangoUserAdmin):
model = User
list_display = ("email", "phone_number", "role", "is_staff", "is_phone_verified")
list_filter = ("role", "is_staff", "is_phone_verified")
ordering = ("email",)
search_fields = ("email", "phone_number")
fieldsets = (
(None, {"fields": ("email", "password")}),
("Personal", {"fields": ("first_name", "last_name", "phone_number")}),
("Roles", {"fields": ("role", "is_phone_verified")}),
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
("Dates", {"fields": ("last_login",)}),
)
add_fieldsets = (
(None, {
"classes": ("wide",),
"fields": ("email", "password1", "password2", "role"),
}),
)
@admin.register(PhoneOTP)
class PhoneOTPAdmin(admin.ModelAdmin):
list_display = ("phone_number", "channel", "provider", "created_at", "expires_at", "verified_at")
list_filter = ("channel", "provider")
search_fields = ("phone_number",)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
+78
View File
@@ -0,0 +1,78 @@
import uuid
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
from django.db import models
from django.utils import timezone
class UserRole(models.TextChoices):
ADMIN = "admin", "Admin"
MANAGER = "manager", "Salon Manager"
STAFF = "staff", "Staff"
CUSTOMER = "customer", "Customer"
class UserManager(BaseUserManager):
def create_user(self, email, password=None, **extra_fields):
if not email:
raise ValueError("Email is required")
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("role", UserRole.ADMIN)
if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True")
if extra_fields.get("is_superuser") is not True:
raise ValueError("Superuser must have is_superuser=True")
return self.create_user(email, password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True)
phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True)
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER)
first_name = models.CharField(max_length=150, blank=True)
last_name = models.CharField(max_length=150, blank=True)
is_phone_verified = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = UserManager()
USERNAME_FIELD = "email"
def __str__(self):
return self.email
class OtpChannel(models.TextChoices):
SMS = "sms", "SMS"
WHATSAPP = "whatsapp", "WhatsApp"
class PhoneOTP(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
phone_number = models.CharField(max_length=20)
channel = models.CharField(max_length=20, choices=OtpChannel.choices)
provider = models.CharField(max_length=50)
code_hash = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
verified_at = models.DateTimeField(null=True, blank=True)
def is_expired(self):
return timezone.now() >= self.expires_at
@classmethod
def expiry_at(cls):
return timezone.now() + timedelta(minutes=settings.OTP_EXPIRY_MINUTES)
+41
View File
@@ -0,0 +1,41 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from apps.accounts.models import OtpChannel, PhoneOTP
User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "email", "phone_number", "first_name", "last_name", "role", "is_phone_verified"]
read_only_fields = ["id", "role", "is_phone_verified"]
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8)
class Meta:
model = User
fields = ["email", "password", "phone_number", "first_name", "last_name"]
def create(self, validated_data):
return User.objects.create_user(**validated_data)
class OTPRequestSerializer(serializers.Serializer):
phone_number = serializers.CharField(max_length=20)
channel = serializers.ChoiceField(choices=OtpChannel.choices)
class OTPVerifySerializer(serializers.Serializer):
request_id = serializers.UUIDField()
code = serializers.CharField(max_length=6)
class OTPStatusSerializer(serializers.ModelSerializer):
class Meta:
model = PhoneOTP
fields = ["id", "phone_number", "channel", "provider", "created_at", "expires_at", "verified_at"]
read_only_fields = fields
+83
View File
@@ -0,0 +1,83 @@
import logging
import secrets
from dataclasses import dataclass
from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password
from django.utils import timezone
from apps.accounts.models import OtpChannel, PhoneOTP
logger = logging.getLogger(__name__)
@dataclass
class OtpSendResult:
request_id: str
expires_at: str
class BaseOtpProvider:
def send_sms(self, to_number: str, message: str) -> None:
raise NotImplementedError
def send_whatsapp(self, to_number: str, message: str) -> None:
raise NotImplementedError
class ConsoleOtpProvider(BaseOtpProvider):
def send_sms(self, to_number: str, message: str) -> None:
logger.info("OTP SMS to %s: %s", to_number, message)
def send_whatsapp(self, to_number: str, message: str) -> None:
logger.info("OTP WhatsApp to %s: %s", to_number, message)
PROVIDERS = {
"console": ConsoleOtpProvider,
}
def get_provider() -> BaseOtpProvider:
provider_key = settings.OTP_PROVIDER
provider_cls = PROVIDERS.get(provider_key)
if not provider_cls:
raise ValueError(f"Unknown OTP provider: {provider_key}")
return provider_cls()
def generate_code(length: int = 6) -> str:
digits = "0123456789"
return "".join(secrets.choice(digits) for _ in range(length))
def create_and_send_otp(phone_number: str, channel: str) -> OtpSendResult:
provider = get_provider()
code = generate_code()
otp = PhoneOTP.objects.create(
phone_number=phone_number,
channel=channel,
provider=settings.OTP_PROVIDER,
code_hash=make_password(code),
expires_at=PhoneOTP.expiry_at(),
)
message = f"Your verification code is {code}. It expires in {settings.OTP_EXPIRY_MINUTES} minutes."
if channel == OtpChannel.SMS:
provider.send_sms(phone_number, message)
elif channel == OtpChannel.WHATSAPP:
provider.send_whatsapp(phone_number, message)
else:
raise ValueError("Unsupported OTP channel")
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
def verify_otp(otp: PhoneOTP, code: str) -> bool:
if otp.verified_at or otp.is_expired():
return False
if not check_password(code, otp.code_hash):
return False
otp.verified_at = timezone.now()
otp.save(update_fields=["verified_at"])
return True
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from apps.accounts.views import MeView, OTPRequestView, OTPVerifyView, RegisterView, SocialLoginPlaceholderView
urlpatterns = [
path("register/", RegisterView.as_view(), name="register"),
path("me/", MeView.as_view(), name="me"),
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path("otp/request/", OTPRequestView.as_view(), name="otp_request"),
path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"),
path("social/<str:provider>/", SocialLoginPlaceholderView.as_view(), name="social_login"),
]
+72
View File
@@ -0,0 +1,72 @@
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404
from rest_framework import generics, permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.models import PhoneOTP
from apps.accounts.serializers import (
OTPRequestSerializer,
OTPVerifySerializer,
RegisterSerializer,
UserSerializer,
)
from apps.accounts.services.otp import create_and_send_otp, verify_otp
User = get_user_model()
class RegisterView(generics.CreateAPIView):
serializer_class = RegisterSerializer
permission_classes = [permissions.AllowAny]
class MeView(generics.RetrieveUpdateAPIView):
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_object(self):
return self.request.user
class OTPRequestView(APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = OTPRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
result = create_and_send_otp(data["phone_number"], data["channel"])
return Response(
{"request_id": result.request_id, "expires_at": result.expires_at},
status=status.HTTP_201_CREATED,
)
class OTPVerifyView(APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = OTPVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
if not verify_otp(otp, data["code"]):
return Response({"detail": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST)
user = User.objects.filter(phone_number=otp.phone_number).first()
if user and not user.is_phone_verified:
user.is_phone_verified = True
user.save(update_fields=["is_phone_verified"])
return Response({"detail": "Phone verified"}, status=status.HTTP_200_OK)
class SocialLoginPlaceholderView(APIView):
permission_classes = [permissions.AllowAny]
def post(self, request, provider):
return Response(
{"detail": "Social login not configured yet. Add OAuth provider config."},
status=status.HTTP_501_NOT_IMPLEMENTED,
)