diff --git a/.env.example b/.env.example index 9caafb9..b37e34e 100644 --- a/.env.example +++ b/.env.example @@ -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 + # ═══════════════════════════════════════════════════════════════════════ # ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند # ═══════════════════════════════════════════════════════════════════════ diff --git a/accounts/admin.py b/accounts/admin.py index 010dbe8..d666ac7 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -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 diff --git a/accounts/migrations/0009_loginevent.py b/accounts/migrations/0009_loginevent.py new file mode 100644 index 0000000..f2a2844 --- /dev/null +++ b/accounts/migrations/0009_loginevent.py @@ -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'], + }, + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 0c1070b..ef73492 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -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}" diff --git a/accounts/tests_login_events.py b/accounts/tests_login_events.py new file mode 100644 index 0000000..0eecb16 --- /dev/null +++ b/accounts/tests_login_events.py @@ -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'}) diff --git a/accounts/views.py b/accounts/views.py index fb40341..0efb813 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -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) diff --git a/config/settings.py b/config/settings.py index da6efa4..1633380 100644 --- a/config/settings.py +++ b/config/settings.py @@ -314,37 +314,60 @@ WSGI_APPLICATION = 'config.wsgi.application' # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - # timeout بالاتر از پیش‌فرض: وقتی دو کانکشنِ هم‌زمان (مثلِ تستِ - # concurrency واقعیِ campaigns) به قفلِ نوشتنِ SQLite برخورد - # می‌کنن، این باعث می‌شه کانکشنِ منتظر تا ۲۰ ثانیه صبر کنه به‌جای - # اینکه فوراً OperationalError('database table is locked') بده. - 'OPTIONS': { - 'timeout': 20, - # هر atomic() قفلِ نوشتن را از اول می‌گیرد (BEGIN IMMEDIATE). - # SQLite از SELECT ... FOR UPDATE پشتیبانی نمی‌کند و جنگو - # select_for_update را رویش بی‌صدا نادیده می‌گیرد؛ پس محافظ‌های - # «قفلِ ردیف» در پرداخت، تخفیف، قلب و سهمیه‌ی دستیار بدونِ این - # عملاً قفلی نداشتند. با IMMEDIATE کلِ تراکنش سریالی می‌شود (قفلِ - # کلِ دیتابیس، نه ردیف) — روی SQLite درشت‌تر ولی درست. هزینه: - # تراکنش‌ها پشتِ هم صف می‌کشند؛ فراخوانی‌های شبکه‌ای (AI، زرین‌پال) - # عمداً بیرون از atomic() هستند. بعد از Postgres این را بردارید. - 'transaction_mode': 'IMMEDIATE', - }, - # کانکشنِ DB به‌ازای هر request باز/بسته نشه؛ تا این تعداد ثانیه - # persistent بمونه. با SQLite بی‌اثرِ عملی ولی بی‌ضرره؛ بعد از - # مهاجرت به Postgres جلوی هزینه‌ی TCP+auth در هر request رو می‌گیره. - 'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')), - # تست‌ها روی فایل، مثلِ پروداکشن. دیتابیسِ پیش‌فرضِ تست در حافظه با - # shared-cache است که قفلش جدول‌به‌جدول و بی‌انتظار است («table is - # locked») — رفتاری که روی فایلِ واقعی پیش نمی‌آید؛ تستِ هم‌زمانیِ - # campaigns با BEGIN IMMEDIATE فقط همین‌جا می‌افتاد. - 'TEST': {'NAME': str(BASE_DIR / '.test-db.sqlite3')}, +# 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', + 'NAME': BASE_DIR / 'db.sqlite3', + # timeout بالاتر از پیش‌فرض: وقتی دو کانکشنِ هم‌زمان (مثلِ تستِ + # concurrency واقعیِ campaigns) به قفلِ نوشتنِ SQLite برخورد + # می‌کنن، این باعث می‌شه کانکشنِ منتظر تا ۲۰ ثانیه صبر کنه به‌جای + # اینکه فوراً OperationalError('database table is locked') بده. + 'OPTIONS': { + 'timeout': 20, + # هر atomic() قفلِ نوشتن را از اول می‌گیرد (BEGIN IMMEDIATE). + # SQLite از SELECT ... FOR UPDATE پشتیبانی نمی‌کند و جنگو + # select_for_update را رویش بی‌صدا نادیده می‌گیرد؛ پس محافظ‌های + # «قفلِ ردیف» در پرداخت، تخفیف، قلب و سهمیه‌ی دستیار بدونِ این + # عملاً قفلی نداشتند. با IMMEDIATE کلِ تراکنش سریالی می‌شود (قفلِ + # کلِ دیتابیس، نه ردیف) — روی SQLite درشت‌تر ولی درست. هزینه: + # تراکنش‌ها پشتِ هم صف می‌کشند؛ فراخوانی‌های شبکه‌ای (AI، زرین‌پال) + # عمداً بیرون از atomic() هستند. بعد از Postgres این را بردارید. + 'transaction_mode': 'IMMEDIATE', + }, + # کانکشنِ DB به‌ازای هر request باز/بسته نشه؛ تا این تعداد ثانیه + # persistent بمونه. با SQLite بی‌اثرِ عملی ولی بی‌ضرره؛ بعد از + # مهاجرت به Postgres جلوی هزینه‌ی TCP+auth در هر request رو می‌گیره. + 'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')), + # تست‌ها روی فایل، مثلِ پروداکشن. دیتابیسِ پیش‌فرضِ تست در حافظه با + # shared-cache است که قفلش جدول‌به‌جدول و بی‌انتظار است («table is + # locked») — رفتاری که روی فایلِ واقعی پیش نمی‌آید؛ تستِ هم‌زمانیِ + # campaigns با BEGIN IMMEDIATE فقط همین‌جا می‌افتاد. + 'TEST': {'NAME': str(BASE_DIR / '.test-db.sqlite3')}, + } + } + # In production a shared cache is required for rate limiting to work across # workers/instances; falls back to LocMemCache only when DEBUG=True. diff --git a/content/migrations/0012_choice_value_300.py b/content/migrations/0012_choice_value_300.py new file mode 100644 index 0000000..e3fd454 --- /dev/null +++ b/content/migrations/0012_choice_value_300.py @@ -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), + ), + ] diff --git a/content/models.py b/content/models.py index 8699cb1..6990375 100644 --- a/content/models.py +++ b/content/models.py @@ -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): diff --git a/deploy.sh b/deploy.sh index ff5fba2..24c9453 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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']) diff --git a/requirements.txt b/requirements.txt index 3131223..64626bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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