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