diff --git a/bober_bbq/app.py b/bober_bbq/app.py
index d0ba55a..a134318 100644
--- a/bober_bbq/app.py
+++ b/bober_bbq/app.py
@@ -3,7 +3,7 @@ import logging
import os
from pathlib import Path
-from flask import Flask, Response, jsonify, render_template_string, request, send_from_directory
+from flask import Flask, Response, got_request_exception, jsonify, render_template_string, request, send_from_directory
from flask_wtf.csrf import CSRFProtect, generate_csrf
from markupsafe import Markup
from werkzeug.middleware.proxy_fix import ProxyFix
@@ -19,6 +19,7 @@ from bober_bbq.api.settings import settings_bp
from bober_bbq.config import config
from bober_bbq.extensions import db, login_manager
from bober_bbq.models import AdminUser, Setting
+from bober_bbq.utils import error_alerts
csrf = CSRFProtect()
@@ -150,6 +151,24 @@ def create_app() -> Flask:
app.register_blueprint(api_bp)
app.register_blueprint(admin_bp)
+ def _on_request_exception(sender, exception, **extra):
+ # Fires for any exception that reaches Flask's default error
+ # handling — i.e. a genuine unhandled bug, not a routine 404/403/
+ # login-redirect (those are HTTPException and never get here).
+ # Purely observational: doesn't touch the response, so the actual
+ # error page shown to the user is unchanged.
+ from werkzeug.exceptions import HTTPException
+
+ if isinstance(exception, HTTPException):
+ return
+ error_alerts.notify(f"{request.method} {request.path}", exception)
+
+ # weak=False — this is a local closure with no other reference kept
+ # anywhere; blinker's default weak reference would let it get garbage
+ # collected right after create_app() returns, silently disabling the
+ # connection (confirmed via a smoke test before adding this).
+ got_request_exception.connect(_on_request_exception, app, weak=False)
+
@app.after_request
def prevent_stale_shell_caching(response):
path = request.path
diff --git a/bober_bbq/utils/error_alerts.py b/bober_bbq/utils/error_alerts.py
new file mode 100644
index 0000000..fcf4199
--- /dev/null
+++ b/bober_bbq/utils/error_alerts.py
@@ -0,0 +1,47 @@
+"""Alerts the owner(s) in Telegram when something breaks unattended — an
+unhandled Flask request error or a crashed background job. Both call the
+same `notify()` so there's one cooldown mechanism, not two.
+
+Before this, a failure here was visible only in the server's log file —
+finding out about a live 500 meant someone noticing and saying something,
+or an admin manually grepping `/var/log/bober-bbq-web.log` over SSH.
+"""
+
+import logging
+import time
+
+from bober_bbq.config import config
+from bober_bbq.utils.telegram_notify import send_message
+
+logger = logging.getLogger(__name__)
+
+# One alert per (exception type, source) combination per window — a bug
+# that fires on every request/job tick shouldn't flood the chat.
+_COOLDOWN_SECONDS = 15 * 60
+_last_sent: dict[tuple[str, str], float] = {}
+
+
+def notify(source: str, exc: BaseException) -> None:
+ """`source` is a short label identifying where this broke — a request
+ path (e.g. "POST /admin/suppliers/import") or a scheduled job name
+ (e.g. "reconcile_payments")."""
+ key = (type(exc).__name__, source)
+ now = time.monotonic()
+ last = _last_sent.get(key)
+ if last is not None and now - last < _COOLDOWN_SECONDS:
+ return
+ _last_sent[key] = now
+
+ text = (
+ f"🔥 Помилка в застосунку\n\n"
+ f"Де: {source}\n"
+ f"Тип: {type(exc).__name__}\n"
+ f"Повідомлення: {str(exc)[:300]}\n\n"
+ f"Деталі — в логах сервера (Адмінка → Система)."
+ )
+ for owner_id in config.OWNER_IDS:
+ try:
+ send_message(owner_id, text, parse_mode="HTML")
+ except Exception:
+ # Never let the act of reporting a failure cause another one.
+ logger.exception("Failed to send error alert to owner %s", owner_id)
diff --git a/bober_bbq/utils/gdrive_backup.py b/bober_bbq/utils/gdrive_backup.py
index a6cb620..829a5c2 100644
--- a/bober_bbq/utils/gdrive_backup.py
+++ b/bober_bbq/utils/gdrive_backup.py
@@ -14,6 +14,7 @@ import json
import logging
import os
import shutil
+import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
@@ -98,16 +99,19 @@ def upload_backup(path: Path) -> str:
return created["id"]
-def rotate_backups() -> int:
- """Deletes the oldest Drive backups beyond gdrive_retention_count.
- Returns how many were deleted."""
+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",
+ q=f"'{_folder_id()}' in parents and trashed = false and name contains '{name_contains}'",
orderBy="createdTime desc",
fields="files(id,name,createdTime)",
pageSize=200,
@@ -140,23 +144,72 @@ def create_local_backup() -> Path | None:
# 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."""
- local_path = create_local_backup()
- if local_path is None:
- return False, "Резервне копіювання в Google Drive підтримується лише для SQLite."
-
try:
+ local_path = create_local_backup()
+ if local_path is None:
+ return False, "Резервне копіювання в Google Drive підтримується лише для SQLite."
upload_backup(local_path)
- deleted = rotate_backups()
+ 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)
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 31544b2..b1fca3c 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -11,6 +11,13 @@
Далі `$INSTALL_DIR` — шлях, куди клонується репозиторій (напр.
`/opt/-bot`); підставляйте свій на кожному кроці.
+**Кроки 2-5 (клон, `.env`, nginx, TLS, systemd) автоматизовані —**
+`./provision_client.sh --slug vegcafe --domain vegcafe.example --bot-token
+··· --cafe-name "Веге Кафе"` (`--help` для повного списку прапорців).
+Питає підтвердження перед кожним незворотним кроком і ніколи не чіпає
+директорію, яка вже існує. Розділи нижче лишаються довідкою — що саме
+робить скрипт і що робити, якщо якийсь крок треба виконати вручну.
+
## 1. Підготовка сервера
```bash
diff --git a/provision_client.sh b/provision_client.sh
new file mode 100644
index 0000000..4cff315
--- /dev/null
+++ b/provision_client.sh
@@ -0,0 +1,212 @@
+#!/bin/bash
+# Provisions a NEW, separate deployment of this codebase for another client
+# on a fresh VPS — clone, .env, webapp build, nginx vhost, TLS, systemd
+# units. Automates docs/DEPLOYMENT.md steps 2-5; run this instead of doing
+# 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.
+#
+# Never touches an existing deployment: refuses to run if --install-dir
+# already exists, and every irreversible/system-level step (nginx reload,
+# certbot, systemctl enable) asks for confirmation first.
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: ./provision_client.sh --slug SLUG --domain DOMAIN --bot-token TOKEN --cafe-name "Name" [--install-dir DIR] [--repo-url URL]
+
+ --slug short identifier, lowercase letters/digits/hyphens only
+ (used for the install dir and systemd unit names)
+ --domain public domain this instance will be served from
+ --bot-token Telegram bot token from @BotFather
+ --cafe-name display name for this client (webapp title, PWA manifest)
+ --install-dir defaults to /opt/-bot
+ --repo-url defaults to this project's own git remote
+EOF
+}
+
+SLUG=""
+DOMAIN=""
+BOT_TOKEN=""
+CAFE_NAME=""
+INSTALL_DIR=""
+REPO_URL="https://git.zotac.keenetic.link/zotac/bober-bbq-bot.git"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --slug) SLUG="$2"; shift 2 ;;
+ --domain) DOMAIN="$2"; shift 2 ;;
+ --bot-token) BOT_TOKEN="$2"; shift 2 ;;
+ --cafe-name) CAFE_NAME="$2"; shift 2 ;;
+ --install-dir) INSTALL_DIR="$2"; shift 2 ;;
+ --repo-url) REPO_URL="$2"; shift 2 ;;
+ -h|--help) usage; exit 0 ;;
+ *) echo "Невідомий аргумент: $1"; usage; exit 1 ;;
+ esac
+done
+
+if [[ -z "$SLUG" || -z "$DOMAIN" || -z "$BOT_TOKEN" || -z "$CAFE_NAME" ]]; then
+ echo "Не вистачає обов'язкового аргументу."
+ usage
+ exit 1
+fi
+if [[ ! "$SLUG" =~ ^[a-z0-9-]+$ ]]; then
+ echo "--slug має містити лише малі латинські літери, цифри й дефіс (напр. \"vegcafe\")."
+ exit 1
+fi
+
+INSTALL_DIR="${INSTALL_DIR:-/opt/${SLUG}-bot}"
+SERVICE_WEB="${SLUG}-web"
+SERVICE_BOT="${SLUG}-bot"
+LOG_FILE_WEB="/var/log/${SERVICE_WEB}.log"
+LOG_FILE_BOT="/var/log/${SERVICE_BOT}.log"
+
+confirm() {
+ local reply
+ read -rp "$1 [y/N] " reply
+ [[ "$reply" =~ ^[Yy]$ ]]
+}
+
+echo "=== Провижнінг нового клієнта ==="
+echo "Slug: $SLUG"
+echo "Домен: $DOMAIN"
+echo "Install dir: $INSTALL_DIR"
+echo "Cafe name: $CAFE_NAME"
+echo "Юніти: $SERVICE_WEB / $SERVICE_BOT"
+echo ""
+if ! confirm "Продовжити?"; then
+ echo "Скасовано."
+ exit 0
+fi
+
+if [[ -e "$INSTALL_DIR" ]]; then
+ echo "ПОМИЛКА: $INSTALL_DIR вже існує — оберіть інший --install-dir."
+ echo "(Це також запобігає випадковому запуску поверх існуючого деплою.)"
+ exit 1
+fi
+
+echo "==> 1/8 git clone"
+git clone "$REPO_URL" "$INSTALL_DIR"
+cd "$INSTALL_DIR"
+
+echo "==> 2/8 venv + залежності"
+python3.11 -m venv .venv
+.venv/bin/pip install -q -r requirements.txt
+
+echo "==> 3/8 .env"
+SECRET_KEY=$(openssl rand -hex 32)
+ADMIN_PASSWORD=$(openssl rand -hex 12)
+cp .env.example .env
+sed -i "s|^BOT_TOKEN=.*|BOT_TOKEN=${BOT_TOKEN}|" .env
+sed -i "s|^WEBAPP_URL=.*|WEBAPP_URL=https://${DOMAIN}/webapp/|" .env
+sed -i "s|^BACKEND_PUBLIC_URL=.*|BACKEND_PUBLIC_URL=https://${DOMAIN}|" .env
+sed -i "s|^MONOBANK_WEBHOOK_URL=.*|MONOBANK_WEBHOOK_URL=https://${DOMAIN}/api/payments/monobank/webhook|" .env
+sed -i "s|^SECRET_KEY=.*|SECRET_KEY=${SECRET_KEY}|" .env
+sed -i "s|^FLASK_ADMIN_PASSWORD=.*|FLASK_ADMIN_PASSWORD=${ADMIN_PASSWORD}|" .env
+{
+ echo ""
+ echo "# --- provision_client.sh: deployment identity ---"
+ echo "SERVICE_WEB=${SERVICE_WEB}"
+ echo "SERVICE_BOT=${SERVICE_BOT}"
+ echo "LOG_FILE_WEB=${LOG_FILE_WEB}"
+ echo "LOG_FILE_BOT=${LOG_FILE_BOT}"
+} >> .env
+echo "Адмін-логін: admin / пароль: ${ADMIN_PASSWORD} (запишіть — більше ніде не показується)"
+
+echo "==> 4/8 webapp/.env.local"
+echo "VITE_CAFE_NAME=${CAFE_NAME}" > webapp/.env.local
+
+echo "==> 5/8 webapp build"
+( cd webapp && npm install --silent && npm run build )
+
+echo "==> 6/8 nginx vhost"
+if confirm "Створити nginx vhost для ${DOMAIN} і перезавантажити nginx?"; then
+ NGINX_CONF="/etc/nginx/sites-available/${SLUG}"
+ sudo tee "$NGINX_CONF" > /dev/null < 7/8 certbot (TLS)"
+if confirm "Запустити certbot для ${DOMAIN}? (потрібен вже налаштований DNS-запис на цей сервер)"; then
+ sudo certbot --nginx -d "$DOMAIN"
+else
+ echo "Пропущено — без TLS Telegram Mini App і вебхуки не запрацюють."
+ echo "Запустіть пізніше: sudo certbot --nginx -d ${DOMAIN}"
+fi
+
+echo "==> 8/8 systemd-юніти"
+if confirm "Створити й запустити systemd-юніти (${SERVICE_WEB}, ${SERVICE_BOT})?"; then
+ sudo tee "/etc/systemd/system/${SERVICE_WEB}.service" > /dev/null < /dev/null < https://${DOMAIN}/webapp/
+2. Додати бота в чати доставки/самовивозу, вписати chat_id в Адмінку -> Налаштування.
+3. Якщо клієнт приймає картки — MONOBANK_TOKEN у .env і перевірити вебхук.
+4. Замінити 4 іконки під webapp/public/icons/icon-{32,180,192,512}.png на бренд
+ клієнта, потім ще раз: cd webapp && npm run build
+5. Адмінка (https://${DOMAIN}/admin, admin / пароль вище) -> Налаштування:
+ cafe_name, адреса, графік, кольори, лого.
+6. Адмінка -> Меню: видалити демо-меню (Шашлик/BBQ), додати асортимент клієнта.
+7. Зареєструвати https://${DOMAIN}/health в UptimeRobot (чи аналог) — вручну,
+ акаунт створює власник бізнесу, не цей скрипт.
+8. Резервні копії: Адмінка -> Резервні копії -> увімкнути Google Drive backup
+ (або окремий cron — docs/DEPLOYMENT.md, крок 7).
+
+EOF
diff --git a/run_web.py b/run_web.py
index 3a8c776..d3df247 100644
--- a/run_web.py
+++ b/run_web.py
@@ -4,6 +4,7 @@ Dev: python bober_bbq/app.py (Flask debug server, auto-reload)
Prod: python run_web.py (waitress, no reload, multi-threaded)
"""
+import logging
import os
from apscheduler.schedulers.background import BackgroundScheduler
@@ -15,10 +16,13 @@ from bober_bbq.extensions import db
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.checkbox_prro import maybe_close_shift
from bober_bbq.utils.gdrive_backup import run_scheduled_backup
from bober_bbq.utils.reconciliation import check_monobank_token_health, reconcile_pending_payments
+logger = logging.getLogger(__name__)
+
app = create_app()
with app.app_context():
@@ -27,33 +31,41 @@ with app.app_context():
seed()
-def _run_in_app_context(fn):
+def _run_in_app_context(fn, job_name):
+ # APScheduler's default executor only logs a job's exception itself —
+ # nothing here previously caught one, so a crashing job (like
+ # reconcile_payments hitting something other than MonobankError) failed
+ # silently from the owner's perspective, visible only in server logs.
with app.app_context():
- fn()
+ try:
+ fn()
+ except Exception as e:
+ logger.exception("Scheduled job %s crashed", job_name)
+ error_alerts.notify(f"фонове завдання {job_name}", e)
if __name__ == "__main__":
scheduler = BackgroundScheduler(timezone=config.TIMEZONE)
scheduler.add_job(
- lambda: _run_in_app_context(reconcile_pending_payments),
+ lambda: _run_in_app_context(reconcile_pending_payments, "reconcile_payments"),
"interval",
minutes=10,
id="reconcile_payments",
)
scheduler.add_job(
- lambda: _run_in_app_context(check_monobank_token_health),
+ lambda: _run_in_app_context(check_monobank_token_health, "monobank_token_health"),
"interval",
hours=1,
id="monobank_token_health",
)
scheduler.add_job(
- lambda: _run_in_app_context(run_scheduled_backup),
+ lambda: _run_in_app_context(run_scheduled_backup, "gdrive_backup"),
"interval",
hours=1,
id="gdrive_backup",
)
scheduler.add_job(
- lambda: _run_in_app_context(maybe_close_shift),
+ lambda: _run_in_app_context(maybe_close_shift, "checkbox_auto_close_shift"),
"interval",
minutes=10,
id="checkbox_auto_close_shift",