From 5d1e1cb7cb558ecede32b4b0be120ad8c75fc357 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Wed, 11 Mar 2026 17:12:28 +0800 Subject: [PATCH] initial commit --- .env.sample | 44 +++++ .gitignore | 36 ++++ .pre-commit-config.yaml | 25 +++ apps/users/__init__.py | 0 apps/users/admin.py | 183 +++++++++++++++++++ apps/users/api/serializers.py | 131 +++++++++++++ apps/users/api/urls.py | 21 +++ apps/users/api/views.py | 237 ++++++++++++++++++++++++ apps/users/apps.py | 7 + apps/users/migrations/__init__.py | 0 apps/users/models.py | 71 +++++++ apps/users/services/auth.py | 174 ++++++++++++++++++ apps/users/services/forms.py | 15 ++ apps/users/services/managers.py | 25 +++ apps/users/tasks.py | 63 +++++++ apps/users/utils.py | 16 ++ apps/workspaces/admin.py | 65 +++++++ apps/workspaces/api/filters.py | 19 ++ apps/workspaces/api/permissions.py | 108 +++++++++++ apps/workspaces/api/serializers.py | 24 +++ apps/workspaces/api/urls.py | 12 ++ apps/workspaces/api/views.py | 104 +++++++++++ apps/workspaces/apps.py | 10 + apps/workspaces/migrations/__init__.py | 0 apps/workspaces/models.py | 77 ++++++++ apps/workspaces/signals.py | 14 ++ clear_migrations.sh | 21 +++ config/__init__.py | 3 + config/asgi.py | 16 ++ config/services/__init__.py | 1 + config/services/auditlog.py | 1 + config/services/celery.py | 9 + config/services/logging.py | 244 +++++++++++++++++++++++++ config/services/storage.py | 15 ++ config/services/unfold.py | 46 +++++ config/settings/__init__.py | 17 ++ config/settings/base.py | 215 ++++++++++++++++++++++ config/settings/development.py | 23 +++ config/settings/local.py | 8 + config/settings/production.py | 16 ++ config/urls.py | 27 +++ config/wsgi.py | 16 ++ core/__init__.py | 0 core/admins/base.py | 65 +++++++ core/admins/utils.py | 21 +++ core/api/mixins.py | 9 + core/exceptions/handlers.py | 95 ++++++++++ core/filters/base.py | 7 + core/middlewares/current_user.py | 18 ++ core/middlewares/exception_logging.py | 23 +++ core/models/base.py | 227 +++++++++++++++++++++++ core/paginations/cursor.py | 8 + core/paginations/limit_offset.py | 56 ++++++ core/serializers/base.py | 57 ++++++ core/serializers/mini.py | 11 ++ core/utils.py | 72 ++++++++ manage.py | 23 +++ pyproject.toml | 41 +++++ requirements/base.txt | 44 +++++ requirements/dev.txt | 24 +++ requirements/prod.txt | 11 ++ 61 files changed, 2971 insertions(+) create mode 100644 .env.sample create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 apps/users/__init__.py create mode 100644 apps/users/admin.py create mode 100644 apps/users/api/serializers.py create mode 100644 apps/users/api/urls.py create mode 100644 apps/users/api/views.py create mode 100644 apps/users/apps.py create mode 100644 apps/users/migrations/__init__.py create mode 100644 apps/users/models.py create mode 100644 apps/users/services/auth.py create mode 100644 apps/users/services/forms.py create mode 100644 apps/users/services/managers.py create mode 100644 apps/users/tasks.py create mode 100644 apps/users/utils.py create mode 100644 apps/workspaces/admin.py create mode 100644 apps/workspaces/api/filters.py create mode 100644 apps/workspaces/api/permissions.py create mode 100644 apps/workspaces/api/serializers.py create mode 100644 apps/workspaces/api/urls.py create mode 100644 apps/workspaces/api/views.py create mode 100644 apps/workspaces/apps.py create mode 100644 apps/workspaces/migrations/__init__.py create mode 100644 apps/workspaces/models.py create mode 100644 apps/workspaces/signals.py create mode 100644 clear_migrations.sh create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/services/__init__.py create mode 100644 config/services/auditlog.py create mode 100644 config/services/celery.py create mode 100644 config/services/logging.py create mode 100644 config/services/storage.py create mode 100644 config/services/unfold.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/base.py create mode 100644 config/settings/development.py create mode 100644 config/settings/local.py create mode 100644 config/settings/production.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 core/__init__.py create mode 100644 core/admins/base.py create mode 100644 core/admins/utils.py create mode 100644 core/api/mixins.py create mode 100644 core/exceptions/handlers.py create mode 100644 core/filters/base.py create mode 100644 core/middlewares/current_user.py create mode 100644 core/middlewares/exception_logging.py create mode 100644 core/models/base.py create mode 100644 core/paginations/cursor.py create mode 100644 core/paginations/limit_offset.py create mode 100644 core/serializers/base.py create mode 100644 core/serializers/mini.py create mode 100644 core/utils.py create mode 100644 manage.py create mode 100644 pyproject.toml create mode 100644 requirements/base.txt create mode 100644 requirements/dev.txt create mode 100644 requirements/prod.txt diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..45fa934 --- /dev/null +++ b/.env.sample @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..797bfd8 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..726b9a9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/apps/users/__init__.py b/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/admin.py b/apps/users/admin.py new file mode 100644 index 0000000..9005ce9 --- /dev/null +++ b/apps/users/admin.py @@ -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) diff --git a/apps/users/api/serializers.py b/apps/users/api/serializers.py new file mode 100644 index 0000000..d35c54d --- /dev/null +++ b/apps/users/api/serializers.py @@ -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() \ No newline at end of file diff --git a/apps/users/api/urls.py b/apps/users/api/urls.py new file mode 100644 index 0000000..705c047 --- /dev/null +++ b/apps/users/api/urls.py @@ -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"), +] diff --git a/apps/users/api/views.py b/apps/users/api/views.py new file mode 100644 index 0000000..bf9348c --- /dev/null +++ b/apps/users/api/views.py @@ -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) diff --git a/apps/users/apps.py b/apps/users/apps.py new file mode 100644 index 0000000..4312557 --- /dev/null +++ b/apps/users/apps.py @@ -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" diff --git a/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/models.py b/apps/users/models.py new file mode 100644 index 0000000..f6da783 --- /dev/null +++ b/apps/users/models.py @@ -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 '❌'})" diff --git a/apps/users/services/auth.py b/apps/users/services/auth.py new file mode 100644 index 0000000..6fde1c5 --- /dev/null +++ b/apps/users/services/auth.py @@ -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": "توکن نامعتبر است یا قبلا منقضی شده است."}) diff --git a/apps/users/services/forms.py b/apps/users/services/forms.py new file mode 100644 index 0000000..60ab88d --- /dev/null +++ b/apps/users/services/forms.py @@ -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") diff --git a/apps/users/services/managers.py b/apps/users/services/managers.py new file mode 100644 index 0000000..edc3176 --- /dev/null +++ b/apps/users/services/managers.py @@ -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) diff --git a/apps/users/tasks.py b/apps/users/tasks.py new file mode 100644 index 0000000..7c99b21 --- /dev/null +++ b/apps/users/tasks.py @@ -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 diff --git a/apps/users/utils.py b/apps/users/utils.py new file mode 100644 index 0000000..e37b3ca --- /dev/null +++ b/apps/users/utils.py @@ -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), + ) diff --git a/apps/workspaces/admin.py b/apps/workspaces/admin.py new file mode 100644 index 0000000..8614d9d --- /dev/null +++ b/apps/workspaces/admin.py @@ -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", + ) diff --git a/apps/workspaces/api/filters.py b/apps/workspaces/api/filters.py new file mode 100644 index 0000000..cef6cfb --- /dev/null +++ b/apps/workspaces/api/filters.py @@ -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"] diff --git a/apps/workspaces/api/permissions.py b/apps/workspaces/api/permissions.py new file mode 100644 index 0000000..c3bcad3 --- /dev/null +++ b/apps/workspaces/api/permissions.py @@ -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() diff --git a/apps/workspaces/api/serializers.py b/apps/workspaces/api/serializers.py new file mode 100644 index 0000000..eda58c4 --- /dev/null +++ b/apps/workspaces/api/serializers.py @@ -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", + ) diff --git a/apps/workspaces/api/urls.py b/apps/workspaces/api/urls.py new file mode 100644 index 0000000..f9dc6eb --- /dev/null +++ b/apps/workspaces/api/urls.py @@ -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)), +] diff --git a/apps/workspaces/api/views.py b/apps/workspaces/api/views.py new file mode 100644 index 0000000..5f64d3d --- /dev/null +++ b/apps/workspaces/api/views.py @@ -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) diff --git a/apps/workspaces/apps.py b/apps/workspaces/apps.py new file mode 100644 index 0000000..8ceb513 --- /dev/null +++ b/apps/workspaces/apps.py @@ -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 diff --git a/apps/workspaces/migrations/__init__.py b/apps/workspaces/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/workspaces/models.py b/apps/workspaces/models.py new file mode 100644 index 0000000..f8b49bf --- /dev/null +++ b/apps/workspaces/models.py @@ -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}" diff --git a/apps/workspaces/signals.py b/apps/workspaces/signals.py new file mode 100644 index 0000000..207c085 --- /dev/null +++ b/apps/workspaces/signals.py @@ -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, + ) diff --git a/clear_migrations.sh b/clear_migrations.sh new file mode 100644 index 0000000..4f9e812 --- /dev/null +++ b/clear_migrations.sh @@ -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 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..365167e --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .services.celery import app as celery_app + +__all__ = ["celery_app"] diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..cd6907c --- /dev/null +++ b/config/asgi.py @@ -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() diff --git a/config/services/__init__.py b/config/services/__init__.py new file mode 100644 index 0000000..b81316f --- /dev/null +++ b/config/services/__init__.py @@ -0,0 +1 @@ +"""Service configuration modules.""" diff --git a/config/services/auditlog.py b/config/services/auditlog.py new file mode 100644 index 0000000..164896e --- /dev/null +++ b/config/services/auditlog.py @@ -0,0 +1 @@ +AUDITLOG_INCLUDE_ALL_MODELS = True diff --git a/config/services/celery.py b/config/services/celery.py new file mode 100644 index 0000000..0fd732b --- /dev/null +++ b/config/services/celery.py @@ -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() diff --git a/config/services/logging.py b/config/services/logging.py new file mode 100644 index 0000000..949373e --- /dev/null +++ b/config/services/logging.py @@ -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) diff --git a/config/services/storage.py b/config/services/storage.py new file mode 100644 index 0000000..5875436 --- /dev/null +++ b/config/services/storage.py @@ -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/") diff --git a/config/services/unfold.py b/config/services/unfold.py new file mode 100644 index 0000000..b13a959 --- /dev/null +++ b/config/services/unfold.py @@ -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"] diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..f02b92b --- /dev/null +++ b/config/settings/__init__.py @@ -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 * diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..9d20926 --- /dev/null +++ b/config/settings/base.py @@ -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", "") diff --git a/config/settings/development.py b/config/settings/development.py new file mode 100644 index 0000000..de2188a --- /dev/null +++ b/config/settings/development.py @@ -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"] diff --git a/config/settings/local.py b/config/settings/local.py new file mode 100644 index 0000000..4299023 --- /dev/null +++ b/config/settings/local.py @@ -0,0 +1,8 @@ +""" +Local developer overrides. + +Copy this file and set only the settings you want to override locally. +""" + +# Example: +# DEBUG = True diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..3731fc5 --- /dev/null +++ b/config/settings/production.py @@ -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] diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..2a21a9c --- /dev/null +++ b/config/urls.py @@ -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) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..b2bb19f --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/admins/base.py b/core/admins/base.py new file mode 100644 index 0000000..289a175 --- /dev/null +++ b/core/admins/base.py @@ -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 diff --git a/core/admins/utils.py b/core/admins/utils.py new file mode 100644 index 0000000..cd22784 --- /dev/null +++ b/core/admins/utils.py @@ -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 diff --git a/core/api/mixins.py b/core/api/mixins.py new file mode 100644 index 0000000..973005e --- /dev/null +++ b/core/api/mixins.py @@ -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()) diff --git a/core/exceptions/handlers.py b/core/exceptions/handlers.py new file mode 100644 index 0000000..976266a --- /dev/null +++ b/core/exceptions/handlers.py @@ -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) diff --git a/core/filters/base.py b/core/filters/base.py new file mode 100644 index 0000000..9692255 --- /dev/null +++ b/core/filters/base.py @@ -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") \ No newline at end of file diff --git a/core/middlewares/current_user.py b/core/middlewares/current_user.py new file mode 100644 index 0000000..480022c --- /dev/null +++ b/core/middlewares/current_user.py @@ -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) diff --git a/core/middlewares/exception_logging.py b/core/middlewares/exception_logging.py new file mode 100644 index 0000000..f6ade2f --- /dev/null +++ b/core/middlewares/exception_logging.py @@ -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 diff --git a/core/models/base.py b/core/models/base.py new file mode 100644 index 0000000..b1f9991 --- /dev/null +++ b/core/models/base.py @@ -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) diff --git a/core/paginations/cursor.py b/core/paginations/cursor.py new file mode 100644 index 0000000..d2d91d2 --- /dev/null +++ b/core/paginations/cursor.py @@ -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 diff --git a/core/paginations/limit_offset.py b/core/paginations/limit_offset.py new file mode 100644 index 0000000..14f45a1 --- /dev/null +++ b/core/paginations/limit_offset.py @@ -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, + } + ) diff --git a/core/serializers/base.py b/core/serializers/base.py new file mode 100644 index 0000000..5a6d217 --- /dev/null +++ b/core/serializers/base.py @@ -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)) diff --git a/core/serializers/mini.py b/core/serializers/mini.py new file mode 100644 index 0000000..dbf99f7 --- /dev/null +++ b/core/serializers/mini.py @@ -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 diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..1ce8ab2 --- /dev/null +++ b/core/utils.py @@ -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 diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..aabb818 --- /dev/null +++ b/manage.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c755433 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..01b3169 --- /dev/null +++ b/requirements/base.txt @@ -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 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..553cc47 --- /dev/null +++ b/requirements/dev.txt @@ -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 diff --git a/requirements/prod.txt b/requirements/prod.txt new file mode 100644 index 0000000..8375636 --- /dev/null +++ b/requirements/prod.txt @@ -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