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

44
.env.sample Normal file
View File

@@ -0,0 +1,44 @@
# Environment
ENVIRONMENT=development
DEBUG=True
# Django Core
DJANGO_SETTINGS_MODULE=config.settings
DJANGO_SECRET_KEY=
DJANGO_ALLOWED_HOSTS=
# Database
POSTGRES_DB=app_db
POSTGRES_USER=app_user
POSTGRES_PASSWORD=app_password
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
# CORS / CSRF
CORS_ALLOWED_ORIGINS=https://app.example.com
CSRF_TRUSTED_ORIGINS=https://app.example.com
# JWT
ACCESS_TOKEN_LIFETIME=5
JWT_SECRET_KEY=
JWT_SIGNING_KEY=
JWT_ACCESS_TOKEN_LIFETIME_MINUTES=5
JWT_REFRESH_TOKEN_LIFETIME_DAYS=7
JWT_ROTATE_REFRESH_TOKENS=True
JWT_BLACKLIST_AFTER_ROTATION=True
JWT_ALGORITHM=HS256
# Redis / Celery
REDIS_URL=redis://redis:6379/0
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
CELERY_BROKER_URL=
CELERY_RESULT_BACKEND=
# Timzone / Language
LANGUAGE_CODE=en-us
TIME_ZONE=Asia/Tehran
SMS_APIKEY=
BASE_URL=

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Python
__pycache__/
*.py[cod]
*.pyo
# Code formatter
.ruff_cache/
# Environment
*.env
.env
*.venv
.venv
# Django
db.sqlite3
media/
staticfiles/
# Migrations (except __init__.py)
**/migrations/*.py
**/migrations/*.pyc
!**/migrations/__init__.py
# Logs
*.log
logs/
# IDE / Editor
.vscode/
.idea/
*.swp
# test coverage report
.coverage
htmlcov/

25
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,25 @@
default_language_version:
python: python3
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: check-case-conflict
- id: check-merge-conflict
- id: check-json
- id: check-toml
- id: check-yaml
- id: detect-private-key
- id: debug-statements
- id: end-of-file-fixer
- id: mixed-line-ending
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.14
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format

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,
)

21
clear_migrations.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
set -e
# Find all directories that contain a migrations folder (Django apps)
while IFS= read -r -d '' dir; do
app_dir="${dir%/migrations}"
app_name="$(basename "$app_dir")"
echo "clean migrations $app_name ..."
find "$dir" -maxdepth 1 -type f -name "*.py" ! -name "__init__.py" -delete
find "$dir" -maxdepth 1 -type f -name "*.pyc" -delete
find "$dir" -maxdepth 1 -type f -name "*.pyc" -delete
find "$dir" -maxdepth 1 -type f -name "*.py~" -delete
find "$dir" -maxdepth 1 -type f -name "*.pyo" -delete
find "$dir" -maxdepth 1 -type f -name "*.swp" -delete
done < <(find . -type d -name migrations -print0)
echo "clear all."
#run
# ./clear_migrations.sh

3
config/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .services.celery import app as celery_app
__all__ = ["celery_app"]

16
config/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()

View File

@@ -0,0 +1 @@
"""Service configuration modules."""

View File

@@ -0,0 +1 @@
AUDITLOG_INCLUDE_ALL_MODELS = True

View File

@@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("qlockify")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

244
config/services/logging.py Normal file
View File

@@ -0,0 +1,244 @@
import logging
import logging.config
import time
from logging.handlers import RotatingFileHandler
from pathlib import Path
from pythonjsonlogger import jsonlogger
BASE_DIR = Path(__file__).resolve().parents[2]
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(exist_ok=True)
# Custom formatter for console output (ERROR logs)
class CustomConsoleFormatter(logging.Formatter):
def format(self, record):
if isinstance(record.msg, dict):
msg_dict = record.msg
return (
f"{record.levelname} {self.formatTime(record, self.datefmt)} "
f"{msg_dict.get('request_method', '')} "
f"{msg_dict.get('request_url', '')} "
f"{msg_dict.get('status_code', '')} "
f"{msg_dict.get('remote_addr', '')} "
f"{msg_dict.get('duration_ms', 0)}ms "
f"{msg_dict.get('message', record.getMessage())}"
)
return super().format(record)
# Custom formatter for INFO console output (simpler format)
class CustomConsoleInfoFormatter(logging.Formatter):
def format(self, record):
if isinstance(record.msg, dict):
msg_dict = record.msg
return (
f"{record.levelname} {self.formatTime(record, self.datefmt)} "
f"{msg_dict.get('request_method', '')} "
f"{msg_dict.get('request_url', '')} "
f"{msg_dict.get('status_code', '')} "
f"{msg_dict.get('remote_addr', '')} "
f"{msg_dict.get('duration_ms', 0)}ms"
)
return super().format(record)
LOGGING = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {name} {module} {funcName} {lineno} {message}",
"style": "{",
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(levelname)s %(asctime)s %(name)s %(module)s %(funcName)s %(lineno)d %(message)s %(pathname)s",
},
"request_formatter": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(levelname)s %(asctime)s %(name)s %(message)s %(pathname)s %(lineno)d %(request_method)s %(request_url)s %(status_code)d %(remote_addr)s %(user_agent)s %(duration_ms)d", # noqa: E501
},
# Custom formatters for console
"error_console": {
"()": CustomConsoleFormatter,
"format": "%(levelname)s %(asctime)s %(name)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"info_console": {
"()": CustomConsoleInfoFormatter,
"format": "%(levelname)s %(asctime)s %(name)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
"console": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "verbose",
},
"error_console": {
"level": "ERROR",
"class": "logging.StreamHandler",
"formatter": "error_console",
},
"info_console": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "info_console",
},
"file": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "django.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "json",
},
"error_file": {
"level": "ERROR",
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "errors.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 5,
"formatter": "json",
},
"debug_file": {
"level": "DEBUG",
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "debug.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 5,
"formatter": "verbose",
},
"request_info_file": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "info.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "request_formatter",
},
"request_error_file": {
"level": "ERROR",
"class": "logging.handlers.RotatingFileHandler",
"filename": str(LOG_DIR / "errors.log"),
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "request_formatter",
},
},
"loggers": {
"django.request": {
"handlers": ["error_console", "error_file"],
"level": "ERROR",
"propagate": False,
},
"django.server": {
"handlers": ["error_console"],
"level": "ERROR",
"propagate": False,
},
"myapp": {
"handlers": ["console", "file", "debug_file", "error_file"],
"level": "DEBUG",
"propagate": False,
},
"project.requests.info": {
"handlers": [
"request_info_file",
"info_console",
], # Use info_console for INFO
"level": "INFO",
"propagate": False,
},
"project.requests.error": {
"handlers": [
"request_error_file",
"error_console",
], # Use error_console for ERROR
"level": "ERROR",
"propagate": False,
},
"common.exceptions": {
"handlers": ["error_console", "error_file", "debug_file"],
"level": "ERROR",
"propagate": False,
},
"common.middleware": {
"handlers": ["error_console", "error_file", "debug_file"],
"level": "ERROR",
"propagate": False,
},
},
}
class RequestLoggingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.info_logger = logging.getLogger("project.requests.info")
self.error_logger = logging.getLogger("project.requests.error")
def __call__(self, request):
start = time.perf_counter()
try:
response = self.get_response(request)
except Exception:
duration_ms = int((time.perf_counter() - start) * 1000)
message = f"HTTP {request.method} request to {request.get_full_path()}"
log_data = {
"request_method": request.method,
"request_url": request.get_full_path(),
"status_code": 500,
"remote_addr": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
"duration_ms": duration_ms,
"message": message,
}
self.error_logger.exception(log_data)
raise
duration_ms = int((time.perf_counter() - start) * 1000)
message = f"HTTP {request.method} request to {request.get_full_path()}"
log_data = {
"request_method": request.method,
"request_url": request.get_full_path(),
"status_code": response.status_code,
"remote_addr": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
"duration_ms": duration_ms,
"message": message,
}
if response.status_code == 404:
log_data["message"] = f"Not Found: {request.get_full_path()}"
elif response.status_code == 401:
log_data["message"] = f"Unauthorized: {request.get_full_path()}"
if response.status_code >= 400:
self.error_logger.error(log_data)
else:
self.info_logger.info(log_data)
return response
def get_custom_logger(name):
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
custom_handler = RotatingFileHandler(
filename=str(LOG_DIR / f"{name}.log"),
maxBytes=1024 * 1024 * 15, # 15MB
backupCount=10,
)
custom_handler.setFormatter(jsonlogger.JsonFormatter())
if not logger.handlers:
logger.addHandler(custom_handler)
return logger
logging.config.dictConfig(LOGGING)

View File

@@ -0,0 +1,15 @@
import os
from urllib.parse import urljoin
from django.conf import settings
from django.core.files.storage import FileSystemStorage
class UploadStorage(FileSystemStorage):
"""
Storage for uploaded files (images, etc.)
Saves files under MEDIA_ROOT/uploads/
"""
location = os.path.join(settings.MEDIA_ROOT, "uploads")
base_url = urljoin(settings.MEDIA_URL, "uploads/")

46
config/services/unfold.py Normal file
View File

@@ -0,0 +1,46 @@
from django.conf import settings
UNFOLD = {
"SITE_TITLE": "Qlockify Admin",
"SITE_HEADER": "Qlockify Admin Panel",
"SITE_BRANDING": "Qlockify",
"SITE_URL": "/api/docs/",
"SITE_SYMBOL": "speed",
"SHOW_HISTORY": True,
"SHOW_VIEW_ON_SITE": True,
"ENVIRONMENT": "config.services.unfold.environment_callback",
"COLORS": {
"primary": {
"50": "#f5f7ff",
"100": "#e8edff",
"200": "#c7d2ff",
"300": "#a3b4ff",
"400": "#7a8dff",
"500": "#4f6bff",
"600": "#3f55d6",
"700": "#3243ab",
"800": "#263281",
"900": "#1b245b",
},
"gray": {
"50": "#f8fafc",
"100": "#f1f5f9",
"200": "#e2e8f0",
"300": "#cbd5e1",
"400": "#94a3b8",
"500": "#64748b",
"600": "#475569",
"700": "#334155",
"800": "#1f2937",
"900": "#0f172a",
},
},
"SIDEBAR": {
"show_search": True,
"show_all_applications": True,
},
}
def environment_callback(request):
return ["Development", "warning"] if settings.DEBUG else ["Production", "success"]

View File

@@ -0,0 +1,17 @@
import contextlib
import os
from .base import *
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
if ENVIRONMENT == "production":
from .production import *
else:
from .development import *
from config.services.auditlog import *
from config.services.logging import *
from config.services.unfold import *
with contextlib.suppress(ImportError):
from .local import *

215
config/settings/base.py Normal file
View File

@@ -0,0 +1,215 @@
import os
from datetime import timedelta
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")
DJANGO_APPS = [
"unfold",
"unfold.contrib.filters",
"unfold.contrib.forms",
"unfold.contrib.import_export",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
THIRD_PARTY_APPS = [
"rest_framework",
"rest_framework_simplejwt",
"rest_framework.authtoken",
"rest_framework_simplejwt.token_blacklist",
"drf_spectacular",
"drf_spectacular_sidecar",
"django_filters",
"import_export",
"corsheaders",
"auditlog",
]
LOCAL_APPS = [
"apps.users",
"apps.workspaces",
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"core.middlewares.current_user.CurrentUserMiddleware",
"core.middlewares.exception_logging.ExceptionLoggingMiddleware",
"config.services.logging.RequestLoggingMiddleware",
"auditlog.middleware.AuditlogMiddleware",
]
ROOT_URLCONF = "config.urls"
AUTH_USER_MODEL = "users.User"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
ASGI_APPLICATION = "config.asgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("POSTGRES_DB"),
"USER": os.getenv("POSTGRES_USER"),
"PASSWORD": os.getenv("POSTGRES_PASSWORD"),
"HOST": os.getenv("POSTGRES_HOST"),
"PORT": os.getenv("POSTGRES_PORT"),
"CONN_MAX_AGE": 0,
"DISABLE_SERVER_SIDE_CURSORS": True,
}
}
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.AllowAny"],
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DATETIME_FORMAT": "%Y-%m-%d %H:%M",
"DATE_FORMAT": "%Y-%m-%d",
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
}
LANGUAGE_CODE = os.getenv("LANGUAGE_CODE", "en-us")
TIME_ZONE = os.getenv("TIME_ZONE", "Asia/Tehran")
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "static"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
SPECTACULAR_SETTINGS = {
"TITLE": "Qlockify.ir API Documentation",
"DESCRIPTION": "API documentation for Qlockify.ir",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": True,
"SWAGGER_UI_SETTINGS": {
"deepLinking": True,
},
"COMPONENT_SPLIT_REQUEST": True,
"ENUM_NAME_OVERRIDES": {},
"TAG_SORTING": "alpha",
"SWAGGER_UI_DIST": "SIDECAR",
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
}
JWT_ACCESS_TOKEN_LIFETIME = int(os.getenv("JWT_ACCESS_TOKEN_LIFETIME_MINUTES", 60))
JWT_REFRESH_TOKEN_LIFETIME = int(os.getenv("JWT_REFRESH_TOKEN_LIFETIME_DAYS", 7))
JWT_ROTATE_REFRESH_TOKENS = os.getenv("JWT_ROTATE_REFRESH_TOKENS", "True") == "True"
JWT_BLACKLIST_AFTER_ROTATION = os.getenv("JWT_BLACKLIST_AFTER_ROTATION", "True") == "True"
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=JWT_ACCESS_TOKEN_LIFETIME),
"REFRESH_TOKEN_LIFETIME": timedelta(days=JWT_REFRESH_TOKEN_LIFETIME),
"ROTATE_REFRESH_TOKENS": JWT_ROTATE_REFRESH_TOKENS,
"BLACKLIST_AFTER_ROTATION": JWT_BLACKLIST_AFTER_ROTATION,
"ALGORITHM": JWT_ALGORITHM,
"SIGNING_KEY": SECRET_KEY,
"AUTH_HEADER_TYPES": ("Bearer",),
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
}
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = os.getenv("REDIS_PORT", "6379")
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
if REDIS_PASSWORD:
REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/0"
else:
REDIS_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}/0"
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/1")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/1")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TASK_ALWAYS_EAGER = False
CELERY_IMPORTS = ("apps.users.tasks",)
CELERY_TIMEZONE = os.getenv("TIME_ZONE")
CELERY_TASK_TRACK_STARTED = True
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"images": {"BACKEND": "config.services.storage.UploadStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
SMS_APIKEY = os.getenv("SMS_APIKEY", "")
BASE_URL = os.getenv("BASE_URL", "")

View File

@@ -0,0 +1,23 @@
import os
from django.conf import settings
DEBUG = True
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
CSRF_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SAMESITE = "Lax"
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
CSRF_TRUSTED_ORIGINS = os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1").split(",")
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOWED_ORIGINS = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(",")
# Django Debug Toolbar
INSTALLED_APPS = settings.INSTALLED_APPS + ["debug_toolbar"]
MIDDLEWARE = settings.MIDDLEWARE + ["debug_toolbar.middleware.DebugToolbarMiddleware"]
INTERNAL_IPS = ["127.0.0.1"]

8
config/settings/local.py Normal file
View File

@@ -0,0 +1,8 @@
"""
Local developer overrides.
Copy this file and set only the settings you want to override locally.
"""
# Example:
# DEBUG = True

View File

@@ -0,0 +1,16 @@
import os
DEBUG = False
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "None"
SESSION_COOKIE_SAMESITE = "None"
ALLOWED_HOSTS = [x for x in os.getenv("DJANGO_ALLOWED_HOSTS", "").split(",") if x]
CSRF_TRUSTED_ORIGINS = [x for x in os.getenv("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if x]
CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOWED_ORIGINS = [x for x in os.getenv("DJANGO_CORS_ALLOWED_ORIGINS", "").split(",") if x]

27
config/urls.py Normal file
View File

@@ -0,0 +1,27 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
urlpatterns = [
path("admin/", admin.site.urls),
# API Documentations
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
# Apps
path("api/users/", include("apps.users.api.urls"), name="users"),
path('api/', include('apps.workspaces.api.urls')),
]
if settings.DEBUG:
import debug_toolbar
urlpatterns += [path("__debug__/", include(debug_toolbar.urls))]
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

16
config/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()

0
core/__init__.py Normal file
View File

65
core/admins/base.py Normal file
View File

@@ -0,0 +1,65 @@
from auditlog.mixins import AuditlogHistoryAdminMixin
from django.contrib import admin, messages
from django.db import transaction
from django.db.models.deletion import ProtectedError
from import_export.admin import ImportExportModelAdmin
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from core.admins.utils import SoftDeleteListFilter
class BaseAdmin(AuditlogHistoryAdminMixin, ImportExportModelAdmin, UnfoldModelAdmin):
show_auditlog_history_link = True
actions = ["hard_delete_selected", "restore_selected"]
list_filter = (SoftDeleteListFilter,)
def get_queryset(self, request):
return self.model.all_objects.all()
@admin.action(description="Hard delete selected (permanent)")
def hard_delete_selected(self, request, queryset):
count = queryset.count()
try:
with transaction.atomic():
queryset.hard_delete()
self.message_user(
request,
f"{count} record(s) permanently deleted.",
level=messages.SUCCESS,
)
except ProtectedError:
self.message_user(
request,
"Cannot hard delete because related protected objects exist.",
level=messages.ERROR,
)
except Exception as e:
self.message_user(request, str(e), level=messages.ERROR)
@admin.action(description="Restore selected (undo soft delete)")
def restore_selected(self, request, queryset):
restored = 0
for obj in queryset:
if getattr(obj, "is_deleted", False):
obj.restore()
restored += 1
self.message_user(
request,
f"{restored} record(s) restored.",
level=messages.SUCCESS,
)
def get_actions(self, request):
actions = super().get_actions(request)
if not request.user.is_superuser:
actions.pop("hard_delete_selected", None)
is_deleted_filter = request.GET.get("is_deleted")
should_show_restore_actions = is_deleted_filter == "1"
if not should_show_restore_actions:
actions.pop("restore_selected", None)
actions.pop("hard_delete_selected", None)
return actions

21
core/admins/utils.py Normal file
View File

@@ -0,0 +1,21 @@
from django.contrib import admin
class SoftDeleteListFilter(admin.SimpleListFilter):
title = "Soft Delete Status"
parameter_name = "is_deleted"
def lookups(self, request, model_admin):
return [
("0", "Active"),
("1", "Deleted"),
]
def queryset(self, request, queryset):
if self.value() == "0":
return queryset.filter(is_deleted=False)
if self.value() == "1":
return queryset.model.deleted_objects.all()
return queryset

9
core/api/mixins.py Normal file
View File

@@ -0,0 +1,9 @@
class WorkspaceQuerysetMixin:
workspace_lookup_url_kwarg = "workspace_id"
def get_workspace(self):
return self.kwargs[self.workspace_lookup_url_kwarg]
def get_queryset(self):
queryset = super().get_queryset()
return queryset.filter(workspace_id=self.get_workspace())

View File

@@ -0,0 +1,95 @@
import logging
import traceback
from collections.abc import Iterable
from typing import Any
from django.conf import settings
from rest_framework import status as http_status
from rest_framework.exceptions import ErrorDetail
from rest_framework.response import Response
from rest_framework.views import exception_handler as drf_exception_handler
logger = logging.getLogger(__name__)
def _flatten_messages(values: Iterable) -> list[str]:
items: list[str] = []
for value in values:
items.extend(_to_str_list(value))
return items
def _to_str_list(value: str | ErrorDetail | list | tuple | dict) -> list[str]:
if isinstance(value, str | ErrorDetail):
return [str(value)]
if isinstance(value, list | tuple):
return _flatten_messages(value)
if isinstance(value, dict):
items: list[str] = []
for field, v in value.items():
msgs = _to_str_list(v)
for msg in msgs:
if field in ("non_field_errors", "__all__"):
items.append(str(msg))
else:
items.append(f"{field}: {msg}")
return items
return [str(value)]
def _format_payload(messages: list[str], status_code: int) -> dict[str, Any]:
clean_messages: list[str] = []
for msg in messages:
msg = msg.replace("error:", "").strip()
if ":" in msg:
_, only_msg = msg.split(":", 1)
clean_messages.append(only_msg.strip())
else:
clean_messages.append(msg)
error_message = messages[0] if messages else http_status.HTTP_STATUS_CODES.get(status_code, "Error")
return {
"error": error_message,
"status_code": status_code,
"messages": [{"message": msg} for msg in clean_messages],
}
def _request_extra(context: dict[str, Any]) -> dict[str, Any]:
request = context.get("request")
meta = getattr(request, "META", {})
return {
"request_method": getattr(request, "method", None),
"request_url": getattr(request, "get_full_path", lambda: None)(),
"remote_addr": meta.get("REMOTE_ADDR") if meta else None,
"user_agent": meta.get("HTTP_USER_AGENT", "") if meta else "",
}
def exception_handler(exc, context) -> Response:
response = drf_exception_handler(exc, context)
is_server_error = response is None or getattr(response, "status_code", 500) >= 500
if is_server_error:
logger.exception("DRF exception", extra=_request_extra(context))
if settings.DEBUG:
is_unhandled = response is None
if is_unhandled or is_server_error:
raise
if response is not None:
status_code = response.status_code
detail = response.data
if status_code < 500:
messages = _to_str_list(detail)
payload = _format_payload(messages, status_code)
return Response(payload, status=status_code)
traceback_text = traceback.format_exc()
payload = _format_payload(
["Internal server error."],
http_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
payload["exception"] = str(exc)
payload["traceback"] = traceback_text
return Response(payload, status=http_status.HTTP_500_INTERNAL_SERVER_ERROR)

7
core/filters/base.py Normal file
View File

@@ -0,0 +1,7 @@
import django_filters as filters
class BaseFilterSet(filters.FilterSet):
created_after = filters.DateTimeFilter(field_name="created_at", lookup_expr="gte")
created_before = filters.DateTimeFilter(field_name="created_at", lookup_expr="lte")
updated_after = filters.DateTimeFilter(field_name="updated_at", lookup_expr="gte")
updated_before = filters.DateTimeFilter(field_name="updated_at", lookup_expr="lte")

View File

@@ -0,0 +1,18 @@
import logging
import threading
logger = logging.getLogger(__name__)
_local = threading.local()
def get_current_user():
return getattr(_local, "user", None)
class CurrentUserMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
_local.user = request.user
return self.get_response(request)

View File

@@ -0,0 +1,23 @@
import logging
logger = logging.getLogger(__name__)
class ExceptionLoggingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
return self.get_response(request)
except Exception:
logger.exception(
"Unhandled exception",
extra={
"request_method": request.method,
"request_url": request.get_full_path(),
"remote_addr": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
},
)
raise

227
core/models/base.py Normal file
View File

@@ -0,0 +1,227 @@
import contextlib
import uuid
from functools import cached_property
from django.conf import settings
from django.db import models
from django.db.models.deletion import ProtectedError
from django.utils import timezone
from core.middlewares.current_user import get_current_user
from core.utils import common_datetime_str
class SoftDeleteQuerySet(models.QuerySet):
def delete(self):
for obj in self:
obj.delete()
return
def hard_delete(self):
return super().delete()
def alive(self):
return self.filter(is_deleted=False)
def dead(self):
return self.filter(is_deleted=True)
class SoftDeleteManager(models.Manager):
def __init__(self, *args, **kwargs):
self.alive_only = kwargs.pop("alive_only", None)
super().__init__(*args, **kwargs)
def get_queryset(self) -> SoftDeleteQuerySet:
if self.alive_only is True:
return SoftDeleteQuerySet(self.model).filter(is_deleted=False)
if self.alive_only is False:
return SoftDeleteQuerySet(self.model).filter(is_deleted=True)
return SoftDeleteQuerySet(self.model)
def hard_delete(self):
return self.get_queryset().hard_delete()
class BaseModel(models.Model):
id = models.UUIDField(default=uuid.uuid7, primary_key=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
deleted_at = models.DateTimeField(null=True, blank=True)
is_deleted = models.BooleanField(default=False)
is_active = models.BooleanField(default=False)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_%(app_label)s_%(class)s_set",
)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_%(app_label)s_%(class)s_set",
)
objects = SoftDeleteManager(alive_only=True)
all_objects = SoftDeleteManager(alive_only=None)
deleted_objects = SoftDeleteManager(alive_only=False)
class Meta:
abstract = True
indexes = (models.Index(fields=["id"], name="%(class)s_id_idx"),)
def save(self, *args, **kwargs):
user = get_current_user()
if user and user.is_authenticated:
if not self.created_by:
self.created_by = user
self.updated_by = user
super().save(*args, **kwargs)
@classmethod
def get_or_restore(cls, defaults=None, **kwargs):
instance = cls.all_objects.filter(**kwargs).first()
if instance:
restored = False
if instance.is_deleted:
instance.restore()
restored = True
if defaults:
for key, value in defaults.items():
setattr(instance, key, value)
instance.save(update_fields=list(defaults.keys()))
return instance, False, restored
instance, created = cls.objects.get_or_create(defaults=defaults, **kwargs)
return instance, created, False
@classmethod
def update_or_restore(cls, defaults=None, **kwargs):
instance = cls.all_objects.filter(**kwargs).first()
if instance:
restored = False
if instance.is_deleted:
instance.restore()
restored = True
if defaults:
for key, value in defaults.items():
setattr(instance, key, value)
instance.save(update_fields=list(defaults.keys()))
return instance, False, restored
instance, created = cls.objects.update_or_create(defaults=defaults, **kwargs)
return instance, created, False
def _soft_delete_related(self, using=None):
for rel in self._meta.related_objects:
if not hasattr(rel, "on_delete"):
continue
on_delete = rel.on_delete
if on_delete not in (models.CASCADE, models.SET_NULL, models.PROTECT):
continue
accessor = rel.get_accessor_name()
try:
related = getattr(self, accessor)
except Exception:
continue
if on_delete is models.PROTECT:
if rel.one_to_one:
try:
_ = related
except rel.related_model.DoesNotExist:
continue
raise ProtectedError(
"Cannot delete because related protected objects exist.",
[related],
)
if related.all().exists():
raise ProtectedError(
"Cannot delete because related protected objects exist.",
list(related.all()),
)
continue
if on_delete is models.SET_NULL:
field_name = rel.field.name
if rel.one_to_one:
try:
obj = related
except rel.related_model.DoesNotExist:
continue
setattr(obj, field_name, None)
obj.save(using=using, update_fields=[field_name])
else:
related.all().update(**{field_name: None})
continue
if rel.one_to_one:
with contextlib.suppress(rel.related_model.DoesNotExist):
related.delete(using=using)
else:
for obj in related.all():
obj.delete(using=using)
def delete(self, using=None, keep_parents=False):
if self.is_deleted:
return
self._soft_delete_related(using=using)
self.is_deleted = True
self.deleted_at = timezone.now()
self.save(using=using, update_fields=["is_deleted", "deleted_at"])
def hard_delete(self, using=None, keep_parents=False):
super().delete(using=using, keep_parents=keep_parents)
def restore(self):
if not self.is_deleted:
return
# Restore related soft-deleted objects for CASCADE relations.
for rel in self._meta.related_objects:
if not hasattr(rel, "on_delete") or rel.on_delete is not models.CASCADE:
continue
accessor = rel.get_accessor_name()
try:
related = getattr(self, accessor)
except Exception:
continue
if rel.one_to_one:
with contextlib.suppress(rel.related_model.DoesNotExist):
related.restore()
else:
for obj in related.all():
obj.restore()
self.is_deleted = False
self.deleted_at = None
self.save(update_fields=["is_deleted", "deleted_at"])
@cached_property
def can_delete(self):
for field in self._meta.related_objects:
try:
if getattr(self, field.related_name).all().exists():
return False
except Exception:
pass
return True
@property
def created_at_display(self):
return common_datetime_str(self.created_at)
@property
def updated_at_display(self):
return common_datetime_str(self.updated_at)

View File

@@ -0,0 +1,8 @@
from rest_framework.pagination import CursorPagination
class StandardCursorPagination(CursorPagination):
page_size = 50
page_size_query_param = "limit"
cursor_query_param = "cursor"
max_page_size = 100

View File

@@ -0,0 +1,56 @@
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.response import Response
def _positive_int(integer_string):
ret = int(integer_string)
if ret < 1:
raise ValueError()
return ret
class CustomLimitOffsetPagination(LimitOffsetPagination):
limit_query_param = "limit"
offset_query_param = "offset"
def paginate_queryset(self, queryset, request, view=None):
self.limit = self.get_limit(request)
if self.limit is None:
return None
self.count = self.get_count(queryset)
self.offset = self.get_offset(request)
self.request = request
if self.count == 0 or self.offset >= self.count:
return []
return list(queryset[self.offset : self.offset + self.limit])
def get_offset(self, request):
try:
return _positive_int(request.query_params[self.offset_query_param])
except (KeyError, ValueError):
return 0
def get_limit(self, request):
if self.limit_query_param:
try:
return _positive_int(request.query_params[self.limit_query_param])
except (KeyError, ValueError):
pass
return self.default_limit
def get_paginated_response(self, data):
pages_count = 0 if self.count == 0 else (self.count + self.limit - 1) // self.limit
current_page = 0 if self.count == 0 else (self.offset // self.limit) + 1
return Response(
{
"pages_count": pages_count,
"items_per_page": self.limit,
"current_page_items_count": len(data),
"current_page": current_page,
"total_items": self.count,
"items": data,
}
)

57
core/serializers/base.py Normal file
View File

@@ -0,0 +1,57 @@
from django.contrib.auth import get_user_model
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.serializers.mini import UserMiniSerializer
User = get_user_model()
class BaseModelSerializer(serializers.ModelSerializer):
"""
Base serializer for all models inheriting from BaseModel.
Returns audit fields with a nested user mini representation.
"""
id = serializers.UUIDField(read_only=True)
created_by = UserMiniSerializer(read_only=True)
updated_by = UserMiniSerializer(read_only=True)
created_at = serializers.SerializerMethodField()
updated_at = serializers.SerializerMethodField()
can_delete = serializers.SerializerMethodField()
class Meta:
model = None
fields = (
"id",
"created_by",
"updated_by",
"created_at",
"updated_at",
"can_delete",
)
read_only_fields = fields
def to_internal_value(self, data):
if isinstance(data, dict):
data = data.copy()
for name, field in self.fields.items():
if (
name in data
and data[name] is None
and isinstance(field, (serializers.CharField, serializers.URLField))
):
data[name] = ""
return super().to_internal_value(data)
@extend_schema_field(serializers.CharField)
def get_created_at(self, obj) -> str:
return obj.created_at_display
@extend_schema_field(serializers.CharField)
def get_updated_at(self, obj) -> str:
return obj.updated_at_display
@extend_schema_field(serializers.BooleanField)
def get_can_delete(self, obj) -> bool:
return bool(getattr(obj, "can_delete", False))

11
core/serializers/mini.py Normal file
View File

@@ -0,0 +1,11 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
User = get_user_model()
class UserMiniSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ("id", "first_name", "last_name", "mobile")
read_only_fields = fields

72
core/utils.py Normal file
View File

@@ -0,0 +1,72 @@
import os
from datetime import datetime
from django.utils import timezone
from django.utils.text import slugify
def common_user_str(user):
if not user:
return ""
return user.full_name if user.full_name else user.mobile
def common_datetime_str(datetime):
if not datetime:
return ""
try:
if timezone.is_aware(datetime):
datetime = timezone.localtime(datetime)
else:
datetime = timezone.make_aware(datetime, timezone.get_current_timezone())
except Exception:
pass
return datetime.strftime("%Y.%m.%d %H:%M")
def common_date_str(datetime):
if not datetime:
return ""
try:
if timezone.is_aware(datetime):
datetime = timezone.localtime(datetime)
else:
datetime = timezone.make_aware(datetime, timezone.get_current_timezone())
except Exception:
pass
return datetime.strftime("%Y.%m.%d")
def file_name_datetime_str():
dt = timezone.now()
return f"{dt.year}-{dt.month}-{dt.day}-{dt.hour}-{dt.minute}-{dt.second}"
def upload_to_by_date(instance, filename):
today = datetime.now()
timestamp = today.strftime("%Y%m%d%H%M%S")
file_extension = os.path.splitext(filename)[1]
new_filename = f"{timestamp}{file_extension}"
return os.path.join(f"storage/{today.year}/", new_filename)
def calculate_age(birth_date):
"""
Helper Function to calculate age from birth date
"""
if not birth_date:
return None
today = timezone.localdate()
age = today.year - birth_date.year - int((today.month, today.day) < (birth_date.month, birth_date.day))
return age
def generate_slug(title, Object, pk):
base_slug = slugify(title, allow_unicode=True)
slug = base_slug
counter = 2
while slug and Object.objects.filter(slug=slug).exclude(pk=pk).exists():
slug = f"{base_slug}-{counter}"
counter += 1
return slug

23
manage.py Normal file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

41
pyproject.toml Normal file
View File

@@ -0,0 +1,41 @@
[tool.ruff]
target-version = "py314"
line-length = 120
extend-exclude = [
".venv",
"static",
"logs",
"migrations",
]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"DJ", # ruff-django
"SIM", # simplify code
"T20", # catch stray print()
]
fixable = ["ALL"]
[tool.ruff.lint.isort]
known-first-party = [
"config",
]
[tool.ruff.lint.per-file-ignores]
"**/migrations/*.py" = ["E501", "F401"]
"**/settings/*.py" = ["E501"]
"manage.py" = ["E402"]
"config/asgi.py" = ["E402"]
"config/settings/*.py" = ["E402", "F403"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"

44
requirements/base.txt Normal file
View File

@@ -0,0 +1,44 @@
# Core framework
Django>=5.2,<5.3
djangorestframework>=3.16
# CORS for React frontend
django-cors-headers>=4.4
# Authentication
djangorestframework-simplejwt>=5.4
# Filtering
django-filter>=24.2
# API documentation
drf-spectacular==0.28.0
drf-spectacular-sidecar==2026.3.1
# Environment variables
python-dotenv==1.2.2
# Admin Panel integeration
django-unfold==0.76.0
django-import-export==4.4.0
# Database
psycopg[binary]>=3.2
# Logging
django-auditlog==3.4.1
python-json-logger==3.3.0
# Utilities
python-dateutil>=2.9
requests
# Image/file handling
Pillow>=10.3
# background tasks
celery==5.4.0
redis==7.1.0
django-redis==5.4.0
django-celery-beat==2.8.0
flower==2.0.1

24
requirements/dev.txt Normal file
View File

@@ -0,0 +1,24 @@
# Development server improvements
django-extensions>=3.2
# Better shell
ipython>=8.25
# Debug toolbar
django-debug-toolbar>=4.4
# Testing
pytest>=8.2
pytest-django>=4.8
factory-boy>=3.3
# Linting & formatting
black>=24.4
ruff>=0.5
# Type checking
mypy>=1.10
django-stubs>=5.0
# Pre-commit hooks
pre-commit>=3.7

11
requirements/prod.txt Normal file
View File

@@ -0,0 +1,11 @@
# WSGI server
gunicorn>=22.0
# Static files serving
whitenoise>=6.7
# Postgres connection pooling
psycopg-pool>=3.2
# Monitoring
sentry-sdk>=2.8