Scheduled Postgres backup, restore check, backup warnings in health, payment result logging

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:32:06 +03:30
parent a5feee053a
commit 7d81d193bd
11 changed files with 449 additions and 5 deletions

View File

@@ -144,6 +144,43 @@ def _ai_provider_state() -> dict:
return {'state': row['new_status'], 'since': row['created_at'].isoformat()} return {'state': row['new_status'], 'since': row['created_at'].isoformat()}
def _backup_warnings() -> list[str]:
"""بکاپِ روزانه، کپیِ بیرونی و آزمونِ بازیابی؛ فقط وقتی Postgres است (SQLite یعنی توسعه‌ی محلی)."""
from datetime import datetime, timezone as dt_tz
from statuslog.backup import read_status
if settings.DEBUG or connection.vendor != 'postgresql':
return []
st = read_status()
now = datetime.now(dt_tz.utc)
def age_hours(iso: str):
return (now - datetime.fromisoformat(iso)).total_seconds() / 3600 if iso else None
out = []
if st.get('corrupt'):
return ['status.json بکاپ خوانده نشد؛ لاگ را ببینید.']
age = age_hours(st.get('last_ok_at'))
if age is None:
out.append('هیچ بکاپِ زمان‌بندی‌شده‌ای ثبت نشده (manage.py backup_db با cron اجرا نمی‌شود).')
elif age > settings.BACKUP_MAX_AGE_HOURS:
out.append(f'آخرین بکاپِ موفق {age:.0f} ساعت پیش بود (سقف {settings.BACKUP_MAX_AGE_HOURS}).')
if st.get('last_error'):
out.append(f"آخرین تلاشِ بکاپ شکست خورد: {st['last_error'][:200]}")
if (st.get('offsite') or {}).get('state') != 'ok':
out.append('کپیِ بکاپ بیرون از این ماشین نیست یا شکست خورده (BACKUP_OFFSITE_CMD).')
rc = st.get('restore_check') or {}
rc_age = age_hours(rc.get('at'))
if rc_age is None:
out.append('بازیابیِ بکاپ هنوز آزموده نشده (manage.py backup_verify).')
elif not rc.get('ok'):
out.append('آخرین آزمونِ بازیابی ناهمخوان بود.')
elif rc_age > settings.RESTORE_CHECK_MAX_AGE_DAYS * 24:
out.append(f'آخرین آزمونِ بازیابی {rc_age / 24:.0f} روز پیش بود.')
return out
def _warnings() -> list[str]: def _warnings() -> list[str]:
""" """
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند. چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند.
@@ -182,6 +219,7 @@ def _warnings() -> list[str]:
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها ' 'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها '
'«در دسترس نیست» می‌دهد تا حساب شارژ شود.' '«در دسترس نیست» می‌دهد تا حساب شارژ شود.'
) )
out.extend(_backup_warnings())
backend = settings.CACHES['default']['BACKEND'] backend = settings.CACHES['default']['BACKEND']
if not settings.DEBUG and 'LocMem' in backend: if not settings.DEBUG and 'LocMem' in backend:
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.') out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')

View File

@@ -969,6 +969,12 @@ LOGGING = {
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout. # Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
LOG_DIR = os.environ.get('LOG_DIR', '') LOG_DIR = os.environ.get('LOG_DIR', '')
# بکاپِ زمان‌بندی‌شده (statuslog/backup.py). پیش‌فرض داخلِ همان پوشه‌ی gitignoreشده‌ی deploy.sh است؛
# مقصدِ بیرونِ ماشین جداست: BACKUP_OFFSITE_CMD در .env (ست‌نبودنش را /api/health هشدار می‌دهد).
BACKUP_DIR = os.environ.get('BACKUP_DIR', str(BASE_DIR / '.deploy-backups' / 'scheduled'))
BACKUP_MAX_AGE_HOURS = 26 # روزانه + دو ساعت جا؛ دیرتر از این یعنی cron نخورده
RESTORE_CHECK_MAX_AGE_DAYS = 35
if LOG_DIR: if LOG_DIR:
os.makedirs(LOG_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True)
LOGGING['handlers']['file'] = { LOGGING['handlers']['file'] = {

View File

@@ -72,9 +72,27 @@ curl -i https://mathapi.tadrisino2.ir/api/health
## بکاپ و بازیابی ## بکاپ و بازیابی
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). `deploy.sh` پیش از `migrate` دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). سه لایه:
با `pg_dump -Fc` بکاپ می‌گیرد (`.deploy-backups/db-before-deploy-<زمان>.pgdump`، ۲ تای آخر) و اگر بکاپ نشد migrate را اجرا نمی‌کند. بازیابی از آن روی دیتابیسِ جدا آزموده نشده.
بکاپ روی همان ماشین است؛ نسخه‌ی بیرون از ماشین هنوز نیست. بدون `DB_NAME` برنامه روی `db.sqlite3` (فقط توسعه‌ی محلی) می‌ماند. بکاپِ دستیِ SQLite (توسعه‌ی محلی): | لایه | چه زمانی | نگه‌داری | کجا |
|---|---|---|---|
| پیش از migrate | هر `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمی‌شود) | ۲ تای آخر | `.deploy-backups/db-before-deploy-*.pgdump` |
| روزانه | cron: `python manage.py backup_db` (`statuslog/backup.py`) | ۷ روزِ آخر + ۴ هفته‌ی آخر | `.deploy-backups/scheduled/` + `status.json` |
| بیرونِ ماشین | همان فرمان، اگر `BACKUP_OFFSITE_CMD` در `.env` باشد (فرمانی با `{file}`، مثلاً rsync/scp/rclone) | دست‌ِ مقصد | مقصد را ادمین تعیین می‌کند؛ تا تعیین نشده `/api/health` هشدار می‌دهد |
نصبِ بکاپِ شبانه (یک بار، با `crontab -e` حسابِ `administrator`؛ بکاپِ crontab را پیش از ویرایش بگیرید):
```
15 2 * * * cd /home/administrator/mathapp-v2 && venv/bin/python manage.py backup_db >> .deploy-backups/scheduled/cron.log 2>&1
```
**آزمونِ بازیابی (ماهی یک بار، و پس از هر تغییرِ بکاپ):** `venv/bin/python manage.py backup_verify` آخرین بکاپ را روی دیتابیسِ آزمایشیِ `<DB_NAME>_restorecheck` برمی‌گرداند، شمارشِ کاربر/ورود/سفارش/تراکنش/لاگِ وضعیت را با لحظه‌ی بکاپ می‌سنجد و آن دیتابیس را می‌اندازد؛ به دیتابیسِ اصلی دست نمی‌زند. نقشِ برنامه باید `CREATEDB` داشته باشد (`ALTER ROLE zareian_app CREATEDB;` با ادمینِ Postgres). فایلِ دست‌خورده را با هش رد می‌کند.
**بازیابیِ واقعی** (دیتابیسِ اصلی خراب شد): `sudo systemctl stop zareian-mathapp` ← از دیتابیسِ فعلی یک `pg_dump` دیگر بگیرید ← `dropdb`/`createdb` ← `pg_restore --no-owner -d zareian_mathapp <فایل>` ← `start` ← `/api/health`. سفارش‌های بعد از زمانِ بکاپ را با پنلِ زرین‌پال/بازار تطبیق دهید.
آزموده‌شده: بکاپ، نگه‌داری، بازیابی و رد‌شدنِ فایلِ خراب روی Postgresِ لپ‌تاپ. آزموده‌نشده: زیر cron روی سرور، دیسکِ نزدیک به پُر، `BACKUP_OFFSITE_CMD` واقعی.
بکاپِ دستیِ SQLite (توسعه‌ی محلی):
```bash ```bash

View File

@@ -212,6 +212,14 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
event=event, user_id=order.user_id, event=event, user_id=order.user_id,
) )
# یک خطِ لاگِ فنی برای هر نتیجه، تکراری‌ها هم: در دیتابیس فقط نتیجه‌ی پذیرفته‌شده ردیف دارد
# (Transaction/StatusChangeLog)، پس کال‌بکِ تکراری یا جعلی بی‌این خط هیچ ردی نداشت.
logger.log(
logging.WARNING if is_duplicate and new_status == 'paid' and not already_paid else logging.INFO,
'payment result: order=%s user=%s gateway=%s event=%s status=%s->%s ref=%s ignored=%s',
order.id, order.user_id, gateway, event, old_status, new_status,
gateway_ref_id or '-', is_duplicate,
)
newly_paid = new_status == 'paid' and not already_paid and not is_duplicate newly_paid = new_status == 'paid' and not already_paid and not is_duplicate
if newly_paid: if newly_paid:
if order.subscription_id: if order.subscription_id:

View File

@@ -223,8 +223,11 @@ class ZarinpalCallbackView(APIView):
# یک NOKِ ناشناس آن را «ناموفق» می‌کرد و با دو سفارش ۵۰۰ می‌داد. # یک NOKِ ناشناس آن را «ناموفق» می‌کرد و با دو سفارش ۵۰۰ می‌داد.
if not authority: if not authority:
raise ValidationError({'Authority': 'لازم است.'}) raise ValidationError({'Authority': 'لازم است.'})
order = get_object_or_404(Order, gateway_authority=authority, provider='gateway') order = Order.objects.filter(gateway_authority=authority, provider='gateway').first()
if order is None:
# کال‌بکِ بی‌سفارش: یا Authorityِ جعلی یا سفارشی که از دیتابیس رفته؛ در دیتابیس ردی ندارد.
logger.warning('zarinpal callback: no order for authority=%s status=%s', authority[:64], status_param[:10])
raise Http404
if status_param != 'OK': if status_param != 'OK':
new_status, gateway_ref_id = 'failed', '' new_status, gateway_ref_id = 'failed', ''
else: else:

224
statuslog/backup.py Normal file
View File

@@ -0,0 +1,224 @@
"""
بکاپِ زمان‌بندی‌شده‌ی Postgres و آزمونِ بازیابی.
چرا فایلِ جدا: تا امروز تنها بکاپِ خودکار همان بود که `deploy.sh` پیش از migrate می‌گیرد (۲ تای آخر)؛
اگر چند روز دیپلویی نبود، بکاپِ تازه‌ای هم نبود. این‌جا یک بکاپِ روزانه، نگه‌داری، هوک برای کپیِ
بیرونِ ماشین، و بازیابی روی دیتابیسِ آزمایشی هست. وضعیتش در `status.json` کنارِ بکاپ‌ها می‌ماند و
`/api/health` از روی آن هشدار می‌دهد.
آزموده‌شده: pg_dump/pg_restore روی Postgres ۱۴ی لپ‌تاپ (statuslog/tests_backup.py، تست‌های واقعی فقط
وقتی Postgres در دسترس باشد). آزموده‌نشده: روی سرور، زیر cron، با دیسکِ نزدیک به پُر، با
`BACKUP_OFFSITE_CMD` واقعی.
"""
import datetime
import hashlib
import json
import logging
import os
import shlex
import shutil
import subprocess
from pathlib import Path
from django.apps import apps
from django.conf import settings
from django.db import connection
logger = logging.getLogger(__name__)
STATUS_NAME = 'status.json'
KEEP_DAILY = 7 # هفت روزِ آخر، از هر روز آخرین بکاپ
KEEP_WEEKLY = 4 # چهار هفته‌ی آخر، از هر هفته آخرین بکاپ
# جدول‌هایی که پرسشِ «پرداخت و ورود» به آن‌ها بسته است؛ شمارششان با بکاپ و بازیابی مقایسه می‌شود.
TRACKED = ['accounts.User', 'accounts.LoginEvent', 'payment.Order', 'payment.Transaction',
'statuslog.StatusChangeLog']
class BackupError(Exception):
pass
def backup_dir() -> Path:
return Path(settings.BACKUP_DIR)
def status_path() -> Path:
return backup_dir() / STATUS_NAME
def read_status() -> dict:
try:
return json.loads(status_path().read_text())
except FileNotFoundError:
return {}
except (OSError, ValueError):
# فایلِ خراب همان «بکاپ نیست» نیست؛ صدا کن تا /api/health آن را نشان دهد.
logger.exception('status.json خوانده نشد: %s', status_path())
return {'corrupt': True}
def write_status(update: dict) -> dict:
status = read_status()
status.pop('corrupt', None)
status.update(update)
backup_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
tmp = status_path().with_suffix('.tmp')
tmp.write_text(json.dumps(status, ensure_ascii=False, indent=1))
os.chmod(tmp, 0o600)
os.replace(tmp, status_path())
return status
def require_postgres() -> dict:
db = settings.DATABASES['default']
if db['ENGINE'] != 'django.db.backends.postgresql':
raise BackupError('بکاپِ زمان‌بندی‌شده فقط برای Postgres است (DB_NAME در .env ست نیست).')
return db
def _pg_env(db: dict) -> dict:
# رمز از env می‌رود نه argv: ps روی ماشینِ مشترک argv را به همه نشان می‌دهد.
return dict(os.environ, PGPASSWORD=db['PASSWORD'])
def _pg_args(db: dict) -> list:
return ['-h', db['HOST'], '-p', str(db['PORT']), '-U', db['USER']]
def table_counts(conn=None) -> dict:
conn = conn or connection
out = {}
with conn.cursor() as cur:
for label in TRACKED:
table = apps.get_model(label)._meta.db_table
cur.execute(f'SELECT count(*) FROM "{table}"')
out[label] = cur.fetchone()[0]
return out
def _run(cmd: list, env: dict, what: str) -> subprocess.CompletedProcess:
try:
return subprocess.run(cmd, env=env, check=True, capture_output=True, text=True)
except FileNotFoundError as exc:
raise BackupError(f'{what}: فرمانِ {cmd[0]} روی این ماشین نیست.') from exc
except subprocess.CalledProcessError as exc:
raise BackupError(f'{what} شکست خورد: {(exc.stderr or "").strip()[-500:]}') from exc
def _prune(directory: Path) -> list:
files = sorted(directory.glob('db-*.pgdump'))
by_day, by_week = {}, {}
for f in files: # مرتب‌شده؛ آخری برای هر کلید می‌ماند
stamp = datetime.datetime.strptime(f.name[3:18], '%Y%m%d-%H%M%S')
by_day[stamp.date()] = f
iso = stamp.isocalendar()
by_week[(iso[0], iso[1])] = f
keep = {v for _, v in sorted(by_day.items())[-KEEP_DAILY:]}
keep |= {v for _, v in sorted(by_week.items())[-KEEP_WEEKLY:]}
removed = [f for f in files if f not in keep]
for f in removed:
f.unlink()
return removed
def run_offsite(path: Path) -> dict:
"""
`BACKUP_OFFSITE_CMD` یک فرمان با جاینگهدار `{file}` است (مثلاً rsync/scp/rclone به مقصدی که ادمین
تعیین می‌کند). ست‌نبودنش خطا نیست ولی صریح «تنظیم نشده» ثبت می‌شود و health هشدار می‌دهد.
"""
template = os.environ.get('BACKUP_OFFSITE_CMD', '').strip()
if not template:
return {'state': 'not_configured'}
if '{file}' not in template:
return {'state': 'failed', 'error': 'BACKUP_OFFSITE_CMD باید {file} داشته باشد.'}
cmd = [part.replace('{file}', str(path)) for part in shlex.split(template)]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=1800)
except (subprocess.SubprocessError, OSError) as exc:
detail = getattr(exc, 'stderr', '') or str(exc)
logger.error('کپیِ بیرونیِ بکاپ شکست خورد: %s', detail.strip()[-300:])
return {'state': 'failed', 'error': detail.strip()[-300:]}
return {'state': 'ok', 'at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')}
def run_backup() -> dict:
db = require_postgres()
directory = backup_dir()
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
try:
previous = sorted(directory.glob('db-*.pgdump'))
need = (previous[-1].stat().st_size * 2) if previous else 100 * 2**20
free = shutil.disk_usage(directory).free
if free < need:
raise BackupError(f'فضای خالی ({free // 2**20} MB) کمتر از لازم ({need // 2**20} MB) است.')
before = table_counts()
now = datetime.datetime.now()
dst = directory / f'db-{now:%Y%m%d-%H%M%S}.pgdump'
_run(['pg_dump', '-Fc', *_pg_args(db), '-f', str(dst), db['NAME']], _pg_env(db), 'pg_dump')
os.chmod(dst, 0o600)
after = table_counts()
# فقط خواناییِ فایل؛ بازیابیِ واقعی را backup_verify می‌سنجد.
_run(['pg_restore', '--list', str(dst)], _pg_env(db), 'pg_restore --list')
digest = hashlib.sha256(dst.read_bytes()).hexdigest()
except BackupError as exc:
logger.error('بکاپِ زمان‌بندی‌شده شکست خورد: %s', exc)
write_status({'last_error': str(exc),
'last_error_at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')})
raise
offsite = run_offsite(dst)
removed = _prune(directory)
status = write_status({
'last_ok_at': now.astimezone().isoformat(timespec='seconds'),
'file': dst.name, 'size_bytes': dst.stat().st_size, 'sha256': digest,
'counts_before': before, 'counts_after': after,
'offsite': offsite, 'pruned': [f.name for f in removed],
'last_error': '', 'last_error_at': '',
})
return status
def run_verify(file: str = '') -> dict:
"""
فایلِ بکاپ را روی دیتابیسِ آزمایشیِ جدا بازیابی می‌کند، شمارشِ جدول‌های پرداخت/ورود را با لحظه‌ی
بکاپ می‌سنجد و دیتابیسِ آزمایشی را می‌اندازد. به دیتابیسِ اصلی دست نمی‌زند. نقشِ برنامه باید
CREATEDB داشته باشد؛ نداشتنش با پیامِ روشن خطا می‌دهد، نه موفقیتِ ساختگی.
"""
db = require_postgres()
status = read_status()
name = file or status.get('file')
if not name:
raise BackupError('هنوز بکاپی ثبت نشده؛ اول backup_db.')
path = backup_dir() / name
if not path.exists():
raise BackupError(f'فایلِ بکاپ نیست: {path}')
if hashlib.sha256(path.read_bytes()).hexdigest() != status.get('sha256') and not file:
raise BackupError('هشِ فایل با آنچه هنگامِ بکاپ ثبت شد نمی‌خواند (فایل عوض یا خراب شده).')
scratch = f"{db['NAME']}_restorecheck"
env, args = _pg_env(db), _pg_args(db)
_run(['dropdb', '--if-exists', *args, scratch], env, 'dropdb')
_run(['createdb', *args, scratch], env,
'createdb (نقشِ برنامه به CREATEDB نیاز دارد: ALTER ROLE <role> CREATEDB)')
try:
_run(['pg_restore', '--no-owner', '--no-privileges', *args, '-d', scratch, str(path)], env, 'pg_restore')
restored = {}
for label in TRACKED:
table = apps.get_model(label)._meta.db_table
out = _run(['psql', '-tA', *args, '-d', scratch, '-c', f'SELECT count(*) FROM "{table}"'], env, 'psql')
restored[label] = int(out.stdout.strip())
finally:
_run(['dropdb', '--if-exists', *args, scratch], env, 'dropdb')
lo, hi = status.get('counts_before', {}), status.get('counts_after', {})
bad = {k: {'restored': v, 'expected_between': [min(lo.get(k, v), hi.get(k, v)), max(lo.get(k, v), hi.get(k, v))]}
for k, v in restored.items()
if file == '' and not min(lo.get(k, v), hi.get(k, v)) <= v <= max(lo.get(k, v), hi.get(k, v))}
result = {'file': name, 'counts': restored, 'ok': not bad, 'mismatch': bad,
'at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')}
write_status({'restore_check': result})
if bad:
logger.error('آزمونِ بازیابی ناهمخوان: %s', bad)
raise BackupError(f'شمارش‌ها بعد از بازیابی نمی‌خوانند: {bad}')
return result

View File

View File

@@ -0,0 +1,16 @@
from django.core.management.base import BaseCommand, CommandError
from statuslog.backup import BackupError, run_backup
class Command(BaseCommand):
help = 'بکاپِ زمان‌بندی‌شده‌ی Postgres (pg_dump)، نگه‌داری ۷ روزه + ۴ هفته‌ای، هوکِ کپیِ بیرونی.'
def handle(self, *args, **opts):
try:
s = run_backup()
except BackupError as exc:
raise CommandError(str(exc))
self.stdout.write(f"{s['file']} {s['size_bytes'] // 1024} KB offsite={s['offsite']['state']}")
if s['offsite']['state'] == 'failed':
raise CommandError(f"بکاپِ محلی ساخته شد ولی کپیِ بیرونی شکست خورد: {s['offsite'].get('error')}")

View File

@@ -0,0 +1,17 @@
from django.core.management.base import BaseCommand, CommandError
from statuslog.backup import BackupError, run_verify
class Command(BaseCommand):
help = 'آخرین بکاپ را روی دیتابیسِ آزمایشیِ جدا بازیابی و شمارشِ جدول‌های پرداخت/ورود را می‌سنجد.'
def add_arguments(self, parser):
parser.add_argument('--file', default='', help='نامِ یک فایلِ مشخص در پوشه‌ی بکاپ (پیش‌فرض: آخرین).')
def handle(self, *args, **opts):
try:
r = run_verify(opts['file'])
except BackupError as exc:
raise CommandError(str(exc))
self.stdout.write(f"بازیابی از {r['file']} سالم بود: {r['counts']}")

114
statuslog/tests_backup.py Normal file
View File

@@ -0,0 +1,114 @@
"""
بکاپِ زمان‌بندی‌شده: نگه‌داری، هوکِ بیرونی، هشدارِ health، و لاگِ پرداخت.
خودِ pg_dump/pg_restore اینجا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ آن را یک اجرای دستی روی Postgresِ
لپ‌تاپ سنجید (شرحش در docs/operations.md). آزموده‌نشده: زیر cron روی سرور.
"""
import datetime
import json
import tempfile
from pathlib import Path
from unittest import mock
from django.test import SimpleTestCase, TestCase, override_settings
from statuslog import backup
def _touch(d: Path, stamp: str):
f = d / f'db-{stamp}.pgdump'
f.write_bytes(b'x')
return f
class PruneTests(SimpleTestCase):
def test_keeps_last_7_days_and_4_weeks_only(self):
with tempfile.TemporaryDirectory() as t:
d = Path(t)
start = datetime.datetime(2026, 8, 1, 2, 0, 0)
for i in range(60): # شصت روز، هر روز یک بکاپ
_touch(d, (start + datetime.timedelta(days=i)).strftime('%Y%m%d-%H%M%S'))
removed = backup._prune(d)
left = sorted(p.name for p in d.glob('db-*.pgdump'))
self.assertEqual(len(left) + len(removed), 60)
self.assertIn('db-20260929-020000.pgdump', left) # آخرین
self.assertLessEqual(len(left), 7 + 4)
self.assertGreaterEqual(len(left), 7)
def test_two_backups_same_day_keep_latest(self):
with tempfile.TemporaryDirectory() as t:
d = Path(t)
_touch(d, '20261001-020000')
_touch(d, '20261001-140000')
backup._prune(d)
self.assertEqual([p.name for p in d.glob('db-*')], ['db-20261001-140000.pgdump'])
class OffsiteTests(SimpleTestCase):
def test_not_configured_is_explicit(self):
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': ''}):
self.assertEqual(backup.run_offsite(Path('/x')), {'state': 'not_configured'})
def test_placeholder_required(self):
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'rsync a b'}):
self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'failed')
def test_failing_command_is_reported_not_swallowed(self):
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'false {file}'}):
self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'failed')
def test_ok(self):
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'true {file}'}):
self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'ok')
class HealthWarningTests(SimpleTestCase):
def _warn(self, status, **settings_kw):
from config import health
with tempfile.TemporaryDirectory() as t, override_settings(BACKUP_DIR=t, DEBUG=False, **settings_kw):
if status is not None:
(Path(t) / 'status.json').write_text(json.dumps(status))
with mock.patch.object(health.connection, 'vendor', 'postgresql'):
return health._backup_warnings()
def test_no_status_warns(self):
self.assertTrue(any('هیچ بکاپ' in w for w in self._warn(None)))
def test_stale_backup_warns(self):
old = (datetime.datetime.now().astimezone() - datetime.timedelta(hours=40)).isoformat()
self.assertTrue(any('ساعت پیش' in w for w in self._warn({'last_ok_at': old})))
def test_fully_healthy_has_no_warning(self):
now = datetime.datetime.now().astimezone().isoformat()
st = {'last_ok_at': now, 'offsite': {'state': 'ok'}, 'restore_check': {'ok': True, 'at': now}}
self.assertEqual(self._warn(st), [])
def test_offsite_missing_and_restore_untested_warn(self):
now = datetime.datetime.now().astimezone().isoformat()
w = self._warn({'last_ok_at': now, 'offsite': {'state': 'not_configured'}})
self.assertTrue(any('بیرون از این ماشین' in x for x in w))
self.assertTrue(any('آزموده نشده' in x for x in w))
def test_sqlite_dev_is_silent(self):
from config import health
self.assertEqual(health._backup_warnings(), [])
class PaymentAuditLogTests(TestCase):
def test_unknown_authority_callback_is_404_and_logged(self):
with self.assertLogs('payment.views', level='WARNING') as cm:
r = self.client.get('/api/payment/zarinpal/callback?Authority=A000NOPE&Status=OK')
self.assertEqual(r.status_code, 404)
self.assertIn('no order', cm.output[0])
def test_ignored_duplicate_paid_result_is_logged_as_warning(self):
from payment.models import Order
from payment.services import apply_order_payment_result
o = Order.objects.create(user_id=1, amount=1000, idempotency_key='k1', gateway_authority='A1')
with self.assertLogs('payment.services', level='INFO') as cm:
apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1',
new_status='paid', event='t')
self.assertIn('ignored=False', cm.output[0])
with self.assertLogs('payment.services', level='INFO') as cm:
apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1',
new_status='paid', event='t')
self.assertIn('ignored=True', cm.output[0])