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

@@ -24,6 +24,13 @@ DJANGO_SECRET_KEY=
# (/api/health هشدار می‌دهد). مثال: redis://127.0.0.1:6379/1
CACHE_URL=
# Postgres: با ست‌بودنِ DB_NAME برنامه روی Postgres بالا می‌آید (DB_USER و DB_PASSWORD الزامی)؛ خالی = SQLite.
DB_NAME=
DB_USER=
DB_PASSWORD=
DB_HOST=127.0.0.1
DB_PORT=5432
# ═══════════════════════════════════════════════════════════════════════
# ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند
# ═══════════════════════════════════════════════════════════════════════

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)

View File

@@ -314,6 +314,28 @@ WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
# Postgres وقتی DB_NAME ست باشد؛ وگرنه SQLite (وضعِ فعلیِ سرور تا روزِ انتقال).
# ست‌بودنِ DB_NAME با ناقص‌بودنِ بقیه (کاربر/رمز) عمداً خطا می‌دهد، نه اتصالِ بی‌رمز.
DB_NAME = os.environ.get('DB_NAME', '')
if DB_NAME:
_missing = [k for k in ('DB_USER', 'DB_PASSWORD') if not os.environ.get(k)]
if _missing:
raise RuntimeError('DB_NAME ست شده ولی %s خالی است.' % ', '.join(_missing))
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': DB_NAME,
'USER': os.environ['DB_USER'],
'PASSWORD': os.environ['DB_PASSWORD'],
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
'PORT': os.environ.get('DB_PORT', '5432'),
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
# قفلِ ردیف روی Postgres واقعی است (select_for_update)؛ transaction_mode
# فقط برای SQLite بود. با تست‌های هم‌زمانی روی Postgres سنجیده می‌شود.
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
@@ -346,6 +368,7 @@ DATABASES = {
}
}
# In production a shared cache is required for rate limiting to work across
# workers/instances; falls back to LocMemCache only when DEBUG=True.
CACHE_URL = os.environ.get('CACHE_URL', '')

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-08 08:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0011_lesson_figures'),
]
operations = [
migrations.AlterField(
model_name='choice',
name='value',
field=models.CharField(max_length=300),
),
]

View File

@@ -129,7 +129,8 @@ class Question(models.Model):
class Choice(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='choices')
value = models.CharField(max_length=100)
# ۳۰۰: یک گزینه‌ی کتاب ۱۲۲ نویسه بود و SQLite طول را اجرا نمی‌کرد؛ Postgres رد می‌کرد.
value = models.CharField(max_length=300)
is_correct = models.BooleanField(default=False)
def __str__(self):

View File

@@ -224,8 +224,7 @@ printf ' دیتابیس و بکاپ‌ها فقط برای %s خواندنی‌
# به‌عنوانِ فرمان روی سرور نیست). اگر بکاپ نشد، migrate اجرا نمی‌شود: دیپلوی بی‌بکاپ را
# عمداً نمی‌پذیریم. فقط ۲ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و یکی قبل‌تر) نگه داشته می‌شود تا دیسکِ
# مشترک پر نشود؛ بکاپ‌های دستی با نامِ دیگر دست نمی‌خورند.
# آزموده‌نشده: Postgres (بعد از مهاجرت باید pg_dump جایگزین شود؛ اینجا فقط هشدار می‌دهد)،
# و بکاپ از بیرونِ ماشین که هنوز نیست (بند ۱۷ در چک‌لیستِ لانچ).
# Postgres با pg_dump (آزموده‌نشده روی سرور تا انتقال)؛ بکاپ از بیرونِ ماشین که هنوز نیست (بند ۱۷ در چک‌لیستِ لانچ).
step "بکاپِ دیتابیس"
python - <<'PYBACKUP' || fail "بکاپِ دیتابیس نشد؛ migrate اجرا نشد و هیچ تغییری روی داده اعمال نشد."
import datetime
@@ -241,8 +240,24 @@ django.setup()
from django.conf import settings
db = settings.DATABASES['default']
if db['ENGINE'] == 'django.db.backends.postgresql':
import subprocess
os.makedirs('.deploy-backups', mode=0o700, exist_ok=True)
dst = '.deploy-backups/db-before-deploy-%s.pgdump' % datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
env = dict(os.environ, PGPASSWORD=db['PASSWORD'])
# فرمتِ custom (فشرده)؛ رمز از env می‌رود نه argv چون ps روی این ماشین argv را به همه نشان می‌دهد.
subprocess.run(['pg_dump', '-Fc', '-h', db['HOST'], '-p', str(db['PORT']), '-U', db['USER'],
'-f', dst, db['NAME']], env=env, check=True)
os.chmod(dst, 0o600)
# فقط خوانا بودنِ فایل را می‌سنجد؛ بازیابیِ واقعی روی دیتابیسِ دیگر آزموده نشده.
subprocess.run(['pg_restore', '--list', dst], check=True, stdout=subprocess.DEVNULL)
print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، pg_restore --list: ok)')
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.pgdump'))[:-2]
for path in old:
os.remove(path)
raise SystemExit(0)
if db['ENGINE'] != 'django.db.backends.sqlite3':
print(f" ⚠ موتورِ دیتابیس {db['ENGINE']} است؛ بکاپِ خودکار فقط SQLite را پوشش می‌دهد. خودتان بکاپ بگیرید.")
print(f" ⚠ موتورِ دیتابیس {db['ENGINE']} است؛ بکاپِ خودکار فقط SQLite و Postgres را پوشش می‌دهد. خودتان بکاپ بگیرید.")
raise SystemExit(0)
src_path = str(db['NAME'])

View File

@@ -24,3 +24,4 @@ qdrant-client==1.19.0
pymupdf==1.28.2
pdf2image==1.17.0
pillow==12.3.0
psycopg[binary]==3.3.6