Compare commits
2 commits
699a73c8a0
...
375022e739
| Author | SHA1 | Date | |
|---|---|---|---|
| 375022e739 | |||
| f53a142cf4 |
13 changed files with 893 additions and 353 deletions
19
README.md
19
README.md
|
|
@ -102,9 +102,10 @@
|
|||
градієнтів) підбирається автоматично.
|
||||
- **Розсилка** повідомлення всім клієнтам бота.
|
||||
- **Резервні копії** БД: створення, відновлення, видалення з адмінки,
|
||||
плюс опційні **автоматичні бекапи в Google Drive** через сервісний
|
||||
акаунт (без інтерактивного логіну) з ротацією — старі копії
|
||||
видаляються, коли їх стає більше за налаштовану кількість.
|
||||
плюс опційні **автоматичні бекапи через rclone** (Google Drive, S3-сумісне
|
||||
сховище або будь-який інший rclone-backend, налаштовується прямо у формі)
|
||||
з ротацією — старі копії видаляються, коли їх стає більше за налаштовану
|
||||
кількість.
|
||||
- **Адміністратори**: ролі admin/manager (RBAC) — менеджер не бачить
|
||||
Налаштування, Адміністраторів, Резервні копії, Статус системи й
|
||||
Журнал дій.
|
||||
|
|
@ -133,7 +134,7 @@ bober-bbq-bot/
|
|||
│ │ ├── inventory.py # склад: інгредієнти, рецептура, заявки на поповнення
|
||||
│ │ ├── customers.py # клієнти й статистика по них
|
||||
│ │ ├── statistics.py # звіти за період + графік виручки
|
||||
│ │ ├── system.py # статус системи, бекапи (локальні + Google Drive), очищення БД
|
||||
│ │ ├── system.py # статус системи, бекапи (локальні + rclone), очищення БД
|
||||
│ │ ├── telegram_chats.py # перевірка підключення чатів доставки/самовивозу
|
||||
│ │ ├── broadcast.py # розсилка повідомлення всім клієнтам
|
||||
│ │ ├── admins.py # керування адміністраторами/менеджерами
|
||||
|
|
@ -142,7 +143,7 @@ bober-bbq-bot/
|
|||
│ ├── utils/
|
||||
│ │ ├── reconciliation.py # фонові job'и: страховка оплати + health-check токена
|
||||
│ │ ├── inventory.py # автосписання/повернення складу, автозаявки на поповнення
|
||||
│ │ ├── gdrive_backup.py # автобекапи БД у Google Drive з ротацією
|
||||
│ │ ├── backup_remote.py # автобекапи БД через rclone (Google Drive/S3/інше) з ротацією
|
||||
│ │ ├── notify_customer.py # авто-сповіщення клієнта про статус + запит оцінки
|
||||
│ │ ├── notify_manager.py # сповіщення менеджера (нове/скасоване замовлення), staff_chat_id() по типу замовлення
|
||||
│ │ ├── telegram_notify.py # низькорівневий sendMessage/sendDocument (HTTP, поза aiogram)
|
||||
|
|
@ -380,7 +381,7 @@ Better Uptime тощо), щоб дізнатись про падіння сай
|
|||
|---|---|---|
|
||||
| `reconcile_payments` | кожні 10 хв | звіряє неоплачені card-замовлення за останні 6 год напряму з monobank — страховка на випадок пропущеного вебхука |
|
||||
| `monobank_token_health` | щогодини | пінгує токен monobank; якщо робочий токен раптом перестав діяти — пише власнику (`OWNER_IDS`) в Telegram |
|
||||
| `gdrive_backup` | щогодини (перевіряє) | якщо увімкнено й минув налаштований інтервал — вивантажує свіжий бекап у Google Drive і чистить застарілі копії; при збої пише власнику в Telegram |
|
||||
| `backup_remote` | щогодини (перевіряє) | якщо увімкнено й минув налаштований інтервал — вивантажує свіжий бекап через rclone (Google Drive/S3/інше) і чистить застарілі копії; при збої пише власнику в Telegram |
|
||||
|
||||
## Налаштування в адмінці (без доступу до сервера)
|
||||
|
||||
|
|
@ -411,9 +412,9 @@ Better Uptime тощо), щоб дізнатись про падіння сай
|
|||
розподіл по зірках, фільтр і пагінація.
|
||||
- Розсилка повідомлення всім клієнтам бота.
|
||||
- Резервні копії БД: створення, відновлення, видалення — прямо з адмінки,
|
||||
плюс автоматичні бекапи в Google Drive з ротацією (Адмінка →
|
||||
Резервні копії → секція «Google Drive», з покроковою інструкцією
|
||||
налаштування сервісного акаунта прямо на сторінці).
|
||||
плюс автоматичні бекапи через rclone з ротацією (Адмінка →
|
||||
Резервні копії → тип сховища: Google Drive / S3-сумісне / інше,
|
||||
з покроковою інструкцією налаштування прямо на сторінці для кожного).
|
||||
- Облік сировини: перемикач «Увімкнути облік сировини», нижче в тому
|
||||
ж розділі — при увімкненні в меню з'являється «Склад».
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from bober_bbq.config import BASE_DIR, config
|
|||
from bober_bbq.extensions import db
|
||||
from bober_bbq.models import AuditLog, CartItem, Order, OrderItem, Product, Setting, TelegramUser
|
||||
from bober_bbq.utils.audit import log_action
|
||||
from bober_bbq.utils.gdrive_backup import backup_now, gdrive_enabled, test_connection
|
||||
from bober_bbq.utils.backup_remote import backup_now, backup_remote_enabled, test_connection
|
||||
|
||||
APP_VERSION = "1.0.0"
|
||||
BACKUP_DIR = BASE_DIR / "backups"
|
||||
|
|
@ -93,61 +93,84 @@ def backups_list():
|
|||
]
|
||||
sqlite_path = _sqlite_path()
|
||||
|
||||
gdrive_last_at_raw = Setting.get("gdrive_last_backup_at", "")
|
||||
gdrive_last_at = None
|
||||
if gdrive_last_at_raw:
|
||||
remote_last_at_raw = Setting.get("backup_remote_last_backup_at", "")
|
||||
remote_last_at = None
|
||||
if remote_last_at_raw:
|
||||
try:
|
||||
gdrive_last_at = datetime.fromisoformat(gdrive_last_at_raw).replace(tzinfo=timezone.utc).astimezone(_tz)
|
||||
remote_last_at = datetime.fromisoformat(remote_last_at_raw).replace(tzinfo=timezone.utc).astimezone(_tz)
|
||||
except ValueError:
|
||||
gdrive_last_at = None
|
||||
gdrive_last_status = Setting.get("gdrive_last_backup_status", "")
|
||||
remote_last_at = None
|
||||
remote_last_status = Setting.get("backup_remote_last_backup_status", "")
|
||||
remote_keys = [
|
||||
"backup_remote_enabled",
|
||||
"backup_remote_type",
|
||||
"backup_remote_interval_hours",
|
||||
"backup_remote_retention_count",
|
||||
"backup_gdrive_service_account_json",
|
||||
"backup_gdrive_folder_id",
|
||||
"backup_s3_provider",
|
||||
"backup_s3_access_key_id",
|
||||
"backup_s3_secret_access_key",
|
||||
"backup_s3_endpoint",
|
||||
"backup_s3_region",
|
||||
"backup_s3_bucket",
|
||||
"backup_s3_prefix",
|
||||
"backup_custom_type",
|
||||
"backup_custom_config",
|
||||
"backup_custom_path",
|
||||
]
|
||||
return render_template(
|
||||
"admin/backups.html",
|
||||
backups=backups,
|
||||
is_sqlite=bool(sqlite_path),
|
||||
gdrive_values={
|
||||
"gdrive_backup_enabled": Setting.get("gdrive_backup_enabled", "0"),
|
||||
"gdrive_service_account_json": Setting.get("gdrive_service_account_json", ""),
|
||||
"gdrive_folder_id": Setting.get("gdrive_folder_id", ""),
|
||||
"gdrive_backup_interval_hours": Setting.get("gdrive_backup_interval_hours", "24"),
|
||||
"gdrive_retention_count": Setting.get("gdrive_retention_count", "14"),
|
||||
},
|
||||
gdrive_last_at=gdrive_last_at,
|
||||
gdrive_last_status=gdrive_last_status,
|
||||
remote_values={key: Setting.get(key, "") for key in remote_keys},
|
||||
remote_last_at=remote_last_at,
|
||||
remote_last_status=remote_last_status,
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route("/backups/gdrive-settings", methods=["POST"])
|
||||
@admin_bp.route("/backups/remote-settings", methods=["POST"])
|
||||
@admin_required
|
||||
def backups_gdrive_settings():
|
||||
Setting.set("gdrive_backup_enabled", "1" if request.form.get("gdrive_backup_enabled") else "0")
|
||||
Setting.set("gdrive_service_account_json", request.form.get("gdrive_service_account_json", "").strip())
|
||||
Setting.set("gdrive_folder_id", request.form.get("gdrive_folder_id", "").strip())
|
||||
Setting.set("gdrive_backup_interval_hours", request.form.get("gdrive_backup_interval_hours", "24") or "24")
|
||||
Setting.set("gdrive_retention_count", request.form.get("gdrive_retention_count", "14") or "14")
|
||||
def backups_remote_settings():
|
||||
Setting.set("backup_remote_enabled", "1" if request.form.get("backup_remote_enabled") else "0")
|
||||
Setting.set("backup_remote_type", request.form.get("backup_remote_type", "").strip())
|
||||
Setting.set("backup_remote_interval_hours", request.form.get("backup_remote_interval_hours", "24") or "24")
|
||||
Setting.set("backup_remote_retention_count", request.form.get("backup_remote_retention_count", "14") or "14")
|
||||
Setting.set("backup_gdrive_service_account_json", request.form.get("backup_gdrive_service_account_json", "").strip())
|
||||
Setting.set("backup_gdrive_folder_id", request.form.get("backup_gdrive_folder_id", "").strip())
|
||||
Setting.set("backup_s3_provider", request.form.get("backup_s3_provider", "AWS").strip() or "AWS")
|
||||
Setting.set("backup_s3_access_key_id", request.form.get("backup_s3_access_key_id", "").strip())
|
||||
Setting.set("backup_s3_secret_access_key", request.form.get("backup_s3_secret_access_key", "").strip())
|
||||
Setting.set("backup_s3_endpoint", request.form.get("backup_s3_endpoint", "").strip())
|
||||
Setting.set("backup_s3_region", request.form.get("backup_s3_region", "").strip())
|
||||
Setting.set("backup_s3_bucket", request.form.get("backup_s3_bucket", "").strip())
|
||||
Setting.set("backup_s3_prefix", request.form.get("backup_s3_prefix", "").strip())
|
||||
Setting.set("backup_custom_type", request.form.get("backup_custom_type", "").strip())
|
||||
Setting.set("backup_custom_config", request.form.get("backup_custom_config", "").strip())
|
||||
Setting.set("backup_custom_path", request.form.get("backup_custom_path", "").strip())
|
||||
db.session.commit()
|
||||
log_action("gdrive_settings_update", "Оновлено налаштування резервного копіювання в Google Drive")
|
||||
flash("Налаштування Google Drive збережено", "success")
|
||||
log_action("backup_remote_settings_update", "Оновлено налаштування резервного копіювання")
|
||||
flash("Налаштування резервного копіювання збережено", "success")
|
||||
return redirect(url_for("admin.backups_list"))
|
||||
|
||||
|
||||
@admin_bp.route("/backups/gdrive-test", methods=["POST"])
|
||||
@admin_bp.route("/backups/remote-test", methods=["POST"])
|
||||
@admin_required
|
||||
def backups_gdrive_test():
|
||||
def backups_remote_test():
|
||||
ok, message = test_connection()
|
||||
flash(message, "success" if ok else "error")
|
||||
return redirect(url_for("admin.backups_list"))
|
||||
|
||||
|
||||
@admin_bp.route("/backups/gdrive-backup-now", methods=["POST"])
|
||||
@admin_bp.route("/backups/remote-backup-now", methods=["POST"])
|
||||
@admin_required
|
||||
def backups_gdrive_backup_now():
|
||||
if not gdrive_enabled():
|
||||
flash("Спершу увімкніть і збережіть налаштування Google Drive.", "error")
|
||||
def backups_remote_backup_now():
|
||||
if not backup_remote_enabled():
|
||||
flash("Спершу увімкніть і збережіть налаштування резервного копіювання.", "error")
|
||||
return redirect(url_for("admin.backups_list"))
|
||||
ok, message = backup_now()
|
||||
if ok:
|
||||
log_action("gdrive_backup_manual", "Створено позачергову резервну копію в Google Drive")
|
||||
log_action("backup_remote_manual", "Створено позачергову резервну копію")
|
||||
flash(message, "success" if ok else "error")
|
||||
return redirect(url_for("admin.backups_list"))
|
||||
|
||||
|
|
|
|||
|
|
@ -626,11 +626,25 @@ DEFAULT_SETTINGS = {
|
|||
"liqpay_private_key": "",
|
||||
"liqpay_sandbox_mode": "0",
|
||||
"payment_destination_template": "Оплата замовлення {number}",
|
||||
"gdrive_backup_enabled": "0",
|
||||
"gdrive_service_account_json": "",
|
||||
"gdrive_folder_id": "",
|
||||
"gdrive_backup_interval_hours": "24",
|
||||
"gdrive_retention_count": "14",
|
||||
# Off-site backups via rclone — see bober_bbq/utils/backup_remote.py.
|
||||
# backup_remote_type selects which of the field groups below is
|
||||
# actually used: "" (not configured) | "gdrive" | "s3" | "custom".
|
||||
"backup_remote_enabled": "0",
|
||||
"backup_remote_type": "",
|
||||
"backup_remote_interval_hours": "24",
|
||||
"backup_remote_retention_count": "14",
|
||||
"backup_gdrive_service_account_json": "",
|
||||
"backup_gdrive_folder_id": "",
|
||||
"backup_s3_provider": "AWS",
|
||||
"backup_s3_access_key_id": "",
|
||||
"backup_s3_secret_access_key": "",
|
||||
"backup_s3_endpoint": "",
|
||||
"backup_s3_region": "",
|
||||
"backup_s3_bucket": "",
|
||||
"backup_s3_prefix": "",
|
||||
"backup_custom_type": "",
|
||||
"backup_custom_config": "",
|
||||
"backup_custom_path": "",
|
||||
"admin_theme_accent": "#ff7a3d",
|
||||
"webapp_theme_accent": "#ff7a3d",
|
||||
"sidebar_collapsible_menu": "1",
|
||||
|
|
|
|||
|
|
@ -617,6 +617,7 @@ td { vertical-align: middle; }
|
|||
.recipe-row input { width: 130px; flex-shrink: 0; margin: 0; }
|
||||
|
||||
.order-type-fields { display: none; }
|
||||
.backup-remote-fields { display: none; }
|
||||
|
||||
/* ---------- responsive / mobile ---------- */
|
||||
|
||||
|
|
|
|||
|
|
@ -42,70 +42,160 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h2>☁️ Автоматичні резервні копії в Google Drive</h2>
|
||||
<h2>☁️ Автоматичні резервні копії (rclone)</h2>
|
||||
<div class="card" style="max-width:640px;">
|
||||
{% if gdrive_last_at %}
|
||||
{% if remote_last_at %}
|
||||
<p class="muted" style="margin-top:0;">
|
||||
Останній бекап: {{ gdrive_last_at.strftime('%d.%m.%Y %H:%M') }} —
|
||||
{% if gdrive_last_status == 'ok' %}<span style="color:var(--success);">успішно ✅</span>{% else %}<span style="color:var(--danger);">помилка: {{ gdrive_last_status }}</span>{% endif %}
|
||||
Останній бекап: {{ remote_last_at.strftime('%d.%m.%Y %H:%M') }} —
|
||||
{% if remote_last_status == 'ok' %}<span style="color:var(--success);">успішно ✅</span>{% else %}<span style="color:var(--danger);">помилка: {{ remote_last_status }}</span>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('admin.backups_gdrive_settings') }}">
|
||||
<form method="post" action="{{ url_for('admin.backups_remote_settings') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="checkbox" name="gdrive_backup_enabled" class="admin-checkbox" {{ 'checked' if gdrive_values.gdrive_backup_enabled == '1' }}>
|
||||
Увімкнути автоматичні бекапи в Google Drive
|
||||
<input type="checkbox" name="backup_remote_enabled" class="admin-checkbox" {{ 'checked' if remote_values.backup_remote_enabled == '1' }}>
|
||||
Увімкнути автоматичні резервні копії
|
||||
</label>
|
||||
|
||||
<label>Ключ сервісного акаунта Google (JSON)</label>
|
||||
<textarea name="gdrive_service_account_json" rows="4" placeholder='{"type": "service_account", "client_email": "...", ...}'>{{ gdrive_values.gdrive_service_account_json }}</textarea>
|
||||
|
||||
<label>ID папки Google Drive</label>
|
||||
<input type="text" name="gdrive_folder_id" value="{{ gdrive_values.gdrive_folder_id }}" placeholder="1AbCдEfGhIjKlMnOpQrStUvWxYz">
|
||||
<label>Тип сховища</label>
|
||||
<select name="backup_remote_type" id="backup-remote-type" onchange="syncBackupRemoteFields()">
|
||||
<option value="" {{ 'selected' if remote_values.backup_remote_type == '' }}>— не налаштовано —</option>
|
||||
<option value="gdrive" {{ 'selected' if remote_values.backup_remote_type == 'gdrive' }}>Google Drive</option>
|
||||
<option value="s3" {{ 'selected' if remote_values.backup_remote_type == 's3' }}>S3-сумісне сховище</option>
|
||||
<option value="custom" {{ 'selected' if remote_values.backup_remote_type == 'custom' }}>Інше (для просунутих)</option>
|
||||
</select>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Інтервал бекапів, год</label>
|
||||
<input type="number" min="1" name="gdrive_backup_interval_hours" value="{{ gdrive_values.gdrive_backup_interval_hours }}">
|
||||
<input type="number" min="1" name="backup_remote_interval_hours" value="{{ remote_values.backup_remote_interval_hours }}">
|
||||
</div>
|
||||
<div>
|
||||
<label>Скільки копій зберігати (ротація)</label>
|
||||
<input type="number" min="1" name="gdrive_retention_count" value="{{ gdrive_values.gdrive_retention_count }}">
|
||||
<input type="number" min="1" name="backup_remote_retention_count" value="{{ remote_values.backup_remote_retention_count }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fields-gdrive" class="backup-remote-fields">
|
||||
<label>Ключ сервісного акаунта Google (JSON)</label>
|
||||
<textarea name="backup_gdrive_service_account_json" rows="4" placeholder='{"type": "service_account", "client_email": "...", ...}'>{{ remote_values.backup_gdrive_service_account_json }}</textarea>
|
||||
|
||||
<label>ID папки Google Drive</label>
|
||||
<input type="text" name="backup_gdrive_folder_id" value="{{ remote_values.backup_gdrive_folder_id }}" placeholder="1AbCдEfGhIjKlMnOpQrStUvWxYz">
|
||||
|
||||
<details style="margin-top:12px;">
|
||||
<summary style="cursor:pointer;font-weight:700;font-size:13px;">Як налаштувати (крок за кроком)</summary>
|
||||
<ol class="muted" style="font-size:12.5px;line-height:1.8;padding-left:20px;margin-top:10px;">
|
||||
<li>Відкрийте <a href="https://console.cloud.google.com/" target="_blank">Google Cloud Console</a>, створіть проєкт (або оберіть існуючий).</li>
|
||||
<li>Увімкніть <b>Google Drive API</b> для цього проєкту (APIs & Services → Library).</li>
|
||||
<li>Створіть сервісний акаунт: IAM & Admin → Service Accounts → Create Service Account.</li>
|
||||
<li>Відкрийте створений акаунт → Keys → Add Key → Create new key → JSON. Завантажиться файл.</li>
|
||||
<li>Відкрийте цей файл будь-яким текстовим редактором і вставте весь вміст у поле «Ключ сервісного акаунта» вище.</li>
|
||||
<li>У Google Drive створіть папку для бекапів, поділіться нею (Share) з email сервісного акаунта (поле <code>client_email</code> у JSON-файлі), давши права «Редактор».</li>
|
||||
<li>Скопіюйте ID папки з адресного рядка (частина після <code>/folders/</code>) у поле «ID папки» вище.</li>
|
||||
<li>Оберіть «Google Drive» вище, збережіть, натисніть «Перевірити підключення» — якщо все гаразд, спробуйте «Backup зараз».</li>
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div id="fields-s3" class="backup-remote-fields">
|
||||
<label>Провайдер</label>
|
||||
<select name="backup_s3_provider">
|
||||
<option value="AWS" {{ 'selected' if remote_values.backup_s3_provider == 'AWS' }}>AWS S3</option>
|
||||
<option value="Wasabi" {{ 'selected' if remote_values.backup_s3_provider == 'Wasabi' }}>Wasabi</option>
|
||||
<option value="Minio" {{ 'selected' if remote_values.backup_s3_provider == 'Minio' }}>MinIO</option>
|
||||
<option value="Other" {{ 'selected' if remote_values.backup_s3_provider == 'Other' }}>Інше (напр. Backblaze B2, DigitalOcean Spaces)</option>
|
||||
</select>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Access Key ID</label>
|
||||
<input type="text" name="backup_s3_access_key_id" value="{{ remote_values.backup_s3_access_key_id }}" autocomplete="off">
|
||||
</div>
|
||||
<div>
|
||||
<label>Secret Access Key</label>
|
||||
<input type="password" name="backup_s3_secret_access_key" value="{{ remote_values.backup_s3_secret_access_key }}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Endpoint <span class="muted">(порожньо для справжнього AWS S3)</span></label>
|
||||
<input type="text" name="backup_s3_endpoint" value="{{ remote_values.backup_s3_endpoint }}" placeholder="напр. s3.eu-central-003.backblazeb2.com">
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Регіон <span class="muted">(необов'язково)</span></label>
|
||||
<input type="text" name="backup_s3_region" value="{{ remote_values.backup_s3_region }}">
|
||||
</div>
|
||||
<div>
|
||||
<label>Бакет</label>
|
||||
<input type="text" name="backup_s3_bucket" value="{{ remote_values.backup_s3_bucket }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Тека всередині бакета <span class="muted">(необов'язково)</span></label>
|
||||
<input type="text" name="backup_s3_prefix" value="{{ remote_values.backup_s3_prefix }}" placeholder="напр. backups">
|
||||
|
||||
<details style="margin-top:12px;">
|
||||
<summary style="cursor:pointer;font-weight:700;font-size:13px;">Як налаштувати (крок за кроком)</summary>
|
||||
<ol class="muted" style="font-size:12.5px;line-height:1.8;padding-left:20px;margin-top:10px;">
|
||||
<li>Створіть бакет у своєму провайдері (AWS S3, Backblaze B2, Wasabi, DigitalOcean Spaces тощо).</li>
|
||||
<li>Створіть ключ доступу (Access Key / Application Key) з правом запису в цей бакет.</li>
|
||||
<li>Для AWS S3 залиште поле «Endpoint» порожнім. Для інших провайдерів — вкажіть їхній S3-endpoint (є в панелі провайдера; для Backblaze B2 і подібних оберіть провайдера «Інше»).</li>
|
||||
<li>Заповніть Access Key ID, Secret Access Key, бакет — і збережіть.</li>
|
||||
<li>Натисніть «Перевірити підключення», потім «Backup зараз».</li>
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div id="fields-custom" class="backup-remote-fields">
|
||||
<label>Тип rclone-backend'у</label>
|
||||
<input type="text" name="backup_custom_type" value="{{ remote_values.backup_custom_type }}" placeholder="напр. sftp, b2, azureblob">
|
||||
|
||||
<label>Додаткові параметри <span class="muted">(по одному <code>ключ = значення</code> на рядок)</span></label>
|
||||
<textarea name="backup_custom_config" rows="4" placeholder="account = ... key = ...">{{ remote_values.backup_custom_config }}</textarea>
|
||||
|
||||
<label>Шлях призначення</label>
|
||||
<input type="text" name="backup_custom_path" value="{{ remote_values.backup_custom_path }}" placeholder="напр. mybucket/backups">
|
||||
|
||||
<details style="margin-top:12px;">
|
||||
<summary style="cursor:pointer;font-weight:700;font-size:13px;">Як налаштувати (крок за кроком)</summary>
|
||||
<p class="muted" style="font-size:12.5px;line-height:1.7;">
|
||||
Для нетипових сховищ, не перелічених вище. Тип і назви параметрів беруться з
|
||||
<a href="https://rclone.org/overview/" target="_blank">документації rclone для конкретного сховища</a>
|
||||
(наприклад, для SFTP: тип <code>sftp</code>, параметри <code>host</code>/<code>user</code>/<code>pass</code>).
|
||||
Кожен рядок у полі «Додаткові параметри» стає одним параметром конфігурації цього сховища.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:14px;display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<button class="btn" type="submit">Зберегти</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style="margin-top:14px;display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<form method="post" action="{{ url_for('admin.backups_gdrive_test') }}" style="display:inline;">
|
||||
<form method="post" action="{{ url_for('admin.backups_remote_test') }}" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn secondary small" type="submit">🔌 Перевірити підключення</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('admin.backups_gdrive_backup_now') }}" style="display:inline;">
|
||||
<form method="post" action="{{ url_for('admin.backups_remote_backup_now') }}" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn secondary small" type="submit">☁️ Backup зараз</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<details style="margin-top:18px;">
|
||||
<summary style="cursor:pointer;font-weight:700;font-size:13px;">Як налаштувати (крок за кроком)</summary>
|
||||
<ol class="muted" style="font-size:12.5px;line-height:1.8;padding-left:20px;margin-top:10px;">
|
||||
<li>Відкрийте <a href="https://console.cloud.google.com/" target="_blank">Google Cloud Console</a>, створіть проєкт (або оберіть існуючий).</li>
|
||||
<li>Увімкніть <b>Google Drive API</b> для цього проєкту (APIs & Services → Library).</li>
|
||||
<li>Створіть сервісний акаунт: IAM & Admin → Service Accounts → Create Service Account.</li>
|
||||
<li>Відкрийте створений акаунт → Keys → Add Key → Create new key → JSON. Завантажиться файл.</li>
|
||||
<li>Відкрийте цей файл будь-яким текстовим редактором і вставте весь вміст у поле «Ключ сервісного акаунта» вище.</li>
|
||||
<li>У Google Drive створіть папку для бекапів, поділіться нею (Share) з email сервісного акаунта (поле <code>client_email</code> у JSON-файлі), давши права «Редактор».</li>
|
||||
<li>Скопіюйте ID папки з адресного рядка (частина після <code>/folders/</code>) у поле «ID папки» вище.</li>
|
||||
<li>Увімкніть перемикач, збережіть, натисніть «Перевірити підключення» — якщо все гаразд, спробуйте «Backup зараз».</li>
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function syncBackupRemoteFields() {
|
||||
var type = document.getElementById('backup-remote-type').value;
|
||||
document.querySelectorAll('.backup-remote-fields').forEach(function (el) {
|
||||
el.style.display = (el.id === 'fields-' + type) ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
syncBackupRemoteFields();
|
||||
</script>
|
||||
|
||||
<dialog id="restore-dialog" style="border:1px solid var(--border);border-radius:var(--radius);background:var(--panel);color:var(--text);padding:0;max-width:420px;width:90%;">
|
||||
<form method="post" id="restore-form" style="padding:20px;display:flex;flex-direction:column;gap:12px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
|
|
|||
306
bober_bbq/utils/backup_remote.py
Normal file
306
bober_bbq/utils/backup_remote.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
"""Automatic off-site backups (SQLite DB + static/uploads/) via rclone,
|
||||
with rotation: once more than `backup_remote_retention_count` backups exist
|
||||
in the target remote, the oldest ones are deleted.
|
||||
|
||||
rclone is invoked as an external binary (`subprocess`), never via a config
|
||||
file on disk — the selected remote's credentials are read from `Setting`
|
||||
rows (see _remote_env_and_path()) and passed as RCLONE_CONFIG_BACKUP_*
|
||||
environment variables for each call, exactly as rclone's own docs describe
|
||||
for defining a remote entirely through the environment. This keeps
|
||||
credentials in the same place the previous Google-Drive-only version kept
|
||||
its service-account JSON (the app's own Setting table) — no worse than
|
||||
before, and no rclone.conf ever touches the filesystem.
|
||||
|
||||
Entirely opt-in via "backup_remote_enabled". The scheduled job in
|
||||
run_web.py calls run_scheduled_backup() frequently (e.g. hourly); this
|
||||
module itself decides whether the configured interval has actually
|
||||
elapsed, so changing the interval in the admin panel takes effect without
|
||||
restarting the process.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from bober_bbq.config import BASE_DIR, config
|
||||
from bober_bbq.extensions import db
|
||||
from bober_bbq.models import Setting
|
||||
from bober_bbq.utils.telegram_notify import send_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_tz = ZoneInfo(config.TIMEZONE)
|
||||
|
||||
# Internal rclone remote name — never shown to the admin, just the prefix
|
||||
# used for RCLONE_CONFIG_BACKUP_* env vars and the "backup:..." path arg.
|
||||
_REMOTE = "backup"
|
||||
|
||||
|
||||
class BackupError(Exception):
|
||||
"""Raised for any configuration or rclone problem — the message is
|
||||
written straight into flash messages / owner notifications, so it's
|
||||
kept human-readable rather than a raw stack trace."""
|
||||
|
||||
|
||||
def backup_remote_enabled() -> bool:
|
||||
return Setting.get_bool("backup_remote_enabled", False)
|
||||
|
||||
|
||||
def _remote_env_and_path() -> tuple[dict, str]:
|
||||
"""Builds the environment rclone needs to see the configured remote,
|
||||
and the "remote:path" argument to use in CLI calls — entirely from
|
||||
Setting rows, entirely via env vars (see module docstring). Raises
|
||||
BackupError immediately if the selected type or its required fields
|
||||
are missing, before any subprocess is ever spawned."""
|
||||
remote_type = Setting.get("backup_remote_type", "").strip()
|
||||
if not remote_type:
|
||||
raise BackupError("Тип сховища для резервних копій не обрано")
|
||||
|
||||
prefix = f"RCLONE_CONFIG_{_REMOTE.upper()}_"
|
||||
env = dict(os.environ)
|
||||
# Never let rclone fall back to a host-level ~/.config/rclone/rclone.conf
|
||||
# — the only configuration source must be what this function builds.
|
||||
env["RCLONE_CONFIG"] = os.devnull
|
||||
|
||||
if remote_type == "gdrive":
|
||||
service_account_json = Setting.get("backup_gdrive_service_account_json", "").strip()
|
||||
folder_id = Setting.get("backup_gdrive_folder_id", "").strip()
|
||||
if not service_account_json:
|
||||
raise BackupError("Не вказано ключ сервісного акаунта Google")
|
||||
if not folder_id:
|
||||
raise BackupError("Не вказано ID папки Google Drive")
|
||||
try:
|
||||
json.loads(service_account_json)
|
||||
except json.JSONDecodeError as e:
|
||||
raise BackupError(f"Невалідний JSON ключа сервісного акаунта: {e}") from e
|
||||
env[prefix + "TYPE"] = "drive"
|
||||
env[prefix + "SERVICE_ACCOUNT_CREDENTIALS"] = service_account_json
|
||||
env[prefix + "ROOT_FOLDER_ID"] = folder_id
|
||||
return env, f"{_REMOTE}:"
|
||||
|
||||
if remote_type == "s3":
|
||||
access_key_id = Setting.get("backup_s3_access_key_id", "").strip()
|
||||
secret_access_key = Setting.get("backup_s3_secret_access_key", "").strip()
|
||||
bucket = Setting.get("backup_s3_bucket", "").strip()
|
||||
if not access_key_id or not secret_access_key:
|
||||
raise BackupError("Не вказано ключі доступу до S3-сховища")
|
||||
if not bucket:
|
||||
raise BackupError("Не вказано назву бакета S3-сховища")
|
||||
env[prefix + "TYPE"] = "s3"
|
||||
env[prefix + "PROVIDER"] = Setting.get("backup_s3_provider", "AWS").strip() or "AWS"
|
||||
env[prefix + "ACCESS_KEY_ID"] = access_key_id
|
||||
env[prefix + "SECRET_ACCESS_KEY"] = secret_access_key
|
||||
endpoint = Setting.get("backup_s3_endpoint", "").strip()
|
||||
if endpoint:
|
||||
env[prefix + "ENDPOINT"] = endpoint
|
||||
region = Setting.get("backup_s3_region", "").strip()
|
||||
if region:
|
||||
env[prefix + "REGION"] = region
|
||||
prefix_path = Setting.get("backup_s3_prefix", "").strip().strip("/")
|
||||
remote_path = f"{_REMOTE}:{bucket}/{prefix_path}" if prefix_path else f"{_REMOTE}:{bucket}"
|
||||
return env, remote_path
|
||||
|
||||
if remote_type == "custom":
|
||||
custom_type = Setting.get("backup_custom_type", "").strip()
|
||||
custom_path = Setting.get("backup_custom_path", "").strip()
|
||||
if not custom_type:
|
||||
raise BackupError("Не вказано тип rclone-backend'у")
|
||||
if not custom_path:
|
||||
raise BackupError("Не вказано шлях призначення")
|
||||
env[prefix + "TYPE"] = custom_type
|
||||
for line in Setting.get("backup_custom_config", "").splitlines():
|
||||
line = line.strip()
|
||||
if not line or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip().upper().replace(" ", "_")
|
||||
if key:
|
||||
env[prefix + key] = value.strip()
|
||||
return env, f"{_REMOTE}:{custom_path}"
|
||||
|
||||
raise BackupError(f"Невідомий тип сховища: {remote_type!r}")
|
||||
|
||||
|
||||
def _rclone(args: list[str], env: dict, timeout: int) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
result = subprocess.run(["rclone", *args], env=env, capture_output=True, text=True, timeout=timeout)
|
||||
except FileNotFoundError as e:
|
||||
raise BackupError("Утиліту rclone не знайдено на сервері (потрібно: apt install rclone)") from e
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise BackupError(f"Команда rclone не відповіла за {timeout}с") from e
|
||||
if result.returncode != 0:
|
||||
raise BackupError((result.stderr or "").strip() or f"rclone завершився з кодом {result.returncode}")
|
||||
return result
|
||||
|
||||
|
||||
def test_connection() -> tuple[bool, str]:
|
||||
"""Verifies the configured remote's credentials work and the target
|
||||
path is reachable, without uploading anything."""
|
||||
try:
|
||||
env, remote_path = _remote_env_and_path()
|
||||
_rclone(["lsjson", remote_path], env, timeout=20)
|
||||
return True, f"Підключення працює — «{remote_path}» доступний."
|
||||
except BackupError as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
return False, f"Помилка rclone: {e}"
|
||||
|
||||
|
||||
def upload_backup(path: Path) -> None:
|
||||
"""Uploads a local backup file to the configured remote."""
|
||||
env, remote_path = _remote_env_and_path()
|
||||
_rclone(["copy", str(path), remote_path], env, timeout=300)
|
||||
|
||||
|
||||
def rotate_backups(name_contains: str) -> int:
|
||||
"""Deletes the oldest remote backups beyond backup_remote_retention_count,
|
||||
among files whose name contains `name_contains` — DB snapshots and
|
||||
uploads archives are rotated as separate groups (by their distinct
|
||||
filename patterns below) so uploading one kind never evicts the other
|
||||
out of retention. Returns how many were deleted."""
|
||||
env, remote_path = _remote_env_and_path()
|
||||
retention = max(Setting.get_int("backup_remote_retention_count", 14), 1)
|
||||
|
||||
result = _rclone(["lsjson", remote_path], env, timeout=30)
|
||||
try:
|
||||
entries = json.loads(result.stdout or "[]")
|
||||
except json.JSONDecodeError as e:
|
||||
raise BackupError(f"Не вдалося розібрати список файлів rclone: {e}") from e
|
||||
|
||||
matching = [e for e in entries if not e.get("IsDir") and name_contains in e.get("Name", "")]
|
||||
matching.sort(key=lambda e: e.get("ModTime", ""), reverse=True)
|
||||
stale = matching[retention:]
|
||||
for entry in stale:
|
||||
_rclone(["deletefile", f"{remote_path}/{entry['Name']}"], env, timeout=30)
|
||||
return len(stale)
|
||||
|
||||
|
||||
def create_local_backup() -> Path | None:
|
||||
"""Mirrors admin/system.py's local-backup logic — kept here instead of
|
||||
imported from there so this module (utils/) never depends on admin/."""
|
||||
uri = config.SQLALCHEMY_DATABASE_URI
|
||||
if not uri.startswith("sqlite:///"):
|
||||
return None
|
||||
sqlite_path = Path(uri.replace("sqlite:///", "", 1))
|
||||
if not sqlite_path.exists():
|
||||
return None
|
||||
backup_dir = BASE_DIR / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now(_tz).strftime("%Y-%m-%d_%H%M%S")
|
||||
dest = backup_dir / f"bober_bbq-{stamp}-backup.db"
|
||||
shutil.copy2(sqlite_path, dest)
|
||||
# copy2 preserves the SOURCE db's mtime — reset it to "now" so the
|
||||
# backups list shows when this snapshot was actually taken.
|
||||
os.utime(dest, None)
|
||||
|
||||
# A backup nobody can actually restore from is worse than no backup —
|
||||
# it looks like protection while quietly being useless. Verify the copy
|
||||
# itself before it's ever uploaded, not just that the copy succeeded.
|
||||
# The connection must be closed before any unlink attempt below — on
|
||||
# Windows an open sqlite3 connection holds a file lock that makes
|
||||
# unlink() raise PermissionError even after the exception is caught.
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(str(dest))
|
||||
result = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
except sqlite3.Error as e:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
dest.unlink(missing_ok=True)
|
||||
raise BackupError(f"Резервна копія пошкоджена (не вдалось відкрити): {e}")
|
||||
else:
|
||||
conn.close()
|
||||
if not result or result[0] != "ok":
|
||||
dest.unlink(missing_ok=True)
|
||||
raise BackupError(f"Резервна копія пошкоджена (integrity check: {result[0] if result else '?'})")
|
||||
|
||||
return dest
|
||||
|
||||
|
||||
def create_uploads_archive() -> Path | None:
|
||||
"""Zips bober_bbq/static/uploads/ (product photos) — these were never
|
||||
part of the backup before, so a DB restore alone couldn't bring a
|
||||
client's menu images back. Returns None if the folder is missing or
|
||||
has nothing but the .gitkeep placeholder in it."""
|
||||
uploads_dir = config.UPLOAD_FOLDER
|
||||
if not uploads_dir.is_dir():
|
||||
return None
|
||||
has_real_files = any(p.is_file() and p.name != ".gitkeep" for p in uploads_dir.rglob("*"))
|
||||
if not has_real_files:
|
||||
return None
|
||||
|
||||
backup_dir = BASE_DIR / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now(_tz).strftime("%Y-%m-%d_%H%M%S")
|
||||
archive_base = backup_dir / f"bober_bbq-uploads-{stamp}"
|
||||
archive_path = Path(shutil.make_archive(str(archive_base), "zip", root_dir=uploads_dir))
|
||||
return archive_path
|
||||
|
||||
|
||||
def backup_now() -> tuple[bool, str]:
|
||||
"""Backs up immediately, bypassing the interval check — used by the
|
||||
admin panel's "Backup зараз" button and by the scheduled job below."""
|
||||
try:
|
||||
local_path = create_local_backup()
|
||||
if local_path is None:
|
||||
return False, "Резервне копіювання зараз підтримується лише для SQLite."
|
||||
upload_backup(local_path)
|
||||
deleted = rotate_backups("-backup.db")
|
||||
|
||||
uploads_path = create_uploads_archive()
|
||||
if uploads_path is not None:
|
||||
upload_backup(uploads_path)
|
||||
deleted += rotate_backups("bober_bbq-uploads-")
|
||||
|
||||
Setting.set("backup_remote_last_backup_at", datetime.utcnow().isoformat())
|
||||
Setting.set("backup_remote_last_backup_status", "ok")
|
||||
db.session.commit()
|
||||
message = f"Резервну копію завантажено: {local_path.name}"
|
||||
if uploads_path is not None:
|
||||
message += f" + {uploads_path.name}"
|
||||
if deleted:
|
||||
message += f" (видалено {deleted} застарілих копій)"
|
||||
logger.info(message)
|
||||
return True, message
|
||||
except BackupError as e:
|
||||
Setting.set("backup_remote_last_backup_status", f"error: {e}")
|
||||
db.session.commit()
|
||||
logger.warning("Remote backup failed: %s", e)
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
Setting.set("backup_remote_last_backup_status", f"error: {e}")
|
||||
db.session.commit()
|
||||
logger.exception("Remote backup failed")
|
||||
return False, f"Помилка rclone: {e}"
|
||||
|
||||
|
||||
def run_scheduled_backup() -> None:
|
||||
"""Called periodically by APScheduler — only actually backs up once
|
||||
the configured interval has elapsed since the last successful run."""
|
||||
if not backup_remote_enabled():
|
||||
return
|
||||
|
||||
interval_hours = Setting.get_int("backup_remote_interval_hours", 24)
|
||||
last_raw = Setting.get("backup_remote_last_backup_at", "")
|
||||
if last_raw:
|
||||
try:
|
||||
last = datetime.fromisoformat(last_raw)
|
||||
if datetime.utcnow() - last < timedelta(hours=interval_hours):
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
ok, message = backup_now()
|
||||
if not ok:
|
||||
for owner_id in config.OWNER_IDS:
|
||||
send_message(
|
||||
owner_id,
|
||||
f"⚠️ <b>Не вдалося зробити резервну копію</b>\n\n{message}\n\n"
|
||||
"Перевірте підключення: Адмінка → Резервні копії.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
"""Automatic off-site database backups to Google Drive via a service
|
||||
account (no interactive OAuth — runs unattended forever), with rotation:
|
||||
once more than `gdrive_retention_count` backups exist in the target
|
||||
folder, the oldest ones are deleted.
|
||||
|
||||
Entirely opt-in via "gdrive_backup_enabled". The scheduled job in
|
||||
run_web.py calls run_scheduled_backup() frequently (e.g. hourly); this
|
||||
module itself decides whether the configured interval has actually
|
||||
elapsed, so changing the interval in the admin panel takes effect
|
||||
without restarting the process.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from bober_bbq.config import BASE_DIR, config
|
||||
from bober_bbq.extensions import db
|
||||
from bober_bbq.models import Setting
|
||||
from bober_bbq.utils.telegram_notify import send_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_tz = ZoneInfo(config.TIMEZONE)
|
||||
|
||||
DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive.file"]
|
||||
DRIVE_MIME = "application/octet-stream"
|
||||
|
||||
|
||||
class GDriveError(Exception):
|
||||
"""Raised for any configuration or API problem — the message is
|
||||
written straight into flash messages / owner notifications, so it's
|
||||
kept human-readable rather than a raw stack trace."""
|
||||
|
||||
|
||||
def gdrive_enabled() -> bool:
|
||||
return Setting.get_bool("gdrive_backup_enabled", False)
|
||||
|
||||
|
||||
def _credentials():
|
||||
from google.oauth2 import service_account
|
||||
|
||||
raw = Setting.get("gdrive_service_account_json", "").strip()
|
||||
if not raw:
|
||||
raise GDriveError("Не вказано ключ сервісного акаунта Google")
|
||||
try:
|
||||
info = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise GDriveError(f"Невалідний JSON ключа сервісного акаунта: {e}") from e
|
||||
try:
|
||||
return service_account.Credentials.from_service_account_info(info, scopes=DRIVE_SCOPES)
|
||||
except Exception as e:
|
||||
raise GDriveError(f"Не вдалося створити облікові дані з ключа: {e}") from e
|
||||
|
||||
|
||||
def _drive_service():
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
return build("drive", "v3", credentials=_credentials(), cache_discovery=False)
|
||||
|
||||
|
||||
def _folder_id() -> str:
|
||||
folder_id = Setting.get("gdrive_folder_id", "").strip()
|
||||
if not folder_id:
|
||||
raise GDriveError("Не вказано ID папки Google Drive")
|
||||
return folder_id
|
||||
|
||||
|
||||
def test_connection() -> tuple[bool, str]:
|
||||
"""Verifies the service account credentials work and the folder is
|
||||
reachable, without uploading anything."""
|
||||
try:
|
||||
service = _drive_service()
|
||||
folder_id = _folder_id()
|
||||
meta = service.files().get(fileId=folder_id, fields="id,name", supportsAllDrives=True).execute()
|
||||
return True, f"Підключення працює — папка «{meta.get('name', folder_id)}» доступна."
|
||||
except GDriveError as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
return False, f"Помилка Google Drive API: {e}"
|
||||
|
||||
|
||||
def upload_backup(path: Path) -> str:
|
||||
"""Uploads a local backup file to the configured folder, returns the
|
||||
created Drive file's id."""
|
||||
from googleapiclient.http import MediaFileUpload
|
||||
|
||||
service = _drive_service()
|
||||
media = MediaFileUpload(str(path), mimetype=DRIVE_MIME, resumable=False)
|
||||
created = (
|
||||
service.files()
|
||||
.create(body={"name": path.name, "parents": [_folder_id()]}, media_body=media, fields="id", supportsAllDrives=True)
|
||||
.execute()
|
||||
)
|
||||
return created["id"]
|
||||
|
||||
|
||||
def rotate_backups(name_contains: str) -> int:
|
||||
"""Deletes the oldest Drive backups beyond gdrive_retention_count,
|
||||
among files whose name contains `name_contains` — DB snapshots and
|
||||
uploads archives are rotated as separate groups (by their distinct
|
||||
filename patterns below) so uploading one kind never evicts the other
|
||||
out of retention. Returns how many were deleted."""
|
||||
service = _drive_service()
|
||||
retention = max(Setting.get_int("gdrive_retention_count", 14), 1)
|
||||
|
||||
files = (
|
||||
service.files()
|
||||
.list(
|
||||
q=f"'{_folder_id()}' in parents and trashed = false and name contains '{name_contains}'",
|
||||
orderBy="createdTime desc",
|
||||
fields="files(id,name,createdTime)",
|
||||
pageSize=200,
|
||||
supportsAllDrives=True,
|
||||
includeItemsFromAllDrives=True,
|
||||
)
|
||||
.execute()
|
||||
.get("files", [])
|
||||
)
|
||||
stale = files[retention:]
|
||||
for f in stale:
|
||||
service.files().delete(fileId=f["id"], supportsAllDrives=True).execute()
|
||||
return len(stale)
|
||||
|
||||
|
||||
def create_local_backup() -> Path | None:
|
||||
"""Mirrors admin/system.py's local-backup logic — kept here instead of
|
||||
imported from there so this module (utils/) never depends on admin/."""
|
||||
uri = config.SQLALCHEMY_DATABASE_URI
|
||||
if not uri.startswith("sqlite:///"):
|
||||
return None
|
||||
sqlite_path = Path(uri.replace("sqlite:///", "", 1))
|
||||
if not sqlite_path.exists():
|
||||
return None
|
||||
backup_dir = BASE_DIR / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now(_tz).strftime("%Y-%m-%d_%H%M%S")
|
||||
dest = backup_dir / f"bober_bbq-{stamp}-gdrive.db"
|
||||
shutil.copy2(sqlite_path, dest)
|
||||
# copy2 preserves the SOURCE db's mtime — reset it to "now" so the
|
||||
# backups list shows when this snapshot was actually taken.
|
||||
os.utime(dest, None)
|
||||
|
||||
# A backup nobody can actually restore from is worse than no backup —
|
||||
# it looks like protection while quietly being useless. Verify the copy
|
||||
# itself before it's ever uploaded, not just that the copy succeeded.
|
||||
# The connection must be closed before any unlink attempt below — on
|
||||
# Windows an open sqlite3 connection holds a file lock that makes
|
||||
# unlink() raise PermissionError even after the exception is caught.
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(str(dest))
|
||||
result = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
except sqlite3.Error as e:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
dest.unlink(missing_ok=True)
|
||||
raise GDriveError(f"Резервна копія пошкоджена (не вдалось відкрити): {e}")
|
||||
else:
|
||||
conn.close()
|
||||
if not result or result[0] != "ok":
|
||||
dest.unlink(missing_ok=True)
|
||||
raise GDriveError(f"Резервна копія пошкоджена (integrity check: {result[0] if result else '?'})")
|
||||
|
||||
return dest
|
||||
|
||||
|
||||
def create_uploads_archive() -> Path | None:
|
||||
"""Zips bober_bbq/static/uploads/ (product photos) — these were never
|
||||
part of the Drive backup before, so a DB restore alone couldn't bring
|
||||
a client's menu images back. Returns None if the folder is missing or
|
||||
has nothing but the .gitkeep placeholder in it."""
|
||||
uploads_dir = config.UPLOAD_FOLDER
|
||||
if not uploads_dir.is_dir():
|
||||
return None
|
||||
has_real_files = any(p.is_file() and p.name != ".gitkeep" for p in uploads_dir.rglob("*"))
|
||||
if not has_real_files:
|
||||
return None
|
||||
|
||||
backup_dir = BASE_DIR / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now(_tz).strftime("%Y-%m-%d_%H%M%S")
|
||||
archive_base = backup_dir / f"bober_bbq-uploads-{stamp}"
|
||||
archive_path = Path(shutil.make_archive(str(archive_base), "zip", root_dir=uploads_dir))
|
||||
return archive_path
|
||||
|
||||
|
||||
def backup_now() -> tuple[bool, str]:
|
||||
"""Backs up immediately, bypassing the interval check — used by the
|
||||
admin panel's "Backup зараз" button and by the scheduled job below."""
|
||||
try:
|
||||
local_path = create_local_backup()
|
||||
if local_path is None:
|
||||
return False, "Резервне копіювання в Google Drive підтримується лише для SQLite."
|
||||
upload_backup(local_path)
|
||||
deleted = rotate_backups("-gdrive.db")
|
||||
|
||||
uploads_path = create_uploads_archive()
|
||||
if uploads_path is not None:
|
||||
upload_backup(uploads_path)
|
||||
deleted += rotate_backups("bober_bbq-uploads-")
|
||||
|
||||
Setting.set("gdrive_last_backup_at", datetime.utcnow().isoformat())
|
||||
Setting.set("gdrive_last_backup_status", "ok")
|
||||
db.session.commit()
|
||||
message = f"Резервну копію завантажено в Google Drive: {local_path.name}"
|
||||
if uploads_path is not None:
|
||||
message += f" + {uploads_path.name}"
|
||||
if deleted:
|
||||
message += f" (видалено {deleted} застарілих копій)"
|
||||
logger.info(message)
|
||||
return True, message
|
||||
except GDriveError as e:
|
||||
Setting.set("gdrive_last_backup_status", f"error: {e}")
|
||||
db.session.commit()
|
||||
logger.warning("Google Drive backup failed: %s", e)
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
Setting.set("gdrive_last_backup_status", f"error: {e}")
|
||||
db.session.commit()
|
||||
logger.exception("Google Drive backup failed")
|
||||
return False, f"Помилка Google Drive API: {e}"
|
||||
|
||||
|
||||
def run_scheduled_backup() -> None:
|
||||
"""Called periodically by APScheduler — only actually backs up once
|
||||
the configured interval has elapsed since the last successful run."""
|
||||
if not gdrive_enabled():
|
||||
return
|
||||
|
||||
interval_hours = Setting.get_int("gdrive_backup_interval_hours", 24)
|
||||
last_raw = Setting.get("gdrive_last_backup_at", "")
|
||||
if last_raw:
|
||||
try:
|
||||
last = datetime.fromisoformat(last_raw)
|
||||
if datetime.utcnow() - last < timedelta(hours=interval_hours):
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
ok, message = backup_now()
|
||||
if not ok:
|
||||
for owner_id in config.OWNER_IDS:
|
||||
send_message(
|
||||
owner_id,
|
||||
f"⚠️ <b>Не вдалося зробити резервну копію в Google Drive</b>\n\n{message}\n\n"
|
||||
"Перевірте підключення: Адмінка → Резервні копії.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
|
@ -21,13 +21,17 @@
|
|||
## 1. Підготовка сервера
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y python3.11 python3.11-venv nginx certbot python3-certbot-nginx nodejs npm fonts-dejavu-core
|
||||
sudo apt update && sudo apt install -y python3.11 python3.11-venv nginx certbot python3-certbot-nginx nodejs npm fonts-dejavu-core rclone
|
||||
```
|
||||
|
||||
`fonts-dejavu-core` — потрібен лише для PDF-відомостей постачальників
|
||||
(кирилична TTF-шрифтова пара для `fpdf2`, `bober_bbq/utils/supplier_reports.py`).
|
||||
Excel-відомості й решта застосунку без нього працюють нормально.
|
||||
|
||||
`rclone` — потрібен лише для автоматичних резервних копій у хмару
|
||||
(`bober_bbq/utils/backup_remote.py`, налаштовується в Адмінка → Резервні
|
||||
копії). Локальні бекапи/відновлення на цій сторінці працюють і без нього.
|
||||
|
||||
## 2. Код і залежності
|
||||
|
||||
```bash
|
||||
|
|
@ -146,15 +150,26 @@ sudo systemctl enable --now <SERVICE_WEB> <SERVICE_BOT>
|
|||
|
||||
## 7. Резервне копіювання
|
||||
|
||||
SQLite: досить копіювати файл `instance/bober_bbq.db` за розкладом (cron):
|
||||
Основний спосіб — вбудована система в самій адмінці (Адмінка → Резервні
|
||||
копії): періодично (не cron, а внутрішній APScheduler-job в `run_web.py`,
|
||||
щогодини перевіряє, чи настав час) знімає копію SQLite-бази і `static/
|
||||
uploads/` (фото товарів), заливає обране сховище через `rclone` і
|
||||
ротує старі копії — усе налаштовується прямо у формі (Google Drive або
|
||||
S3-сумісне сховище: AWS S3 / Backblaze B2 / Wasabi / MinIO / інше), без
|
||||
жодного `rclone.conf` на диску сервера. Дивись
|
||||
`bober_bbq/utils/backup_remote.py`. Вимагає встановленого `rclone`
|
||||
(крок 1 вище).
|
||||
|
||||
Резервний варіант без хмари — просте копіювання файлу за розкладом (cron):
|
||||
|
||||
```bash
|
||||
0 3 * * * cp $INSTALL_DIR/instance/bober_bbq.db /opt/backups/bober_bbq-$(date +\%F).db
|
||||
```
|
||||
|
||||
Postgres: стандартний `pg_dump` за розкладом. Не забудьте також бекапити
|
||||
`bober_bbq/static/uploads/` (фото товарів) — БД зберігає лише посилання
|
||||
на файли.
|
||||
Postgres: стандартний `pg_dump` за розкладом (вбудована система в адмінці
|
||||
поки що підтримує лише SQLite — див. TODO нижче). Не забудьте також
|
||||
бекапити `bober_bbq/static/uploads/` (фото товарів) — БД зберігає лише
|
||||
посилання на файли.
|
||||
|
||||
## 8. Оновлення коду
|
||||
|
||||
|
|
@ -298,13 +313,13 @@ sudo systemctl start <SERVICE_WEB> <SERVICE_BOT>
|
|||
|
||||
### ⚠️ TODO перед реальним переходом: автоматичні бекапи не підтримують Postgres
|
||||
|
||||
Уся наявна інфраструктура резервного копіювання (планові бекапи в Google
|
||||
Drive + кнопки «Backup зараз» / «Відновити» в адмінці) жорстко зав'язана
|
||||
Уся наявна інфраструктура резервного копіювання (планові бекапи через
|
||||
rclone + кнопки «Backup зараз» / «Відновити» в адмінці) жорстко зав'язана
|
||||
на SQLite і **мовчки (або з явним попередженням) вимикається**, якщо
|
||||
`DATABASE_URL` вказує на Postgres. Це не гіпотетична проблема — обидва
|
||||
місця вже сьогодні перевіряють тип БД і відмовляються працювати:
|
||||
|
||||
- **`bober_bbq/utils/gdrive_backup.py`, `create_local_backup()`** —
|
||||
- **`bober_bbq/utils/backup_remote.py`, `create_local_backup()`** —
|
||||
перевіряє `config.SQLALCHEMY_DATABASE_URI.startswith("sqlite:///")` і,
|
||||
якщо ні, просто повертає `None` — плановий бекап (`run_scheduled_backup`,
|
||||
раз на годину через APScheduler у `run_web.py`) тихо нічого не робить.
|
||||
|
|
@ -322,14 +337,14 @@ Drive + кнопки «Backup зараз» / «Відновити» в адмі
|
|||
|
||||
**Що конкретно треба зробити, коли дійде до реального переходу:**
|
||||
|
||||
1. У `gdrive_backup.py` додати гілку для Postgres поруч із наявною
|
||||
1. У `backup_remote.py` додати гілку для Postgres поруч із наявною
|
||||
SQLite-гілкою в `create_local_backup()`: викликати `pg_dump` у
|
||||
форматі custom (`pg_dump -Fc`, а не звичайний SQL-дамп — компактніше
|
||||
і відновлюється через `pg_restore`, без ручного `psql < file.sql`).
|
||||
Результат — файл на диску, той самий контракт, що й зараз
|
||||
(`Path | None`), щоб решта пайплайна (завантаження в Drive,
|
||||
(`Path | None`), щоб решта пайплайна (завантаження через rclone,
|
||||
`rotate_backups()`) запрацювала без змін.
|
||||
2. Додати перевірку цілісності дампу перед завантаженням у Drive —
|
||||
2. Додати перевірку цілісності дампу перед завантаженням —
|
||||
Postgres-аналог наявного `PRAGMA integrity_check` для SQLite. Найпростіший
|
||||
варіант: `pg_restore --list <файл>` на щойно створеному дампі — команда
|
||||
завершується з помилкою, якщо файл пошкоджений/обрізаний, не
|
||||
|
|
|
|||
|
|
@ -61,9 +61,10 @@ explicitly reset between tests — see `_reset_module_level_state()` in
|
|||
only the *webhook handler's* payment-status logic is tested, with
|
||||
signature verification monkeypatched out. Testing the actual HTTP calls
|
||||
would need a fake Monobank server.
|
||||
- **Google Drive backups** (`bober_bbq/utils/gdrive_backup.py`) — needs a
|
||||
real (or mocked) Google service account and Drive API, neither of which
|
||||
is set up for tests.
|
||||
- **Remote backups' actual cloud calls** (`bober_bbq/utils/backup_remote.py`)
|
||||
— `tests/test_backup_remote.py` covers the env/path construction per
|
||||
storage type and the rotation logic with `subprocess.run` mocked, but
|
||||
never shells out to a real `rclone` binary or a real cloud provider.
|
||||
- **The scheduled jobs' timing/APScheduler wiring itself** in `run_web.py`
|
||||
(only the job *functions* they call are tested directly, e.g.
|
||||
`reconcile_pending_payments()`).
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
# that runbook by hand.
|
||||
#
|
||||
# Prerequisite (docs/DEPLOYMENT.md step 1, one-time OS setup, not run by
|
||||
# this script): python3.11, nginx, certbot, nodejs/npm already installed
|
||||
# on this server.
|
||||
# this script): python3.11, nginx, certbot, nodejs/npm, rclone already
|
||||
# installed on this server.
|
||||
#
|
||||
# Never touches an existing deployment: refuses to run if --install-dir
|
||||
# already exists, and every irreversible/system-level step (nginx reload,
|
||||
|
|
@ -206,7 +206,8 @@ cat <<EOF
|
|||
6. Адмінка -> Меню: видалити демо-меню (Шашлик/BBQ), додати асортимент клієнта.
|
||||
7. Зареєструвати https://${DOMAIN}/health в UptimeRobot (чи аналог) — вручну,
|
||||
акаунт створює власник бізнесу, не цей скрипт.
|
||||
8. Резервні копії: Адмінка -> Резервні копії -> увімкнути Google Drive backup
|
||||
(або окремий cron — docs/DEPLOYMENT.md, крок 7).
|
||||
8. Резервні копії: Адмінка -> Резервні копії -> налаштувати сховище (Google
|
||||
Drive або S3-сумісне) і увімкнути (або окремий cron — docs/DEPLOYMENT.md,
|
||||
крок 7).
|
||||
|
||||
EOF
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ cryptography>=42.0
|
|||
Pillow>=10.0
|
||||
openpyxl>=3.1,<4
|
||||
xlrd>=2.0,<3
|
||||
google-api-python-client>=2.100,<3
|
||||
google-auth>=2.23,<3
|
||||
google-auth-httplib2>=0.2,<1
|
||||
fpdf2>=2.7,<3
|
||||
# Off-site backups (bober_bbq/utils/backup_remote.py) shell out to the
|
||||
# `rclone` binary — install it on the OS, not here (see docs/DEPLOYMENT.md).
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ from bober_bbq.migrate import migrate
|
|||
from bober_bbq.models import Setting
|
||||
from bober_bbq.seed import seed
|
||||
from bober_bbq.utils import error_alerts
|
||||
from bober_bbq.utils.backup_remote import run_scheduled_backup
|
||||
from bober_bbq.utils.checkbox_prro import maybe_close_shift
|
||||
from bober_bbq.utils.gdrive_backup import run_scheduled_backup
|
||||
from bober_bbq.utils.reconciliation import (
|
||||
check_liqpay_token_health,
|
||||
check_monobank_token_health,
|
||||
|
|
@ -95,10 +95,10 @@ if __name__ == "__main__":
|
|||
id="liqpay_token_health",
|
||||
)
|
||||
scheduler.add_job(
|
||||
lambda: _run_in_app_context(run_scheduled_backup, "gdrive_backup"),
|
||||
lambda: _run_in_app_context(run_scheduled_backup, "backup_remote"),
|
||||
"interval",
|
||||
hours=1,
|
||||
id="gdrive_backup",
|
||||
id="backup_remote",
|
||||
)
|
||||
scheduler.add_job(
|
||||
lambda: _run_in_app_context(maybe_close_shift, "checkbox_auto_close_shift"),
|
||||
|
|
|
|||
342
tests/test_backup_remote.py
Normal file
342
tests/test_backup_remote.py
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
"""Off-site backups via rclone (bober_bbq/utils/backup_remote.py) — env/path
|
||||
construction per storage type, rotation logic, and the admin settings
|
||||
routes. `subprocess.run` is mocked throughout: there's no real rclone
|
||||
binary or cloud credentials in the test environment, by design (see
|
||||
docs/TESTING.md).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from bober_bbq.config import config
|
||||
from bober_bbq.extensions import db
|
||||
from bober_bbq.models import Setting
|
||||
from bober_bbq.utils import backup_remote
|
||||
|
||||
|
||||
def _admin_login(client, app):
|
||||
with app.app_context():
|
||||
username, password = config.FLASK_ADMIN_USERNAME, config.FLASK_ADMIN_PASSWORD
|
||||
return client.post("/admin/login", data={"username": username, "password": password}, follow_redirects=True)
|
||||
|
||||
|
||||
def _completed(returncode=0, stdout="", stderr=""):
|
||||
return subprocess.CompletedProcess(args=["rclone"], returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _remote_env_and_path() — per storage type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gdrive_env_and_path(app):
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "gdrive")
|
||||
Setting.set("backup_gdrive_service_account_json", '{"type": "service_account"}')
|
||||
Setting.set("backup_gdrive_folder_id", "FOLDER123")
|
||||
db.session.commit()
|
||||
|
||||
env, path = backup_remote._remote_env_and_path()
|
||||
assert env["RCLONE_CONFIG_BACKUP_TYPE"] == "drive"
|
||||
assert env["RCLONE_CONFIG_BACKUP_SERVICE_ACCOUNT_CREDENTIALS"] == '{"type": "service_account"}'
|
||||
assert env["RCLONE_CONFIG_BACKUP_ROOT_FOLDER_ID"] == "FOLDER123"
|
||||
assert path == "backup:"
|
||||
|
||||
|
||||
def test_s3_env_and_path(app):
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "s3")
|
||||
Setting.set("backup_s3_provider", "Other")
|
||||
Setting.set("backup_s3_access_key_id", "AKIA123")
|
||||
Setting.set("backup_s3_secret_access_key", "secret")
|
||||
Setting.set("backup_s3_endpoint", "s3.example.com")
|
||||
Setting.set("backup_s3_region", "eu-central-1")
|
||||
Setting.set("backup_s3_bucket", "mybucket")
|
||||
Setting.set("backup_s3_prefix", "backups")
|
||||
db.session.commit()
|
||||
|
||||
env, path = backup_remote._remote_env_and_path()
|
||||
assert env["RCLONE_CONFIG_BACKUP_TYPE"] == "s3"
|
||||
assert env["RCLONE_CONFIG_BACKUP_PROVIDER"] == "Other"
|
||||
assert env["RCLONE_CONFIG_BACKUP_ACCESS_KEY_ID"] == "AKIA123"
|
||||
assert env["RCLONE_CONFIG_BACKUP_SECRET_ACCESS_KEY"] == "secret"
|
||||
assert env["RCLONE_CONFIG_BACKUP_ENDPOINT"] == "s3.example.com"
|
||||
assert env["RCLONE_CONFIG_BACKUP_REGION"] == "eu-central-1"
|
||||
assert path == "backup:mybucket/backups"
|
||||
|
||||
|
||||
def test_s3_blank_endpoint_omitted_for_real_aws(app):
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "s3")
|
||||
Setting.set("backup_s3_access_key_id", "AKIA123")
|
||||
Setting.set("backup_s3_secret_access_key", "secret")
|
||||
Setting.set("backup_s3_bucket", "mybucket")
|
||||
Setting.set("backup_s3_endpoint", "")
|
||||
db.session.commit()
|
||||
|
||||
env, path = backup_remote._remote_env_and_path()
|
||||
assert "RCLONE_CONFIG_BACKUP_ENDPOINT" not in env
|
||||
assert path == "backup:mybucket"
|
||||
|
||||
|
||||
def test_custom_env_and_path(app):
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "custom")
|
||||
Setting.set("backup_custom_type", "sftp")
|
||||
Setting.set("backup_custom_config", "host = example.com\nuser = deploy\n\npass = secret")
|
||||
Setting.set("backup_custom_path", "srv/backups")
|
||||
db.session.commit()
|
||||
|
||||
env, path = backup_remote._remote_env_and_path()
|
||||
assert env["RCLONE_CONFIG_BACKUP_TYPE"] == "sftp"
|
||||
assert env["RCLONE_CONFIG_BACKUP_HOST"] == "example.com"
|
||||
assert env["RCLONE_CONFIG_BACKUP_USER"] == "deploy"
|
||||
assert env["RCLONE_CONFIG_BACKUP_PASS"] == "secret"
|
||||
assert path == "backup:srv/backups"
|
||||
|
||||
|
||||
def test_empty_remote_type_raises_before_any_subprocess_call(app, monkeypatch):
|
||||
called = []
|
||||
monkeypatch.setattr(backup_remote.subprocess, "run", lambda *a, **kw: called.append(1))
|
||||
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "")
|
||||
db.session.commit()
|
||||
with pytest.raises(backup_remote.BackupError):
|
||||
backup_remote._remote_env_and_path()
|
||||
|
||||
assert not called, "must fail fast without ever invoking rclone"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rotate_backups()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rotate_backups_deletes_only_oldest_excess_of_matching_group(app, monkeypatch):
|
||||
listing = [
|
||||
{"Name": "bober_bbq-2026-01-01_000000-backup.db", "ModTime": "2026-01-01T00:00:00Z", "IsDir": False},
|
||||
{"Name": "bober_bbq-2026-01-02_000000-backup.db", "ModTime": "2026-01-02T00:00:00Z", "IsDir": False},
|
||||
{"Name": "bober_bbq-2026-01-03_000000-backup.db", "ModTime": "2026-01-03T00:00:00Z", "IsDir": False},
|
||||
# Unrelated group — must never be touched by a "-backup.db" rotation.
|
||||
{"Name": "bober_bbq-uploads-2026-01-01_000000.zip", "ModTime": "2026-01-01T00:00:00Z", "IsDir": False},
|
||||
]
|
||||
import json as json_module
|
||||
|
||||
deleted = []
|
||||
|
||||
def fake_run(cmd, env, capture_output, text, timeout):
|
||||
if cmd[1] == "lsjson":
|
||||
return _completed(stdout=json_module.dumps(listing))
|
||||
if cmd[1] == "deletefile":
|
||||
deleted.append(cmd[2])
|
||||
return _completed()
|
||||
raise AssertionError(f"unexpected rclone call: {cmd}")
|
||||
|
||||
monkeypatch.setattr(backup_remote.subprocess, "run", fake_run)
|
||||
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "s3")
|
||||
Setting.set("backup_s3_access_key_id", "k")
|
||||
Setting.set("backup_s3_secret_access_key", "s")
|
||||
Setting.set("backup_s3_bucket", "b")
|
||||
Setting.set("backup_remote_retention_count", "2")
|
||||
db.session.commit()
|
||||
|
||||
deleted_count = backup_remote.rotate_backups("-backup.db")
|
||||
|
||||
assert deleted_count == 1
|
||||
assert deleted == ["backup:b/bober_bbq-2026-01-01_000000-backup.db"]
|
||||
|
||||
|
||||
def test_upload_backup_invokes_rclone_copy(app, monkeypatch, tmp_path):
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, env, capture_output, text, timeout):
|
||||
calls.append(cmd)
|
||||
return _completed()
|
||||
|
||||
monkeypatch.setattr(backup_remote.subprocess, "run", fake_run)
|
||||
|
||||
local_file = tmp_path / "snapshot.db"
|
||||
local_file.write_text("x")
|
||||
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "s3")
|
||||
Setting.set("backup_s3_access_key_id", "k")
|
||||
Setting.set("backup_s3_secret_access_key", "s")
|
||||
Setting.set("backup_s3_bucket", "b")
|
||||
db.session.commit()
|
||||
backup_remote.upload_backup(local_file)
|
||||
|
||||
assert calls == [["rclone", "copy", str(local_file), "backup:b"]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_connection()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connection_failure_returns_stderr_message(app, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
backup_remote.subprocess, "run", lambda *a, **kw: _completed(returncode=1, stderr="access denied")
|
||||
)
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "s3")
|
||||
Setting.set("backup_s3_access_key_id", "k")
|
||||
Setting.set("backup_s3_secret_access_key", "s")
|
||||
Setting.set("backup_s3_bucket", "b")
|
||||
db.session.commit()
|
||||
ok, message = backup_remote.test_connection()
|
||||
assert ok is False
|
||||
assert "access denied" in message
|
||||
|
||||
|
||||
def test_connection_success(app, monkeypatch):
|
||||
monkeypatch.setattr(backup_remote.subprocess, "run", lambda *a, **kw: _completed(returncode=0, stdout="[]"))
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "s3")
|
||||
Setting.set("backup_s3_access_key_id", "k")
|
||||
Setting.set("backup_s3_secret_access_key", "s")
|
||||
Setting.set("backup_s3_bucket", "b")
|
||||
db.session.commit()
|
||||
ok, message = backup_remote.test_connection()
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_missing_rclone_binary_is_a_clean_error(app, monkeypatch):
|
||||
def fake_run(*a, **kw):
|
||||
raise FileNotFoundError("no such file")
|
||||
|
||||
monkeypatch.setattr(backup_remote.subprocess, "run", fake_run)
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "s3")
|
||||
Setting.set("backup_s3_access_key_id", "k")
|
||||
Setting.set("backup_s3_secret_access_key", "s")
|
||||
Setting.set("backup_s3_bucket", "b")
|
||||
db.session.commit()
|
||||
ok, message = backup_remote.test_connection()
|
||||
assert ok is False
|
||||
assert "apt install rclone" in message
|
||||
|
||||
|
||||
def test_rclone_timeout_is_a_clean_error(app, monkeypatch):
|
||||
def fake_run(*a, **kw):
|
||||
raise subprocess.TimeoutExpired(cmd="rclone", timeout=20)
|
||||
|
||||
monkeypatch.setattr(backup_remote.subprocess, "run", fake_run)
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "s3")
|
||||
Setting.set("backup_s3_access_key_id", "k")
|
||||
Setting.set("backup_s3_secret_access_key", "s")
|
||||
Setting.set("backup_s3_bucket", "b")
|
||||
db.session.commit()
|
||||
ok, message = backup_remote.test_connection()
|
||||
assert ok is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_remote_settings_route_persists_s3_fields(app):
|
||||
client = app.test_client()
|
||||
_admin_login(client, app)
|
||||
|
||||
resp = client.post(
|
||||
"/admin/backups/remote-settings",
|
||||
data={
|
||||
"backup_remote_enabled": "on",
|
||||
"backup_remote_type": "s3",
|
||||
"backup_remote_interval_hours": "12",
|
||||
"backup_remote_retention_count": "7",
|
||||
"backup_s3_provider": "Wasabi",
|
||||
"backup_s3_access_key_id": "AKIA1",
|
||||
"backup_s3_secret_access_key": "secret1",
|
||||
"backup_s3_endpoint": "s3.wasabisys.com",
|
||||
"backup_s3_region": "",
|
||||
"backup_s3_bucket": "mybucket",
|
||||
"backup_s3_prefix": "",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
with app.app_context():
|
||||
assert Setting.get("backup_remote_enabled") == "1"
|
||||
assert Setting.get("backup_remote_type") == "s3"
|
||||
assert Setting.get("backup_remote_interval_hours") == "12"
|
||||
assert Setting.get("backup_s3_provider") == "Wasabi"
|
||||
assert Setting.get("backup_s3_access_key_id") == "AKIA1"
|
||||
assert Setting.get("backup_s3_bucket") == "mybucket"
|
||||
|
||||
|
||||
def test_switching_remote_type_does_not_wipe_other_types_values(app):
|
||||
client = app.test_client()
|
||||
_admin_login(client, app)
|
||||
|
||||
client.post(
|
||||
"/admin/backups/remote-settings",
|
||||
data={
|
||||
"backup_remote_type": "gdrive",
|
||||
"backup_gdrive_service_account_json": '{"a": 1}',
|
||||
"backup_gdrive_folder_id": "FID",
|
||||
"backup_s3_access_key_id": "",
|
||||
"backup_s3_secret_access_key": "",
|
||||
"backup_s3_bucket": "",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
client.post(
|
||||
"/admin/backups/remote-settings",
|
||||
data={
|
||||
"backup_remote_type": "s3",
|
||||
# A real form posts every field every time (hidden inputs still
|
||||
# submit) — the gdrive fields ride along unchanged here.
|
||||
"backup_gdrive_service_account_json": '{"a": 1}',
|
||||
"backup_gdrive_folder_id": "FID",
|
||||
"backup_s3_access_key_id": "AKIA2",
|
||||
"backup_s3_secret_access_key": "secret2",
|
||||
"backup_s3_bucket": "otherbucket",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
with app.app_context():
|
||||
assert Setting.get("backup_remote_type") == "s3"
|
||||
assert Setting.get("backup_gdrive_service_account_json") == '{"a": 1}'
|
||||
assert Setting.get("backup_gdrive_folder_id") == "FID"
|
||||
assert Setting.get("backup_s3_access_key_id") == "AKIA2"
|
||||
|
||||
|
||||
def test_remote_test_with_no_type_configured_fails_fast(app, monkeypatch):
|
||||
called = []
|
||||
monkeypatch.setattr(backup_remote.subprocess, "run", lambda *a, **kw: called.append(1))
|
||||
|
||||
client = app.test_client()
|
||||
_admin_login(client, app)
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_type", "")
|
||||
db.session.commit()
|
||||
|
||||
resp = client.post("/admin/backups/remote-test", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert not called, "must not shell out to rclone when nothing is configured"
|
||||
|
||||
|
||||
def test_remote_backup_now_requires_enabled_flag(app, monkeypatch):
|
||||
called = []
|
||||
monkeypatch.setattr(backup_remote.subprocess, "run", lambda *a, **kw: called.append(1))
|
||||
|
||||
client = app.test_client()
|
||||
_admin_login(client, app)
|
||||
with app.app_context():
|
||||
Setting.set("backup_remote_enabled", "0")
|
||||
db.session.commit()
|
||||
|
||||
resp = client.post("/admin/backups/remote-backup-now", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert "Спершу увімкніть" in resp.get_data(as_text=True)
|
||||
assert not called
|
||||
Loading…
Add table
Reference in a new issue