initial commit

This commit is contained in:
2026-03-11 17:12:28 +08:00
commit 5d1e1cb7cb
61 changed files with 2971 additions and 0 deletions

0
apps/users/__init__.py Normal file
View File

183
apps/users/admin.py Normal file
View File

@@ -0,0 +1,183 @@
from django.contrib import admin, messages
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import SetPasswordForm
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.urls import reverse_lazy
from import_export import resources
from unfold.decorators import action as unfold_action
from core.admins.base import BaseAdmin, SoftDeleteListFilter
from apps.users.services.forms import CustomUserChangeForm, CustomUserCreationForm
User = get_user_model()
class UserResource(resources.ModelResource):
class Meta:
model = User
@admin.register(User)
class CustomUserAdmin(BaseUserAdmin, BaseAdmin):
model = User
resource_class = UserResource
form = CustomUserChangeForm
add_form = CustomUserCreationForm
list_display = (
"full_name",
"mobile",
"is_verified",
"is_staff",
"created_at",
"updated_at",
"is_active",
"is_deleted",
)
list_editable = ("mobile",)
search_fields = (
"first_name",
"last_name",
"mobile",
"email",
)
list_filter = (
SoftDeleteListFilter,
"is_verified",
"is_active",
"is_staff",
"is_superuser",
"is_deleted",
"created_at",
)
ordering = ("-created_at",)
readonly_fields = (
"id",
"created_at",
"updated_at",
"deleted_at",
"last_login",
"date_joined",
)
date_hierarchy = "created_at"
actions = (
"hard_delete_selected",
"restore_selected",
"mark_verified",
"mark_unverified",
"activate_users",
"deactivate_users",
)
fieldsets = (
(None, {"fields": ("id", "mobile", "password")}),
(
"Personal Info",
{
"fields": (
"first_name",
"last_name",
"email",
"profile_picture",
"birth_date",
"description",
)
},
),
(
"Permissions",
{
"fields": (
"is_verified",
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
)
},
),
("Audit", {"fields": ("created_by", "updated_by")}),
(
"Important Dates",
{
"fields": (
"last_login",
"date_joined",
"created_at",
"updated_at",
"deleted_at",
"is_deleted",
"password_updated_at",
)
},
),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": (
"mobile",
"password1",
"password2",
"first_name",
"last_name",
"email",
"profile_picture",
"is_verified",
"is_active",
),
},
),
)
filter_horizontal = ("groups", "user_permissions")
actions_row = [
"reset_password_action",
]
@unfold_action(description="Reset user password")
def reset_password_action(self, request, object_id):
user = User.objects.get(pk=object_id)
form = SetPasswordForm(user=user, data=request.POST or None)
if request.method == "POST" and form.is_valid():
password = form.cleaned_data["new_password1"]
user.set_password(password)
user.save()
messages.success(request, f"User '{user}' - password reset.")
changelist_url = reverse_lazy(
f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_changelist"
)
return redirect(changelist_url)
context = {
**self.admin_site.each_context(request),
"title": "Reset User Password",
"opts": self.model._meta,
"form": form,
"action_name": "reset_password",
"action_checkbox_name": ACTION_CHECKBOX_NAME,
}
return TemplateResponse(request, "forms/admin_reset_password.html", context)
@admin.action(description="Mark selected users as verified")
def mark_verified(self, request, queryset):
queryset.update(is_verified=True)
@admin.action(description="Mark selected users as unverified")
def mark_unverified(self, request, queryset):
queryset.update(is_verified=False)
@admin.action(description="Activate selected users")
def activate_users(self, request, queryset):
queryset.update(is_active=True)
@admin.action(description="Deactivate selected users")
def deactivate_users(self, request, queryset):
queryset.update(is_active=False)

View File

@@ -0,0 +1,131 @@
import logging
import random
import string
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from django_redis import get_redis_connection
from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers
from core.serializers.base import BaseModelSerializer
from apps.users.tasks import send_verification_sms
from apps.users.utils import record_login_attempt
User = get_user_model()
logger = logging.getLogger(__name__)
class UserProfilePictureSerializer(BaseModelSerializer):
class Meta:
model = User
fields = BaseModelSerializer.Meta.fields + ("profile_picture",)
class UserListSerializer(BaseModelSerializer):
full_name = serializers.CharField(read_only=True)
class Meta:
model = User
fields = BaseModelSerializer.Meta.fields + (
"mobile",
"full_name",
"profile_picture",
)
class RegisterSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
code = serializers.CharField(max_length=6)
password = serializers.CharField(write_only=True)
re_password = serializers.CharField(write_only=True)
first_name = serializers.CharField(max_length=100, required=False, allow_blank=True)
last_name = serializers.CharField(max_length=100, required=False, allow_blank=True)
def validate(self, data):
mobile = data.get("mobile", "")
password = data.get("password", "")
re_password = data.get("re_password", "")
if not (mobile.isdigit() and len(mobile) == 11):
raise serializers.ValidationError({"mobile": "فرمت شماره موبایل نادرست است."})
if password != re_password:
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
return data
@extend_schema_serializer(component_name="UsersSendOTP")
class SendOTPSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
mode = serializers.ChoiceField(choices=["register", "login", "forget_password"])
def validate_mobile(self, value):
"""
Normalize and validate Iranian mobile numbers (example: 09XXXXXXXXX).
"""
if not value.isdigit() or len(value) != 11 or not value.startswith("09"):
raise serializers.ValidationError("شماره موبایل معتبر نیست.")
return value
@extend_schema_serializer(component_name="UsersLoginOtp")
class LoginOtpSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
code = serializers.CharField(max_length=6)
def validate_mobile(self, value):
if not (value.isdigit() and len(value) == 11):
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
return value
class LoginSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
password = serializers.CharField(write_only=True)
def validate_mobile(self, value):
if not (value.isdigit() and len(value) == 11):
raise serializers.ValidationError("فرمت شماره موبایل نادرست است.")
return value
class ResetPasswordSerializer(serializers.Serializer):
mobile = serializers.CharField(max_length=11)
code = serializers.CharField(max_length=6)
password = serializers.CharField(write_only=True)
re_password = serializers.CharField(write_only=True)
def validate(self, data):
if data.get("password") != data.get("re_password"):
raise serializers.ValidationError({"password": "رمز عبور مطابقت ندارد."})
return data
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(required=True, write_only=True)
new_password = serializers.CharField(required=True, write_only=True)
re_password = serializers.CharField(required=True, write_only=True)
def validate(self, data):
if data.get("new_password") != data.get("re_password"):
raise serializers.ValidationError({"new_password": "رمز عبور جدید و تکرار آن مطابقت ندارند."})
return data
class LogoutSerializer(serializers.Serializer):
refresh = serializers.CharField()
class TokenPairSerializer(serializers.Serializer):
access = serializers.CharField()
refresh = serializers.CharField()
class RegisterWithPasswordSerializer(serializers.Serializer):
mobile = serializers.CharField()
password = serializers.CharField()

21
apps/users/api/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from apps.users.api import views
app_name = "users"
urlpatterns = [
path("register/", views.RegisterWithOTPView.as_view(), name="register_verify"),
path("otp/send/", views.SendOTPView.as_view(), name="send_otp"),
path("otp/login/", views.LoginOTPView.as_view(), name="login_otp"),
path("login/", views.LoginView.as_view(), name="login"),
path("logout/", views.LogoutView.as_view(), name="logout"),
path("password/set/", views.SetPasswordView.as_view(), name="set_password"),
path("password/reset/", views.ResetPasswordView.as_view(), name="reset_password"),
path("password/change/", views.ChangePasswordView.as_view(), name="change_password"),
path("profile/picture/", views.ProfilePictureView.as_view(), name="profile_picture"),
path("list/", views.UserListView.as_view(), name="user_list"),
path("token/obtain/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]

237
apps/users/api/views.py Normal file
View File

@@ -0,0 +1,237 @@
from django.contrib.auth import get_user_model
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import serializers, status
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.generics import ListAPIView, UpdateAPIView
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.tokens import RefreshToken
from core.paginations.limit_offset import CustomLimitOffsetPagination
from apps.users.api.serializers import (
ChangePasswordSerializer,
LoginOtpSerializer,
LoginSerializer,
RegisterSerializer,
ResetPasswordSerializer,
SendOTPSerializer,
UserListSerializer,
UserProfilePictureSerializer,
LogoutSerializer,
TokenPairSerializer,
RegisterWithPasswordSerializer,
)
from apps.users.services.auth import (
register_user_with_password,
register_user_with_otp,
generate_and_send_otp,
login_with_password,
login_with_otp,
reset_password_with_otp,
change_password,
logout_user
)
User = get_user_model()
class RegisterWithPasswordView(APIView):
"""
Sign-up with mobile and password
"""
permission_classes = (AllowAny,)
@extend_schema(request=RegisterWithPasswordSerializer, responses=TokenPairSerializer)
def post(self, request):
mobile = request.data.get("mobile")
password = request.data.get("password")
if not mobile or not password:
return Response(
{"detail": "mobile and password required"},
status=status.HTTP_400_BAD_REQUEST,
)
tokens = register_user_with_password(mobile, password)
return Response(tokens, status=status.HTTP_201_CREATED)
class RegisterWithOTPView(APIView):
"""
A view for registring with OTP and issuing JWT tokens.
"""
permission_classes = (AllowAny,)
@extend_schema(request=RegisterSerializer, responses=TokenPairSerializer)
def post(self, request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data.copy()
data.pop("re_password", None)
tokens = register_user_with_otp(**data)
return Response(tokens, status=status.HTTP_201_CREATED)
class SendOTPView(APIView):
"""
send OTP code for on of the following actions:
+ registrations
+ login
+ password reset
"""
permission_classes = (AllowAny,)
@extend_schema(request=SendOTPSerializer, responses=None)
def post(self, request):
serializer = SendOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
generate_and_send_otp(
mobile=serializer.validated_data["mobile"],
mode=serializer.validated_data["mode"]
)
return Response({"detail": "OTP sent successfully"}, status=status.HTTP_200_OK)
class LoginView(APIView):
permission_classes = (AllowAny,)
@extend_schema(request=LoginSerializer, responses=TokenPairSerializer)
def post(self, request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
tokens = login_with_password(
mobile=serializer.validated_data["mobile"],
password=serializer.validated_data["password"],
request=request
)
return Response(tokens, status=status.HTTP_200_OK)
class LoginOTPView(APIView):
permission_classes = (AllowAny,)
@extend_schema(request=LoginOtpSerializer, responses=TokenPairSerializer)
def post(self, request):
serializer = LoginOtpSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
tokens = login_with_otp(
mobile=serializer.validated_data["mobile"],
code=serializer.validated_data["code"],
request=request
)
return Response(tokens, status=status.HTTP_200_OK)
class ResetPasswordView(APIView):
permission_classes = (AllowAny,)
serializer_class = ResetPasswordSerializer
@extend_schema(request=ResetPasswordSerializer)
def post(self, request):
serializer = ResetPasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
reset_password_with_otp(
mobile=serializer.validated_data["mobile"],
code=serializer.validated_data["code"],
password=serializer.validated_data["password"]
)
return Response({"detail": "رمز عبور با موفقیت تغییر کرد."}, status=status.HTTP_200_OK)
class ChangePasswordView(APIView):
permission_classes = [IsAuthenticated]
serializer_class = ChangePasswordSerializer
@extend_schema(request=ChangePasswordSerializer)
def patch(self, request, *args, **kwargs):
serializer = ChangePasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
change_password(
user=request.user,
old_password=serializer.validated_data["old_password"],
new_password=serializer.validated_data["new_password"]
)
return Response({"detail": "رمز عبور با موفقیت تغییر کرد."}, status=status.HTTP_200_OK)
class LogoutView(APIView):
permission_classes = (IsAuthenticated,)
serializer_class = LogoutSerializer
@extend_schema(
request=inline_serializer(
name="LogoutRequest",
fields={"refresh": serializers.CharField()}
)
)
def post(self, request):
refresh_token = request.data.get("refresh")
logout_user(refresh_token)
return Response(status=status.HTTP_205_RESET_CONTENT)
class SetPasswordView(UpdateAPIView):
permission_classes = [IsAuthenticated]
authentication_classes = [JWTAuthentication]
serializer_class = ChangePasswordSerializer
@extend_schema(request=ChangePasswordSerializer, responses=None)
def patch(self, request, *args, **kwargs):
return super().patch(request, *args, **kwargs)
def get_object(self):
return self.request.user
class ProfilePictureView(APIView):
"""
Update the authenticated user's profile picture.
"""
permission_classes = [IsAuthenticated]
authentication_classes = [JWTAuthentication]
parser_classes = [MultiPartParser, FormParser]
@extend_schema(
request=UserProfilePictureSerializer,
responses=UserProfilePictureSerializer,
operation_id="users_profile_picture_self_create",
)
def post(self, request):
serializer = UserProfilePictureSerializer(
instance=request.user,
data=request.data,
context={"request": request},
partial=True,
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
class UserListView(ListAPIView):
permission_classes = [IsAuthenticated]
authentication_classes = [JWTAuthentication]
serializer_class = UserListSerializer
queryset = User.objects.all()
pagination_class = CustomLimitOffsetPagination
filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter)
search_fields = ("first_name", "last_name", "mobile")
ordering = ("-created_at",)
ordering_fields = ("first_name", "created_at")
@extend_schema(responses=UserListSerializer(many=True))
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)

7
apps/users/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.users"
verbose_name = "01-users"

View File

71
apps/users/models.py Normal file
View File

@@ -0,0 +1,71 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from core.models.base import BaseModel
from core.utils import calculate_age, common_datetime_str
from apps.users.services.managers import UserManager
class User(AbstractUser, BaseModel):
username = None
mobile = models.CharField(max_length=11, unique=True)
email = models.EmailField(blank=True, default="")
description = models.TextField(blank=True, default="")
profile_picture = models.ImageField(upload_to="profile/users/", blank=True, null=True)
birth_date = models.DateField(blank=True, null=True)
password_updated_at = models.DateTimeField(blank=True, null=True)
is_verified = models.BooleanField(default=False)
USERNAME_FIELD = "mobile"
REQUIRED_FIELDS = []
objects = UserManager(alive_only=True)
@property
def full_name(self):
full_name = f"{self.first_name} {self.last_name}".strip()
return full_name if full_name else "Anonymous"
@property
def age(self):
return calculate_age(self.birth_date)
@property
def created_at_display(self):
return common_datetime_str(self.created_at)
def __str__(self):
return self.full_name or self.mobile
class Meta:
verbose_name = "user"
verbose_name_plural = "users"
db_table = "user"
ordering = ("-updated_at", "-created_at")
indexes = (
models.Index(fields=["id"], name="user_id_idx"),
models.Index(fields=["mobile"], name="user_mobile_idx"),
)
class LoginAttempt(BaseModel):
class StatusType(models.IntegerChoices):
FAILED = 0, "failed"
SUCCESS = 1, "success"
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
status = models.PositiveSmallIntegerField(choices=StatusType.choices, default=StatusType.FAILED)
ip_address = models.GenericIPAddressField(null=True, blank=True)
class Meta:
verbose_name = "login_attempts"
verbose_name_plural = "login_attempts"
db_table = "login_attempt"
ordering = ("-updated_at", "-created_at")
def __str__(self):
return f"LoginAttempt for User: {self.user} ({'' if self.status else ''})"

174
apps/users/services/auth.py Normal file
View File

@@ -0,0 +1,174 @@
import random
import string
from django.utils import timezone
from django.contrib.auth import get_user_model
from django.db import transaction
from django_redis import get_redis_connection
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
from rest_framework.exceptions import ValidationError
from apps.users.tasks import send_verification_sms
from apps.users.utils import record_login_attempt
from apps.users.models import LoginAttempt
User = get_user_model()
def get_tokens_for_user(user):
"""Helper service to generate JWT tokens."""
refresh = RefreshToken.for_user(user)
return {
"access": str(refresh.access_token),
"refresh": str(refresh),
}
def register_user_with_password(mobile, password):
"""Business logic for registering a user with just a password."""
user, created, restored = User.get_or_restore(mobile=mobile)
if not created and not restored:
raise ValidationError({"detail": "User already exists."})
user.set_password(password)
user.save()
return get_tokens_for_user(user)
@transaction.atomic
def register_user_with_otp(mobile, code, password, first_name="", last_name=""):
"""Business logic for verifying OTP and registering a user."""
# 1. Check if user already exists
if User.objects.filter(mobile=mobile).exists():
raise ValidationError({"mobile": "این شماره قبلاً ثبت شده است."})
# 2. Verify OTP in Redis
redis_conn = get_redis_connection("default")
stored_code = redis_conn.get(f"verification_code:{mobile}")
if not stored_code:
raise ValidationError({"code": "کد تأیید یافت نشد."})
if stored_code.decode("utf-8") != code:
raise ValidationError({"code": "کد تأیید اشتباه است."})
# 3. Create User
user = User.objects.create_user(
mobile=mobile,
password=password,
first_name=first_name,
last_name=last_name,
is_verified=True,
is_active=True,
)
# 4. Clean up Redis
redis_conn.delete(f"verification_code:{mobile}")
return get_tokens_for_user(user)
def generate_and_send_otp(mobile, mode):
"""Business logic for generating OTP, checking existence rules, and sending SMS."""
user_exists = User.objects.filter(mobile=mobile).exists()
# Apply business rules based on mode
if mode == "register" and user_exists:
raise ValidationError({"mobile": "این شماره قبلاً ثبت‌نام شده است."})
if mode in ["login", "forget_password"] and not user_exists:
raise ValidationError({"mobile": "این شماره یافت نشد."})
# Generate OTP
verification_code = "".join(random.choices(string.digits, k=5))
# Store in Redis (Assuming 2 minutes / 120 seconds expiry)
redis_conn = get_redis_connection("default")
redis_conn.setex(f"verification_code:{mobile}", 120, verification_code)
# Trigger async SMS task
send_verification_sms.delay(mobile, verification_code)
def login_with_password(mobile, password, request=None):
"""Authenticate user with password and record the attempt."""
user = User.objects.filter(mobile=mobile).first()
if not user or not user.check_password(password):
record_login_attempt(request, user, LoginAttempt.StatusType.FAILED)
raise ValidationError({"detail": "شماره موبایل یا رمز عبور اشتباه است."})
if not user.is_active:
raise ValidationError({"detail": "حساب کاربری شما غیرفعال شده است."})
record_login_attempt(request, user, LoginAttempt.StatusType.SUCCESS)
return get_tokens_for_user(user)
def login_with_otp(mobile, code, request=None):
"""Authenticate or implicitly register user via OTP."""
redis_conn = get_redis_connection("default")
stored_code = redis_conn.get(f"verification_code:{mobile}")
if not stored_code or stored_code.decode("utf-8") != code:
record_login_attempt(request, None, LoginAttempt.StatusType.FAILED)
raise ValidationError({"code": "کد تایید نامعتبر است یا منقضی شده است."})
# Fixed Bug: Use `mobile=mobile`, not `username=mobile`
user, created = User.objects.get_or_create(mobile=mobile)
if created:
user.set_unusable_password()
user.save()
if not user.is_active:
raise ValidationError({"detail": "حساب کاربری شما غیرفعال شده است."})
record_login_attempt(request, user, LoginAttempt.StatusType.SUCCESS)
# Clean up Redis
redis_conn.delete(f"verification_code:{mobile}")
return get_tokens_for_user(user)
def reset_password_with_otp(mobile, code, password):
"""Verify OTP and change forgotten password."""
user = User.objects.filter(mobile=mobile).first()
if not user:
raise ValidationError({"mobile": "کاربری با این شماره یافت نشد."})
redis_conn = get_redis_connection("default")
stored_code = redis_conn.get(f"verification_code:{mobile}")
if not stored_code or stored_code.decode("utf-8") != code:
raise ValidationError({"code": "کد تایید نامعتبر است یا منقضی شده است."})
# Update password
user.set_password(password)
user.save()
# Fixed Bug: Ensure we delete the correct key
redis_conn.delete(f"verification_code:{mobile}")
def change_password(user, old_password, new_password):
"""Change password for an already authenticated user."""
if not user.check_password(old_password):
raise ValidationError({"old_password": "رمز عبور فعلی اشتباه است."})
user.set_password(new_password)
user.password_updated_at = timezone.now()
# Save only the fields that changed for DB performance
user.save(update_fields=["password", "password_updated_at"])
def logout_user(refresh_token_str):
"""Blacklist the user's refresh token."""
if not refresh_token_str:
raise ValidationError({"refresh": "توکن رفرش الزامی است."})
try:
token = RefreshToken(refresh_token_str)
token.blacklist()
except TokenError:
raise ValidationError({"detail": "توکن نامعتبر است یا قبلا منقضی شده است."})

View File

@@ -0,0 +1,15 @@
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from apps.users.models import User
class CustomUserCreationForm(UserCreationForm):
class Meta:
model = User
fields = ("mobile", "first_name", "last_name")
class CustomUserChangeForm(UserChangeForm):
class Meta:
model = User
fields = ("mobile", "is_verified")

View File

@@ -0,0 +1,25 @@
from django.contrib.auth.models import BaseUserManager
from core.models.base import SoftDeleteManager
class UserManager(BaseUserManager, SoftDeleteManager):
use_in_migrations = True
def _create_user(self, mobile, password, **extra_fields):
if not mobile:
raise ValueError("Mobile must be set")
user = self.model(mobile=mobile, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(self, mobile, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
return self._create_user(mobile, password, **extra_fields)
def create_superuser(self, mobile, password, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
return self._create_user(mobile, password, **extra_fields)

63
apps/users/tasks.py Normal file
View File

@@ -0,0 +1,63 @@
import logging
import requests
from celery import shared_task
from django.conf import settings
logger = logging.getLogger(__name__)
SMS_APIKEY = settings.SMS_APIKEY
def _send_sms(receptor, pattern_code, variables: list = None):
"""
Send OTP SMS using SMS.ir pattern-based API
"""
SMS_ENDPOINT = "https://api.sms.ir/v1/send/verify"
variables = variables or []
headers = {"Content-Type": "application/json", "Accept": "text/plain", "x-api-key": SMS_APIKEY}
payload = {
"mobile": receptor,
"templateId": str(pattern_code),
"parameters": variables,
}
logger.info(f"Sending SMS to {receptor} with payload: {payload}")
try:
response = requests.post(SMS_ENDPOINT, data=payload, headers=headers, timeout=10)
logger.info(f"Response status: {response.status_code}")
logger.info(f"Response text: {response.text}")
if response.status_code == 200:
result = response.json()
if str(result.get("status", "")) == "1":
logger.info(f"SMS sent successfully to {receptor}")
else:
logger.error(f"SMS.ir API error: {result}")
else:
logger.error(f"HTTP error sending SMS: {response.status_code} - {response.text}")
return response
except requests.exceptions.RequestException:
logger.error("Network error in send_sms", exc_info=True)
return None
@shared_task
def send_verification_sms(mobile, code):
logger.info(f"Starting to send SMS to {mobile} with code {code}")
try:
variables = [{"name": "OTP", "value": str(code)}]
response = _send_sms(mobile, 570574, variables=variables)
if response and response.status_code == 200:
logger.info(f"Verification SMS sent to {mobile}")
else:
raise Exception("Failed to send SMS")
except Exception as e:
logger.error(f"Error in send_verification_sms: {e}", exc_info=True)
raise # For Celery retry

16
apps/users/utils.py Normal file
View File

@@ -0,0 +1,16 @@
from apps.users.models import LoginAttempt
def _get_ip(request) -> str | None:
if not request:
return None
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
return x_forwarded_for.split(",")[0] if x_forwarded_for else request.META.get("REMOTE_ADDR")
def record_login_attempt(request, user=None, status=LoginAttempt.StatusType.FAILED):
LoginAttempt.objects.create(
user=user,
status=status,
ip_address=_get_ip(request),
)

65
apps/workspaces/admin.py Normal file
View File

@@ -0,0 +1,65 @@
from django.contrib import admin
from core.admins.base import BaseAdmin
from apps.workspaces.models import Workspace, WorkspaceMembership
class WorkspaceMembershipInline(admin.TabularInline):
model = WorkspaceMembership
extra = 0
autocomplete_fields = ("user",)
@admin.register(Workspace)
class WorkspaceAdmin(BaseAdmin):
list_display = (
"id",
"name",
"owner",
"created_at",
"updated_at",
"is_deleted",
)
search_fields = (
"name",
"owner__mobile",
)
list_filter = (
"created_at",
"updated_at",
"is_deleted",
)
autocomplete_fields = ("owner",)
inlines = (WorkspaceMembershipInline,)
@admin.register(WorkspaceMembership)
class WorkspaceMembershipAdmin(BaseAdmin):
list_display = (
"id",
"workspace",
"user",
"role",
"is_active",
"created_at",
)
list_filter = (
"role",
"is_active",
"is_deleted",
)
search_fields = (
"workspace__name",
"user__mobile",
)
autocomplete_fields = (
"workspace",
"user",
)

View File

@@ -0,0 +1,19 @@
import django_filters as filters
from apps.workspaces.models import Workspace, WorkspaceMembership
from core.filters.base import BaseFilterSet
class WorkspaceFilter(BaseFilterSet):
class Meta:
model = Workspace
fields = ["owner"]
class WorkspaceMembershipFilter(BaseFilterSet):
role = filters.MultipleChoiceFilter(choices=WorkspaceMembership.Role.choices)
joined_after = filters.DateTimeFilter(field_name="joined_at", lookup_expr="gte")
joined_before = filters.DateTimeFilter(field_name="joined_at", lookup_expr="lte")
class Meta:
model = WorkspaceMembership
fields = ["workspace", "user", "role", "is_active"]

View File

@@ -0,0 +1,108 @@
from rest_framework import permissions
from apps.workspaces.models import Workspace, WorkspaceMembership
class IsWorkspaceOwner(permissions.BasePermission):
"""
Permission check:
- User must be the explicit 'owner' on the Workspace model.
- OR User must have a WorkspaceMembership with the 'OWNER' role.
"""
message = "Access denied. You must be the Workspace Owner to perform this action."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, 'workspace'):
workspace = obj.workspace
else:
return False
if workspace.owner == request.user:
return True
return WorkspaceMembership.objects.filter(
workspace=workspace,
user=request.user,
role=WorkspaceMembership.Role.OWNER,
is_active=True
).exists()
class IsWorkspaceAdmin(permissions.BasePermission):
"""
Permission check:
- User's role in the workspace is either 'ADMIN' or 'OWNER'.
"""
message = "Access denied. You must be a Workspace Admin or Owner to perform this action."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, 'workspace'):
workspace = obj.workspace
else:
return False
if workspace.owner == request.user:
return True
allowed_roles = [
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
]
return WorkspaceMembership.objects.filter(
workspace=workspace,
user=request.user,
role__in=allowed_roles,
is_active=True
).exists()
class IsWorkspaceMember(permissions.BasePermission):
"""
Permission check:
- User's role in the workspace is 'OWNER', 'ADMIN', or 'MEMBER'.
"""
message = "Access denied. You must be an active member of this workspace."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, 'workspace'):
workspace = obj.workspace
else:
return False
if workspace.owner == request.user:
return True
allowed_roles = [
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
WorkspaceMembership.Role.MEMBER,
]
return WorkspaceMembership.objects.filter(
workspace=workspace,
user=request.user,
role__in=allowed_roles,
is_active=True
).exists()

View File

@@ -0,0 +1,24 @@
from rest_framework import serializers
from core.serializers.base import BaseModelSerializer
from apps.workspaces.models import Workspace, WorkspaceMembership
class WorkspaceSerializer(BaseModelSerializer):
class Meta:
model = Workspace
fields = BaseModelSerializer.Meta.fields + (
"name",
"description",
)
class WorkspaceMembershipSerializer(BaseModelSerializer):
class Meta:
model = WorkspaceMembership
fields = BaseModelSerializer.Meta.fields + (
"workspace",
"user",
"role",
"is_active",
)

View File

@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.workspaces.api.views import WorkspaceViewSet, WorkspaceMembershipViewSet
router = DefaultRouter()
router.register(r'workspaces', WorkspaceViewSet, basename='workspace')
router.register(r'workspace-memberships', WorkspaceMembershipViewSet, basename='workspace-membership')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,104 @@
from django.db.models import Q
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.response import Response
from rest_framework.filters import OrderingFilter, SearchFilter
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated
from apps.workspaces.api.permissions import IsWorkspaceOwner, IsWorkspaceAdmin
from apps.workspaces.api.serializers import WorkspaceMembershipSerializer, WorkspaceSerializer
from apps.workspaces.api.filters import WorkspaceFilter, WorkspaceMembershipFilter
from apps.workspaces.models import Workspace, WorkspaceMembership
from core.paginations.limit_offset import CustomLimitOffsetPagination
class WorkspaceViewSet(ModelViewSet):
serializer_class = WorkspaceSerializer
pagination_class = CustomLimitOffsetPagination
filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter)
filterset_class = WorkspaceFilter
search_fields = ("name", "description", "owner__username", "owner__email")
ordering_fields = ("created_at", "updated_at", "name")
ordering = ("-updated_at", "-created_at")
def get_queryset(self):
user = self.request.user
if not user.is_authenticated:
return Workspace.objects.none()
return Workspace.objects.filter(
Q(owner=user) |
Q(memberships__user=user, memberships__is_active=True)
).distinct()
def get_permissions(self):
if self.action in ["update", "partial_update"]:
return [IsAuthenticated(), IsWorkspaceAdmin()]
elif self.action == "destroy":
return [IsAuthenticated(), IsWorkspaceOwner()]
return [IsAuthenticated()]
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
class WorkspaceMembershipViewSet(ModelViewSet):
serializer_class = WorkspaceMembershipSerializer
pagination_class = CustomLimitOffsetPagination
filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter)
filterset_class = WorkspaceMembershipFilter
search_fields = (
"user__mobile",
"user__email",
"user__first_name",
"user__last_name",
"workspace__name"
)
ordering_fields = ("joined_at", "created_at", "role")
ordering = ("-created_at",)
def get_queryset(self):
user = self.request.user
if not user.is_authenticated:
return WorkspaceMembership.objects.none()
return WorkspaceMembership.objects.filter(
Q(workspace__owner=user) |
Q(workspace__memberships__user=user, workspace__memberships__is_active=True)
).distinct()
def get_permissions(self):
if self.action in ["update", "partial_update"]:
return [IsAuthenticated(), IsWorkspaceAdmin()]
if self.action in ["destroy"]:
return [IsAuthenticated(), IsWorkspaceOwner()]
return [IsAuthenticated()]
def create(self, request, *args, **kwargs):
"""
Overridden to check permissions manually.
Because the membership object doesn't exist yet, standard DRF object-level
permissions won't catch payload-level workspace violations.
"""
workspace_id = request.data.get("workspace")
if not workspace_id:
return Response(
{"workspace": ["This field is required."]},
status=status.HTTP_400_BAD_REQUEST
)
workspace = get_object_or_404(Workspace, id=workspace_id)
permission = IsWorkspaceAdmin()
if not permission.has_object_permission(request, self, workspace):
return Response(
{"detail": "You must be a Workspace Admin or Owner to add members."},
status=status.HTTP_403_FORBIDDEN
)
return super().create(request, *args, **kwargs)

10
apps/workspaces/apps.py Normal file
View File

@@ -0,0 +1,10 @@
from django.apps import AppConfig
class WorkspacesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.workspaces"
verbose_name = "Workspaces"
def ready(self) -> None:
from apps.workspaces import signals

View File

77
apps/workspaces/models.py Normal file
View File

@@ -0,0 +1,77 @@
from django.contrib.auth import get_user_model
from django.db import models
from core.models.base import BaseModel
User = get_user_model()
class Workspace(BaseModel):
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
owner = models.ForeignKey(
User,
on_delete=models.PROTECT,
related_name="owned_workspaces",
)
class Meta:
db_table = "workspace"
ordering = ("-updated_at", "-created_at")
indexes = [
models.Index(fields=["owner"], name="workspace_owner_idx"),
]
def __str__(self):
return self.name
@property
def members(self):
return User.objects.filter(
workspace_memberships__workspace=self,
workspace_memberships__is_active=True,
)
class WorkspaceMembership(BaseModel):
class Role(models.TextChoices):
OWNER = "owner", "Owner"
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
GUEST = "guest", "Guest"
workspace = models.ForeignKey(
Workspace,
on_delete=models.CASCADE,
related_name="memberships",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="workspace_memberships",
)
role = models.CharField(
max_length=20,
choices=Role.choices,
default=Role.MEMBER,
)
is_active = models.BooleanField(default=True)
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "workspace_membership"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["workspace"], name="membership_workspace_idx"),
models.Index(fields=["user"], name="membership_user_idx"),
]
constraints = [
models.UniqueConstraint(
fields=["workspace", "user"],
name="unique_workspace_membership",
condition=models.Q(is_deleted=False),
)
]
def __str__(self):
return f"{self.user} @ {self.workspace}"

View File

@@ -0,0 +1,14 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from apps.workspaces.models import Workspace, WorkspaceMembership
@receiver(post_save, sender=Workspace)
def create_owner_membership(sender, instance, created, **kwargs):
if created:
WorkspaceMembership.objects.create(
workspace=instance,
user=instance.owner,
role=WorkspaceMembership.Role.OWNER,
)