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:
@@ -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
|
||||
|
||||
28
accounts/migrations/0009_loginevent.py
Normal file
28
accounts/migrations/0009_loginevent.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
55
accounts/tests_login_events.py
Normal file
55
accounts/tests_login_events.py
Normal 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'})
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user