Compare commits

...

2 commits

Author SHA1 Message Date
375022e739 Add backup_remote.py and wire up the rest of the rclone rework
Continuation of the previous commit (gdrive_backup.py removal landed
separately by accident) — adds the new module itself, the admin
routes/template using it, generalized Setting keys, updated docs/
provisioning notes, and tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 22:57:59 +03:00
f53a142cf4 Rework backups to use rclone, configurable entirely in the admin panel
The backup system was hard-wired to Google Drive via googleapiclient and a
service account — replaced with rclone (invoked as an external binary via
subprocess, credentials passed as RCLONE_CONFIG_BACKUP_* env vars, never
written to a config file on disk) so the destination isn't locked to one
provider. The admin panel now has a storage-type picker (Google Drive / S3-
compatible / custom) with real form fields for each — no rclone.conf to
paste in, matching the same non-technical-friendly spirit as the previous
Google-only settings form, just generalized.

bober_bbq/utils/gdrive_backup.py -> bober_bbq/utils/backup_remote.py;
Setting keys generalized from gdrive_* to backup_*/backup_remote_* (clean
replacement, no migration needed — confirmed via production that Google
Drive backup was never enabled there). Local backup create/list/download/
delete/restore is untouched. Removes the now-unused google-api-python-
client/google-auth/google-auth-httplib2 dependencies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 22:57:23 +03:00
13 changed files with 893 additions and 353 deletions

View file

@ -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-сумісне / інше,
з покроковою інструкцією налаштування прямо на сторінці для кожного).
- Облік сировини: перемикач «Увімкнути облік сировини», нижче в тому
ж розділі — при увімкненні в меню з'являється «Склад».

View file

@ -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"))

View file

@ -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",

View file

@ -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 ---------- */

View file

@ -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 &amp; Services → Library).</li>
<li>Створіть сервісний акаунт: IAM &amp; 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 = ...&#10;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 &amp; Services → Library).</li>
<li>Створіть сервісний акаунт: IAM &amp; 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() }}">

View 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",
)

View file

@ -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",
)

View file

@ -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 <файл>` на щойно створеному дампі — команда
завершується з помилкою, якщо файл пошкоджений/обрізаний, не

View file

@ -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()`).

View file

@ -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

View file

@ -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).

View file

@ -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
View 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