initial commit
This commit is contained in:
44
.env.sample
Normal file
44
.env.sample
Normal 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
36
.gitignore
vendored
Normal 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
25
.pre-commit-config.yaml
Normal 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
0
apps/users/__init__.py
Normal file
183
apps/users/admin.py
Normal file
183
apps/users/admin.py
Normal 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)
|
||||||
131
apps/users/api/serializers.py
Normal file
131
apps/users/api/serializers.py
Normal 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
21
apps/users/api/urls.py
Normal 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
237
apps/users/api/views.py
Normal 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
7
apps/users/apps.py
Normal 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"
|
||||||
0
apps/users/migrations/__init__.py
Normal file
0
apps/users/migrations/__init__.py
Normal file
71
apps/users/models.py
Normal file
71
apps/users/models.py
Normal 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
174
apps/users/services/auth.py
Normal 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": "توکن نامعتبر است یا قبلا منقضی شده است."})
|
||||||
15
apps/users/services/forms.py
Normal file
15
apps/users/services/forms.py
Normal 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")
|
||||||
25
apps/users/services/managers.py
Normal file
25
apps/users/services/managers.py
Normal 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
63
apps/users/tasks.py
Normal 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
16
apps/users/utils.py
Normal 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
65
apps/workspaces/admin.py
Normal 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",
|
||||||
|
)
|
||||||
19
apps/workspaces/api/filters.py
Normal file
19
apps/workspaces/api/filters.py
Normal 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"]
|
||||||
108
apps/workspaces/api/permissions.py
Normal file
108
apps/workspaces/api/permissions.py
Normal 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()
|
||||||
24
apps/workspaces/api/serializers.py
Normal file
24
apps/workspaces/api/serializers.py
Normal 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",
|
||||||
|
)
|
||||||
12
apps/workspaces/api/urls.py
Normal file
12
apps/workspaces/api/urls.py
Normal 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)),
|
||||||
|
]
|
||||||
104
apps/workspaces/api/views.py
Normal file
104
apps/workspaces/api/views.py
Normal 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
10
apps/workspaces/apps.py
Normal 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
|
||||||
0
apps/workspaces/migrations/__init__.py
Normal file
0
apps/workspaces/migrations/__init__.py
Normal file
77
apps/workspaces/models.py
Normal file
77
apps/workspaces/models.py
Normal 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}"
|
||||||
14
apps/workspaces/signals.py
Normal file
14
apps/workspaces/signals.py
Normal 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
21
clear_migrations.sh
Normal 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
3
config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .services.celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ["celery_app"]
|
||||||
16
config/asgi.py
Normal file
16
config/asgi.py
Normal 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()
|
||||||
1
config/services/__init__.py
Normal file
1
config/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Service configuration modules."""
|
||||||
1
config/services/auditlog.py
Normal file
1
config/services/auditlog.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
AUDITLOG_INCLUDE_ALL_MODELS = True
|
||||||
9
config/services/celery.py
Normal file
9
config/services/celery.py
Normal 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
244
config/services/logging.py
Normal 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)
|
||||||
15
config/services/storage.py
Normal file
15
config/services/storage.py
Normal 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
46
config/services/unfold.py
Normal 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"]
|
||||||
17
config/settings/__init__.py
Normal file
17
config/settings/__init__.py
Normal 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
215
config/settings/base.py
Normal 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", "")
|
||||||
23
config/settings/development.py
Normal file
23
config/settings/development.py
Normal 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
8
config/settings/local.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Local developer overrides.
|
||||||
|
|
||||||
|
Copy this file and set only the settings you want to override locally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Example:
|
||||||
|
# DEBUG = True
|
||||||
16
config/settings/production.py
Normal file
16
config/settings/production.py
Normal 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
27
config/urls.py
Normal 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
16
config/wsgi.py
Normal 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
0
core/__init__.py
Normal file
65
core/admins/base.py
Normal file
65
core/admins/base.py
Normal 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
21
core/admins/utils.py
Normal 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
9
core/api/mixins.py
Normal 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())
|
||||||
95
core/exceptions/handlers.py
Normal file
95
core/exceptions/handlers.py
Normal 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
7
core/filters/base.py
Normal 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")
|
||||||
18
core/middlewares/current_user.py
Normal file
18
core/middlewares/current_user.py
Normal 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)
|
||||||
23
core/middlewares/exception_logging.py
Normal file
23
core/middlewares/exception_logging.py
Normal 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
227
core/models/base.py
Normal 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)
|
||||||
8
core/paginations/cursor.py
Normal file
8
core/paginations/cursor.py
Normal 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
|
||||||
56
core/paginations/limit_offset.py
Normal file
56
core/paginations/limit_offset.py
Normal 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
57
core/serializers/base.py
Normal 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
11
core/serializers/mini.py
Normal 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
72
core/utils.py
Normal 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
23
manage.py
Normal 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
41
pyproject.toml
Normal 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
44
requirements/base.txt
Normal 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
24
requirements/dev.txt
Normal 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
11
requirements/prod.txt
Normal 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
|
||||||
Reference in New Issue
Block a user