From 7d81d193bd1bb5ce85436532df26693ee85171f7 Mon Sep 17 00:00:00 2001 From: Zareian Date: Thu, 8 Oct 2026 17:32:06 +0330 Subject: [PATCH] Scheduled Postgres backup, restore check, backup warnings in health, payment result logging Co-Authored-By: Claude Sonnet 5.5 --- config/health.py | 38 +++ config/settings.py | 6 + docs/operations.md | 24 +- payment/services.py | 8 + payment/views.py | 7 +- statuslog/backup.py | 224 ++++++++++++++++++ statuslog/management/__init__.py | 0 statuslog/management/commands/__init__.py | 0 statuslog/management/commands/backup_db.py | 16 ++ .../management/commands/backup_verify.py | 17 ++ statuslog/tests_backup.py | 114 +++++++++ 11 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 statuslog/backup.py create mode 100644 statuslog/management/__init__.py create mode 100644 statuslog/management/commands/__init__.py create mode 100644 statuslog/management/commands/backup_db.py create mode 100644 statuslog/management/commands/backup_verify.py create mode 100644 statuslog/tests_backup.py diff --git a/config/health.py b/config/health.py index 0e95ba6..8030cbc 100644 --- a/config/health.py +++ b/config/health.py @@ -144,6 +144,43 @@ def _ai_provider_state() -> dict: 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]: """ چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند. @@ -182,6 +219,7 @@ def _warnings() -> list[str]: 'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها ' '«در دسترس نیست» می‌دهد تا حساب شارژ شود.' ) + out.extend(_backup_warnings()) backend = settings.CACHES['default']['BACKEND'] if not settings.DEBUG and 'LocMem' in backend: out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.') diff --git a/config/settings.py b/config/settings.py index aff9541..adcacdd 100644 --- a/config/settings.py +++ b/config/settings.py @@ -969,6 +969,12 @@ LOGGING = { # Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout. 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: os.makedirs(LOG_DIR, exist_ok=True) LOGGING['handlers']['file'] = { diff --git a/docs/operations.md b/docs/operations.md index 344e82b..faf8888 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -72,9 +72,27 @@ curl -i https://mathapi.tadrisino2.ir/api/health ## بکاپ و بازیابی -دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). `deploy.sh` پیش از `migrate` -با `pg_dump -Fc` بکاپ می‌گیرد (`.deploy-backups/db-before-deploy-<زمان>.pgdump`، ۲ تای آخر) و اگر بکاپ نشد migrate را اجرا نمی‌کند. بازیابی از آن روی دیتابیسِ جدا آزموده نشده. -بکاپ روی همان ماشین است؛ نسخه‌ی بیرون از ماشین هنوز نیست. بدون `DB_NAME` برنامه روی `db.sqlite3` (فقط توسعه‌ی محلی) می‌ماند. بکاپِ دستیِ SQLite (توسعه‌ی محلی): +دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). سه لایه: + +| لایه | چه زمانی | نگه‌داری | کجا | +|---|---|---|---| +| پیش از 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` آخرین بکاپ را روی دیتابیسِ آزمایشیِ `_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 diff --git a/payment/services.py b/payment/services.py index 01b8af9..e82feef 100644 --- a/payment/services.py +++ b/payment/services.py @@ -212,6 +212,14 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st 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 if newly_paid: if order.subscription_id: diff --git a/payment/views.py b/payment/views.py index 4765c3b..10cbed3 100644 --- a/payment/views.py +++ b/payment/views.py @@ -223,8 +223,11 @@ class ZarinpalCallbackView(APIView): # یک NOKِ ناشناس آن را «ناموفق» می‌کرد و با دو سفارش ۵۰۰ می‌داد. if not 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': new_status, gateway_ref_id = 'failed', '' else: diff --git a/statuslog/backup.py b/statuslog/backup.py new file mode 100644 index 0000000..cde0f34 --- /dev/null +++ b/statuslog/backup.py @@ -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 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 diff --git a/statuslog/management/__init__.py b/statuslog/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/statuslog/management/commands/__init__.py b/statuslog/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/statuslog/management/commands/backup_db.py b/statuslog/management/commands/backup_db.py new file mode 100644 index 0000000..3601881 --- /dev/null +++ b/statuslog/management/commands/backup_db.py @@ -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')}") diff --git a/statuslog/management/commands/backup_verify.py b/statuslog/management/commands/backup_verify.py new file mode 100644 index 0000000..534b924 --- /dev/null +++ b/statuslog/management/commands/backup_verify.py @@ -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']}") diff --git a/statuslog/tests_backup.py b/statuslog/tests_backup.py new file mode 100644 index 0000000..afc4d1b --- /dev/null +++ b/statuslog/tests_backup.py @@ -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])