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

@@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied
from django.shortcuts import render
from django.utils import timezone
from .models import OTPCode, Profile, Session, User
from .models import LoginEvent, OTPCode, Profile, Session, User
from .services import SMSDeliveryError, get_inactive_users, send_sms
@@ -284,3 +284,21 @@ admin.site.unregister(get_user_model())
admin.site.register(get_user_model(), StaffUserAdmin)
admin.site.unregister(Group)
admin.site.register(Group, StaffGroupAdmin)
@admin.register(LoginEvent)
class LoginEventAdmin(admin.ModelAdmin):
"""سابقه‌ی ورود/خروج؛ فقط‌خواندنی (سابقه نباید از پنل عوض شود)."""
list_display = ('created_at', 'event', 'user_id', 'phone', 'ip')
list_filter = ('event', 'created_at')
search_fields = ('phone', 'user_id', 'ip')
date_hierarchy = 'created_at'
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.17 on 2026-10-08 07:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_remove_guest_login'),
]
operations = [
migrations.CreateModel(
name='LoginEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event', models.CharField(choices=[('login_ok', 'ورود موفق'), ('login_failed', 'ورود ناموفق'), ('login_blocked', 'ورود مسدود'), ('logout', 'خروج'), ('account_deleted', 'حذف حساب')], db_index=True, max_length=20)),
('user_id', models.IntegerField(blank=True, db_index=True, null=True)),
('phone', models.CharField(blank=True, db_index=True, max_length=15)),
('ip', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=300)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -84,3 +84,36 @@ class OTPCode(models.Model):
def __str__(self):
return f"otp-{self.phone}"
class LoginEvent(models.Model):
"""
سابقه‌ی ماندگارِ ورود و خروج، برای پرسش‌هایی مثلِ «این کاربر کی آمد، کی رفت».
چرا جدول و نه فقط لاگ: لاگِ stdout با ری‌استارت یا چرخشِ journald از بین می‌رود؛ این ردیف‌ها
در دیتابیس و بکاپ می‌مانند. `user_id` ارجاعِ سبک است (نه FK، مثلِ بقیه‌ی پروژه)، و ردیف با حذفِ
حساب نمی‌ماند مگر در KEPT_ON_ACCOUNT_DELETE. شماره‌ی کامل برای ادمین‌ها ذخیره می‌شود
(تصمیمِ مالک، ۲۰۲۶-۱۰-۰۸)؛ در سیاستِ حریمِ خصوصی باید بیاید.
"""
LOGIN_OK = 'login_ok'
LOGIN_FAILED = 'login_failed' # کدِ غلط یا منقضی
LOGIN_BLOCKED = 'login_blocked' # سقفِ تلاشِ شماره (429)
LOGOUT = 'logout'
ACCOUNT_DELETED = 'account_deleted'
EVENT_CHOICES = [
(LOGIN_OK, 'ورود موفق'), (LOGIN_FAILED, 'ورود ناموفق'), (LOGIN_BLOCKED, 'ورود مسدود'),
(LOGOUT, 'خروج'), (ACCOUNT_DELETED, 'حذف حساب'),
]
event = models.CharField(max_length=20, choices=EVENT_CHOICES, db_index=True)
user_id = models.IntegerField(null=True, blank=True, db_index=True)
phone = models.CharField(max_length=15, blank=True, db_index=True)
ip = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.CharField(max_length=300, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.event} user={self.user_id} {self.created_at:%Y-%m-%d %H:%M}"

View File

@@ -0,0 +1,55 @@
"""
LoginEvent: سابقه‌ی ماندگارِ ورود/خروج. آزموده: ورودِ موفق، کدِ غلط، مسدودشدن، خروج، ماندنِ
ردیف بعد از حذفِ حساب. آزموده نشده: حجمِ بالا و نوشتن هم‌زمان.
"""
from django.core.cache import cache
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import LoginEvent, OTPCode, User
class LoginEventTests(TestCase):
def setUp(self):
cache.clear()
self.phone = '09121116001'
self.api = APIClient()
def otp(self, code='482913'):
OTPCode.objects.create(phone=self.phone, code=code,
expires_at=timezone.now() + timezone.timedelta(minutes=5))
def login(self, code):
return self.api.post('/api/auth/login', {'phone': self.phone, 'otp': code}, format='json',
HTTP_USER_AGENT='test-agent', REMOTE_ADDR='203.0.113.7')
def test_success_and_failure_are_recorded(self):
self.assertEqual(self.login('000000').status_code, 400)
self.otp()
self.assertEqual(self.login('482913').status_code, 200)
events = list(LoginEvent.objects.order_by('id').values_list('event', 'phone', 'ip', 'user_agent'))
self.assertEqual(events, [
('login_failed', self.phone, '203.0.113.7', 'test-agent'),
('login_ok', self.phone, '203.0.113.7', 'test-agent'),
])
self.assertEqual(LoginEvent.objects.get(event='login_ok').user_id,
User.objects.get(phone=self.phone).id)
def test_logout_is_recorded(self):
user = User.objects.create(phone=self.phone)
self.api.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
self.assertEqual(self.api.post('/api/auth/logout', {}, format='json').status_code, 200)
self.assertTrue(LoginEvent.objects.filter(event='logout', user_id=user.id).exists())
def test_events_survive_account_deletion(self):
user = User.objects.create(phone=self.phone)
LoginEvent.objects.create(event='login_ok', user_id=user.id, phone=self.phone)
self.api.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
self.otp()
r = self.api.post('/api/user/delete', {'otp': '482913'}, format='json')
self.assertEqual(r.status_code, 200, r.content)
self.assertFalse(User.objects.filter(pk=user.pk).exists())
self.assertEqual(set(LoginEvent.objects.values_list('event', flat=True)),
{'login_ok', 'account_deleted'})

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)