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:
@@ -24,6 +24,13 @@ DJANGO_SECRET_KEY=
|
|||||||
# (/api/health هشدار میدهد). مثال: redis://127.0.0.1:6379/1
|
# (/api/health هشدار میدهد). مثال: redis://127.0.0.1:6379/1
|
||||||
CACHE_URL=
|
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
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# ۲) دامنهها — دو آدرس، بقیه از آنها ساخته میشوند
|
# ۲) دامنهها — دو آدرس، بقیه از آنها ساخته میشوند
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied
|
|||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.utils import timezone
|
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
|
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.register(get_user_model(), StaffUserAdmin)
|
||||||
admin.site.unregister(Group)
|
admin.site.unregister(Group)
|
||||||
admin.site.register(Group, StaffGroupAdmin)
|
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):
|
def __str__(self):
|
||||||
return f"otp-{self.phone}"
|
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 import status
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
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 rest_framework.views import APIView
|
||||||
|
|
||||||
from .authentication import invalidate_user_cache, issue_access_token
|
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 .services import OTPDeliveryError, send_otp_sms
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
DeleteAccountRequestSerializer,
|
DeleteAccountRequestSerializer,
|
||||||
@@ -38,6 +38,21 @@ from .serializers import (
|
|||||||
logger = logging.getLogger(__name__)
|
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):
|
class RequestOTPView(APIView):
|
||||||
"""
|
"""
|
||||||
POST /api/auth/otp/request — درخواستِ کدِ ورود.
|
POST /api/auth/otp/request — درخواستِ کدِ ورود.
|
||||||
@@ -273,6 +288,12 @@ class LoginView(APIView):
|
|||||||
|
|
||||||
error = verify_phone_otp(phone, otp)
|
error = verify_phone_otp(phone, otp)
|
||||||
if error is not None:
|
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
|
return error
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -293,6 +314,7 @@ class LoginView(APIView):
|
|||||||
refresh_token=hash_refresh_token(refresh_token),
|
refresh_token=hash_refresh_token(refresh_token),
|
||||||
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
|
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)
|
access_token = issue_access_token(user.id)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@@ -316,7 +338,7 @@ def _count_failed_attempt(phone: str) -> None:
|
|||||||
# Redemption و UserOffer هم میمانند: سقفِ مصرفِ کدِ تخفیف ردیفهای Redemption را میشمارد، پس پاک
|
# Redemption و UserOffer هم میمانند: سقفِ مصرفِ کدِ تخفیف ردیفهای Redemption را میشمارد، پس پاک
|
||||||
# شدنشان یک کدِ یکبارمصرف را با «بساز، بخر، حذف کن» دوباره آزاد میکرد.
|
# شدنشان یک کدِ یکبارمصرف را با «بساز، بخر، حذف کن» دوباره آزاد میکرد.
|
||||||
KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion',
|
KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion',
|
||||||
'discount.Redemption', 'discount.UserOffer')
|
'discount.Redemption', 'discount.UserOffer', 'accounts.LoginEvent')
|
||||||
|
|
||||||
|
|
||||||
class DeleteAccountView(APIView):
|
class DeleteAccountView(APIView):
|
||||||
@@ -356,6 +378,7 @@ class DeleteAccountView(APIView):
|
|||||||
return error
|
return error
|
||||||
|
|
||||||
logger.info('Account deleted by its owner: user_id=%s', user.id)
|
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)
|
delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE, delete_otp_codes=False)
|
||||||
return Response(DeleteAccountResponseSerializer({'success': True}).data)
|
return Response(DeleteAccountResponseSerializer({'success': True}).data)
|
||||||
@@ -395,6 +418,7 @@ class LogoutView(APIView):
|
|||||||
).update(revoked=True)
|
).update(revoked=True)
|
||||||
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
|
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
|
||||||
invalidate_user_cache(request.user.id)
|
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)
|
return Response(LogoutResponseSerializer({'success': True}).data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -314,7 +314,29 @@ WSGI_APPLICATION = 'config.wsgi.application'
|
|||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
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': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
@@ -344,7 +366,8 @@ DATABASES = {
|
|||||||
# campaigns با BEGIN IMMEDIATE فقط همینجا میافتاد.
|
# campaigns با BEGIN IMMEDIATE فقط همینجا میافتاد.
|
||||||
'TEST': {'NAME': str(BASE_DIR / '.test-db.sqlite3')},
|
'TEST': {'NAME': str(BASE_DIR / '.test-db.sqlite3')},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# In production a shared cache is required for rate limiting to work across
|
# In production a shared cache is required for rate limiting to work across
|
||||||
# workers/instances; falls back to LocMemCache only when DEBUG=True.
|
# workers/instances; falls back to LocMemCache only when DEBUG=True.
|
||||||
|
|||||||
18
content/migrations/0012_choice_value_300.py
Normal file
18
content/migrations/0012_choice_value_300.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -129,7 +129,8 @@ class Question(models.Model):
|
|||||||
|
|
||||||
class Choice(models.Model):
|
class Choice(models.Model):
|
||||||
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='choices')
|
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)
|
is_correct = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
21
deploy.sh
21
deploy.sh
@@ -224,8 +224,7 @@ printf ' دیتابیس و بکاپها فقط برای %s خواندنی
|
|||||||
# بهعنوانِ فرمان روی سرور نیست). اگر بکاپ نشد، migrate اجرا نمیشود: دیپلوی بیبکاپ را
|
# بهعنوانِ فرمان روی سرور نیست). اگر بکاپ نشد، migrate اجرا نمیشود: دیپلوی بیبکاپ را
|
||||||
# عمداً نمیپذیریم. فقط ۲ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و یکی قبلتر) نگه داشته میشود تا دیسکِ
|
# عمداً نمیپذیریم. فقط ۲ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و یکی قبلتر) نگه داشته میشود تا دیسکِ
|
||||||
# مشترک پر نشود؛ بکاپهای دستی با نامِ دیگر دست نمیخورند.
|
# مشترک پر نشود؛ بکاپهای دستی با نامِ دیگر دست نمیخورند.
|
||||||
# آزمودهنشده: Postgres (بعد از مهاجرت باید pg_dump جایگزین شود؛ اینجا فقط هشدار میدهد)،
|
# Postgres با pg_dump (آزمودهنشده روی سرور تا انتقال)؛ بکاپ از بیرونِ ماشین که هنوز نیست (بند ۱۷ در چکلیستِ لانچ).
|
||||||
# و بکاپ از بیرونِ ماشین که هنوز نیست (بند ۱۷ در چکلیستِ لانچ).
|
|
||||||
step "بکاپِ دیتابیس"
|
step "بکاپِ دیتابیس"
|
||||||
python - <<'PYBACKUP' || fail "بکاپِ دیتابیس نشد؛ migrate اجرا نشد و هیچ تغییری روی داده اعمال نشد."
|
python - <<'PYBACKUP' || fail "بکاپِ دیتابیس نشد؛ migrate اجرا نشد و هیچ تغییری روی داده اعمال نشد."
|
||||||
import datetime
|
import datetime
|
||||||
@@ -241,8 +240,24 @@ django.setup()
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
db = settings.DATABASES['default']
|
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':
|
if db['ENGINE'] != 'django.db.backends.sqlite3':
|
||||||
print(f" ⚠ موتورِ دیتابیس {db['ENGINE']} است؛ بکاپِ خودکار فقط SQLite را پوشش میدهد. خودتان بکاپ بگیرید.")
|
print(f" ⚠ موتورِ دیتابیس {db['ENGINE']} است؛ بکاپِ خودکار فقط SQLite و Postgres را پوشش میدهد. خودتان بکاپ بگیرید.")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
src_path = str(db['NAME'])
|
src_path = str(db['NAME'])
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ qdrant-client==1.19.0
|
|||||||
pymupdf==1.28.2
|
pymupdf==1.28.2
|
||||||
pdf2image==1.17.0
|
pdf2image==1.17.0
|
||||||
pillow==12.3.0
|
pillow==12.3.0
|
||||||
|
psycopg[binary]==3.3.6
|
||||||
|
|||||||
Reference in New Issue
Block a user