Postgres support (DB_* env), LoginEvent audit table, Choice.value 300, deploy.sh pg_dump backup

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:48:48 +03:30
parent e00f6b80eb
commit 042df25738
11 changed files with 261 additions and 38 deletions

View File

@@ -12,11 +12,11 @@ from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.throttling import BaseThrottle, ScopedRateThrottle
from rest_framework.views import APIView
from .authentication import invalidate_user_cache, issue_access_token
from .models import OTPCode, Profile, Session, User, hash_refresh_token
from .models import LoginEvent, OTPCode, Profile, Session, User, hash_refresh_token
from .services import OTPDeliveryError, send_otp_sms
from .serializers import (
DeleteAccountRequestSerializer,
@@ -38,6 +38,21 @@ from .serializers import (
logger = logging.getLogger(__name__)
def record_login_event(request, event: str, *, user_id=None, phone: str = '') -> None:
"""
ردیفِ LoginEvent می‌نویسد. شکستش ورود/خروج را خراب نمی‌کند (ثبتِ سابقه نباید کاربر را بیرون
بیندازد) ولی با logger.exception دیده می‌شود. زیرِ بارِ هم‌زمان و روی Postgres سنجیده نشده.
"""
try:
LoginEvent.objects.create(
event=event, user_id=user_id, phone=phone,
ip=BaseThrottle().get_ident(request) or None,
user_agent=request.META.get('HTTP_USER_AGENT', '')[:300],
)
except Exception:
logger.exception('LoginEvent نوشته نشد: event=%s user_id=%s', event, user_id)
class RequestOTPView(APIView):
"""
POST /api/auth/otp/request — درخواستِ کدِ ورود.
@@ -273,6 +288,12 @@ class LoginView(APIView):
error = verify_phone_otp(phone, otp)
if error is not None:
record_login_event(
request,
LoginEvent.LOGIN_BLOCKED if error.status_code == status.HTTP_429_TOO_MANY_REQUESTS
else LoginEvent.LOGIN_FAILED,
phone=phone,
)
return error
with transaction.atomic():
@@ -293,6 +314,7 @@ class LoginView(APIView):
refresh_token=hash_refresh_token(refresh_token),
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
record_login_event(request, LoginEvent.LOGIN_OK, user_id=user.id, phone=phone)
access_token = issue_access_token(user.id)
data = {
@@ -316,7 +338,7 @@ def _count_failed_attempt(phone: str) -> None:
# Redemption و UserOffer هم می‌مانند: سقفِ مصرفِ کدِ تخفیف ردیف‌های Redemption را می‌شمارد، پس پاک
# شدنشان یک کدِ یک‌بارمصرف را با «بساز، بخر، حذف کن» دوباره آزاد می‌کرد.
KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion',
'discount.Redemption', 'discount.UserOffer')
'discount.Redemption', 'discount.UserOffer', 'accounts.LoginEvent')
class DeleteAccountView(APIView):
@@ -356,6 +378,7 @@ class DeleteAccountView(APIView):
return error
logger.info('Account deleted by its owner: user_id=%s', user.id)
record_login_event(request, LoginEvent.ACCOUNT_DELETED, user_id=user.id, phone=user.phone or '')
# کدهای پیامکیِ شماره می‌مانند: سقفِ روزانه‌ی پیامکِ هر شماره از همین ردیف‌ها شمرده می‌شود.
delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE, delete_otp_codes=False)
return Response(DeleteAccountResponseSerializer({'success': True}).data)
@@ -395,6 +418,7 @@ class LogoutView(APIView):
).update(revoked=True)
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
invalidate_user_cache(request.user.id)
record_login_event(request, LoginEvent.LOGOUT, user_id=request.user.id, phone=request.user.phone or '')
return Response(LogoutResponseSerializer({'success': True}).data)