Make brand identity reusable for redeploying to other clients
Groundwork for reusing this codebase per-client (own VPS/DB/bot per
cafe): the operational config (bot token, DB URL, secrets, Checkbox PRRO
creds) was already .env/Setting-driven, but ~13 spots still hardcoded the
literal string "Bober BBQ" instead of reading the existing cafe_name
Setting, and the webapp's static shell (index.html title, PWA manifest)
had no templating at all since Settings only load after JS boots.
- Route the remaining hardcoded strings through Setting.get("cafe_name",
"Bober BBQ") — same pattern already used correctly in bot/handlers/
start.py. Every fallback stays "Bober BBQ", so production is byte-
identical with no cafe_name override.
- Template webapp/index.html via Vite's native %VITE_CAFE_NAME% HTML
replacement, backed by a committed webapp/.env (default "Bober BBQ",
no secrets) with a per-client override via gitignored .env.local.
- Add webapp/scripts/gen-manifest.mjs to generate manifest.json from a
new manifest.template.json the same way, since Vite doesn't process
public/ assets — wired into the build script.
- Parameterize deploy.sh's systemd unit names and the admin log-viewer's
log paths via optional SERVICE_WEB/SERVICE_BOT/LOG_FILE_WEB/
LOG_FILE_BOT env vars, defaulting to today's literal values.
- Rewrite docs/DEPLOYMENT.md into a repeatable new-client runbook (fixed
an existing /opt/bober-bbq vs /opt/bober-bbq-bot inconsistency, added
a rebranding checklist).
Verified via a smoke test that every changed string still renders
"Bober BBQ" with no overrides present (matching prod's actual .env/DB
state), and that webapp builds both with and without a VITE_CAFE_NAME
override produce the expected output — including a no-override rebuild
confirming byte-identical output to before this change.
This commit is contained in:
parent
23e931d777
commit
922d9c5172
21 changed files with 179 additions and 45 deletions
|
|
@ -34,3 +34,11 @@ MONOBANK_WEBHOOK_URL=https://example.com/api/payments/monobank/webhook
|
||||||
|
|
||||||
# === Misc ===
|
# === Misc ===
|
||||||
TIMEZONE=Europe/Kyiv
|
TIMEZONE=Europe/Kyiv
|
||||||
|
|
||||||
|
# === Deployment identity (only needed if this instance's systemd units/logs
|
||||||
|
# aren't named bober-bbq-web/bober-bbq-bot — e.g. a second client on the
|
||||||
|
# same codebase). Leave unset to use those defaults. ===
|
||||||
|
# SERVICE_WEB=bober-bbq-web
|
||||||
|
# SERVICE_BOT=bober-bbq-bot
|
||||||
|
# LOG_FILE_WEB=/var/log/bober-bbq-web.log
|
||||||
|
# LOG_FILE_BOT=/var/log/bober-bbq-bot.log
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -1,4 +1,10 @@
|
||||||
.env
|
.env
|
||||||
|
# webapp/.env holds only build-time branding (no secrets) and is committed
|
||||||
|
# with the current defaults so Vite's %VAR% HTML replacement always has a
|
||||||
|
# value to substitute; a per-client override goes in webapp/.env.local
|
||||||
|
# instead (higher precedence, never touches the tracked defaults).
|
||||||
|
!webapp/.env
|
||||||
|
webapp/.env.local
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
instance/*.db
|
instance/*.db
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ from bober_bbq.utils.gdrive_backup import backup_now, gdrive_enabled, test_conne
|
||||||
|
|
||||||
APP_VERSION = "1.0.0"
|
APP_VERSION = "1.0.0"
|
||||||
BACKUP_DIR = BASE_DIR / "backups"
|
BACKUP_DIR = BASE_DIR / "backups"
|
||||||
LOG_FILES = ["/var/log/bober-bbq-web.log", "/var/log/bober-bbq-bot.log"]
|
LOG_FILES = [
|
||||||
|
os.getenv("LOG_FILE_WEB", "/var/log/bober-bbq-web.log"),
|
||||||
|
os.getenv("LOG_FILE_BOT", "/var/log/bober-bbq-bot.log"),
|
||||||
|
]
|
||||||
_tz = ZoneInfo(config.TIMEZONE)
|
_tz = ZoneInfo(config.TIMEZONE)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,8 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return jsonify({"service": "Bober BBQ backend", "admin": "/admin", "webapp": "/webapp/", "api": "/api"})
|
cafe_name = Setting.get("cafe_name", "Bober BBQ")
|
||||||
|
return jsonify({"service": f"{cafe_name} backend", "admin": "/admin", "webapp": "/webapp/", "api": "/api"})
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
def health():
|
def health():
|
||||||
|
|
|
||||||
|
|
@ -526,7 +526,7 @@ DEFAULT_SETTINGS = {
|
||||||
"monobank_test_token": "",
|
"monobank_test_token": "",
|
||||||
"monobank_test_mode": "0",
|
"monobank_test_mode": "0",
|
||||||
"monobank_show_basket": "1",
|
"monobank_show_basket": "1",
|
||||||
"payment_destination_template": "Оплата замовлення {number} Bober BBQ",
|
"payment_destination_template": "Оплата замовлення {number}",
|
||||||
"gdrive_backup_enabled": "0",
|
"gdrive_backup_enabled": "0",
|
||||||
"gdrive_service_account_json": "",
|
"gdrive_service_account_json": "",
|
||||||
"gdrive_folder_id": "",
|
"gdrive_folder_id": "",
|
||||||
|
|
|
||||||
|
|
@ -86,13 +86,14 @@ def create_invoice(order: Order) -> dict:
|
||||||
"""Create a payment invoice for the given order and return
|
"""Create a payment invoice for the given order and return
|
||||||
{"invoiceId": ..., "pageUrl": ...}. Raises MonobankNotConfigured /
|
{"invoiceId": ..., "pageUrl": ...}. Raises MonobankNotConfigured /
|
||||||
MonobankError on failure."""
|
MonobankError on failure."""
|
||||||
|
cafe_name = Setting.get("cafe_name", "Bober BBQ")
|
||||||
destination_template = Setting.get(
|
destination_template = Setting.get(
|
||||||
"payment_destination_template", "Оплата замовлення {number} Bober BBQ"
|
"payment_destination_template", "Оплата замовлення {number} " + cafe_name
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
destination = destination_template.format(number=order.display_number())
|
destination = destination_template.format(number=order.display_number())
|
||||||
except (KeyError, IndexError):
|
except (KeyError, IndexError):
|
||||||
destination = f"Оплата замовлення {order.display_number()} Bober BBQ"
|
destination = f"Оплата замовлення {order.display_number()} {cafe_name}"
|
||||||
|
|
||||||
merchant_paym_info = {
|
merchant_paym_info = {
|
||||||
"reference": order.display_number(),
|
"reference": order.display_number(),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}Bober BBQ — Адмін{% endblock %}</title>
|
<title>{% block title %}{{ cafe_name }} — Адмін{% endblock %}</title>
|
||||||
<link rel="icon" type="image/png" href="{{ url_for('webapp_icon', size=32) }}">
|
<link rel="icon" type="image/png" href="{{ url_for('webapp_icon', size=32) }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='admin/css/admin.css') }}?v={{ asset_version('admin/css/admin.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='admin/css/admin.css') }}?v={{ asset_version('admin/css/admin.css') }}">
|
||||||
{% if admin_theme %}
|
{% if admin_theme %}
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
<span class="brand-emoji">🦫</span>
|
<span class="brand-emoji">🦫</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<div class="brand-name">Bober BBQ</div>
|
<div class="brand-name">{{ cafe_name }}</div>
|
||||||
<div class="brand-sub">Admin Panel</div>
|
<div class="brand-sub">Admin Panel</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@
|
||||||
<div class="tg-item">
|
<div class="tg-item">
|
||||||
<div class="tg-icon">📨</div>
|
<div class="tg-icon">📨</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="tg-name">Bober BBQ — Доставка</div>
|
<div class="tg-name">{{ cafe_name }} — Доставка</div>
|
||||||
<div class="tg-sub">{{ stats.today.delivery }} нових замовлень сьогодні{% if not delivery_chat_configured %} · чат не підключено{% endif %}</div>
|
<div class="tg-sub">{{ stats.today.delivery }} нових замовлень сьогодні{% if not delivery_chat_configured %} · чат не підключено{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn secondary small" href="{{ url_for('admin.telegram_chats') }}">Відкрити</a>
|
<a class="btn secondary small" href="{{ url_for('admin.telegram_chats') }}">Відкрити</a>
|
||||||
|
|
@ -203,7 +203,7 @@
|
||||||
<div class="tg-item">
|
<div class="tg-item">
|
||||||
<div class="tg-icon">📦</div>
|
<div class="tg-icon">📦</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="tg-name">Bober BBQ — Самовивіз</div>
|
<div class="tg-name">{{ cafe_name }} — Самовивіз</div>
|
||||||
<div class="tg-sub">{{ stats.today.pickup }} нових замовлень сьогодні{% if not pickup_chat_configured %} · чат не підключено{% endif %}</div>
|
<div class="tg-sub">{{ stats.today.pickup }} нових замовлень сьогодні{% if not pickup_chat_configured %} · чат не підключено{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn secondary small" href="{{ url_for('admin.telegram_chats') }}">Відкрити</a>
|
<a class="btn secondary small" href="{{ url_for('admin.telegram_chats') }}">Відкрити</a>
|
||||||
|
|
@ -211,7 +211,7 @@
|
||||||
<div class="tg-item">
|
<div class="tg-item">
|
||||||
<div class="tg-icon">🍽️</div>
|
<div class="tg-icon">🍽️</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="tg-name">Bober BBQ — У закладі</div>
|
<div class="tg-name">{{ cafe_name }} — У закладі</div>
|
||||||
<div class="tg-sub">{{ stats.today.dine_in }} нових замовлень сьогодні{% if not dine_in_chat_configured %} · чат не підключено{% endif %}</div>
|
<div class="tg-sub">{{ stats.today.dine_in }} нових замовлень сьогодні{% if not dine_in_chat_configured %} · чат не підключено{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn secondary small" href="{{ url_for('admin.telegram_chats') }}">Відкрити</a>
|
<a class="btn secondary small" href="{{ url_for('admin.telegram_chats') }}">Відкрити</a>
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label>Опис платежу (показується клієнту на сторінці оплати)</label>
|
<label>Опис платежу (показується клієнту на сторінці оплати)</label>
|
||||||
<input type="text" name="payment_destination_template" value="{{ values.payment_destination_template }}" placeholder="Оплата замовлення {number} Bober BBQ">
|
<input type="text" name="payment_destination_template" value="{{ values.payment_destination_template }}" placeholder="Оплата замовлення {number} {{ cafe_name }}">
|
||||||
<p class="muted field-hint">{number} буде замінено на номер замовлення, напр. №1005.</p>
|
<p class="muted field-hint">{number} буде замінено на номер замовлення, напр. №1005.</p>
|
||||||
|
|
||||||
<label style="display:flex;align-items:center;gap:8px;">
|
<label style="display:flex;align-items:center;gap:8px;">
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
<div class="grid-2-even">
|
<div class="grid-2-even">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head"><h3>🛵 Bober BBQ — Доставка</h3></div>
|
<div class="card-head"><h3>🛵 {{ cafe_name }} — Доставка</h3></div>
|
||||||
<table>
|
<table>
|
||||||
<tr><td class="muted">Статус</td><td>
|
<tr><td class="muted">Статус</td><td>
|
||||||
{% if delivery_chat_id %}<span class="status-dot ok"></span>Підключено{% else %}<span class="status-dot bad"></span>Не підключено{% endif %}
|
{% if delivery_chat_id %}<span class="status-dot ok"></span>Підключено{% else %}<span class="status-dot bad"></span>Не підключено{% endif %}
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head"><h3>{{ pickup_icon(18) }} Bober BBQ — Самовивіз</h3></div>
|
<div class="card-head"><h3>{{ pickup_icon(18) }} {{ cafe_name }} — Самовивіз</h3></div>
|
||||||
<table>
|
<table>
|
||||||
<tr><td class="muted">Статус</td><td>
|
<tr><td class="muted">Статус</td><td>
|
||||||
{% if pickup_chat_id %}<span class="status-dot ok"></span>Підключено{% else %}<span class="status-dot bad"></span>Не підключено{% endif %}
|
{% if pickup_chat_id %}<span class="status-dot ok"></span>Підключено{% else %}<span class="status-dot bad"></span>Не підключено{% endif %}
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head"><h3>🍽️ Bober BBQ — У закладі</h3></div>
|
<div class="card-head"><h3>🍽️ {{ cafe_name }} — У закладі</h3></div>
|
||||||
<table>
|
<table>
|
||||||
<tr><td class="muted">Статус</td><td>
|
<tr><td class="muted">Статус</td><td>
|
||||||
{% if dine_in_chat_id %}<span class="status-dot ok"></span>Підключено{% else %}<span class="status-dot bad"></span>Не підключено (сповіщення йдуть власнику){% endif %}
|
{% if dine_in_chat_id %}<span class="status-dot ok"></span>Підключено{% else %}<span class="status-dot bad"></span>Не підключено (сповіщення йдуть власнику){% endif %}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ STATUS_MESSAGES = {
|
||||||
"cooking": "👨🍳 <b>Замовлення {number} готується</b>\n\nЗовсім скоро буде готове!",
|
"cooking": "👨🍳 <b>Замовлення {number} готується</b>\n\nЗовсім скоро буде готове!",
|
||||||
"ready": "🎉 <b>Замовлення {number} готове!</b>",
|
"ready": "🎉 <b>Замовлення {number} готове!</b>",
|
||||||
"courier": "🛵 <b>Замовлення {number} в дорозі!</b>\n\nКур'єр вже прямує до вас.",
|
"courier": "🛵 <b>Замовлення {number} в дорозі!</b>\n\nКур'єр вже прямує до вас.",
|
||||||
"completed": "🦫 <b>Дякуємо, що обрали Bober BBQ!</b>\n\nЗамовлення {number} виконано. Смачного!",
|
"completed": "🦫 <b>Дякуємо, що обрали {cafe_name}!</b>\n\nЗамовлення {number} виконано. Смачного!",
|
||||||
"cancelled": "❌ <b>Замовлення {number} скасовано</b>\n\nЗа потреби менеджер зв'яжеться з вами.",
|
"cancelled": "❌ <b>Замовлення {number} скасовано</b>\n\nЗа потреби менеджер зв'яжеться з вами.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ def notify_customer_status_change(order: Order) -> None:
|
||||||
template = STATUS_MESSAGES.get(order.status)
|
template = STATUS_MESSAGES.get(order.status)
|
||||||
if not template:
|
if not template:
|
||||||
return
|
return
|
||||||
text = template.format(number=order.display_number())
|
text = template.format(number=order.display_number(), cafe_name=Setting.get("cafe_name", "Bober BBQ"))
|
||||||
if order.status == "cancelled" and order.cancel_reason:
|
if order.status == "cancelled" and order.cancel_reason:
|
||||||
text += f"\n\n<i>Причина: {html.escape(order.cancel_reason)}</i>"
|
text += f"\n\n<i>Причина: {html.escape(order.cancel_reason)}</i>"
|
||||||
if order.order_type == "pickup" and order.status == "ready":
|
if order.order_type == "pickup" and order.status == "ready":
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,9 @@ def closed_message() -> str:
|
||||||
custom = Setting.get("force_closed_message", "").strip()
|
custom = Setting.get("force_closed_message", "").strip()
|
||||||
return custom or "Наразі заклад тимчасово не приймає замовлення. Перепрошуємо за незручності!"
|
return custom or "Наразі заклад тимчасово не приймає замовлення. Перепрошуємо за незручності!"
|
||||||
start, end = working_hours()
|
start, end = working_hours()
|
||||||
|
cafe_name = Setting.get("cafe_name", "Bober BBQ")
|
||||||
return (
|
return (
|
||||||
f"Наразі Bober BBQ не приймає замовлення.\n"
|
f"Наразі {cafe_name} не приймає замовлення.\n"
|
||||||
f"Графік роботи: щодня з {start} до {end}.\n"
|
f"Графік роботи: щодня з {start} до {end}.\n"
|
||||||
f"Ви можете переглянути меню зараз, а оформити замовлення під час роботи закладу."
|
f"Ви можете переглянути меню зараз, а оформити замовлення під час роботи закладу."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from bober_bbq.app import create_app
|
||||||
from bober_bbq.config import config
|
from bober_bbq.config import config
|
||||||
from bober_bbq.extensions import db
|
from bober_bbq.extensions import db
|
||||||
from bober_bbq.migrate import migrate
|
from bober_bbq.migrate import migrate
|
||||||
|
from bober_bbq.models import Setting
|
||||||
from bober_bbq.seed import seed
|
from bober_bbq.seed import seed
|
||||||
from bot.handlers import contacts, menu, rating, start
|
from bot.handlers import contacts, menu, rating, start
|
||||||
|
|
||||||
|
|
@ -39,7 +40,7 @@ async def main():
|
||||||
dp.include_router(rating.router)
|
dp.include_router(rating.router)
|
||||||
|
|
||||||
await bot.delete_webhook(drop_pending_updates=True)
|
await bot.delete_webhook(drop_pending_updates=True)
|
||||||
logger.info("Bober BBQ bot starting (long polling)...")
|
logger.info("%s bot starting (long polling)...", Setting.get("cafe_name", "Bober BBQ"))
|
||||||
await dp.start_polling(bot)
|
await dp.start_polling(bot)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
15
deploy.sh
15
deploy.sh
|
|
@ -1,6 +1,15 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
cd /opt/bober-bbq-bot
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
|
||||||
|
# SERVICE_WEB/SERVICE_BOT let a differently-named deployment (e.g. a second
|
||||||
|
# client on the same codebase) override the systemd unit names via .env
|
||||||
|
# without editing this script. Read directly instead of sourcing the whole
|
||||||
|
# .env, so unrelated values (secrets, tokens) never end up in this shell.
|
||||||
|
SERVICE_WEB=$(grep -E '^SERVICE_WEB=' .env 2>/dev/null | cut -d= -f2-)
|
||||||
|
SERVICE_BOT=$(grep -E '^SERVICE_BOT=' .env 2>/dev/null | cut -d= -f2-)
|
||||||
|
SERVICE_WEB="${SERVICE_WEB:-bober-bbq-web}"
|
||||||
|
SERVICE_BOT="${SERVICE_BOT:-bober-bbq-bot}"
|
||||||
|
|
||||||
echo "==> git pull"
|
echo "==> git pull"
|
||||||
git pull
|
git pull
|
||||||
|
|
@ -15,8 +24,8 @@ npm run build
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
echo "==> restart services"
|
echo "==> restart services"
|
||||||
sudo systemctl restart bober-bbq-web bober-bbq-bot
|
sudo systemctl restart "$SERVICE_WEB" "$SERVICE_BOT"
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
sudo systemctl is-active bober-bbq-web bober-bbq-bot
|
sudo systemctl is-active "$SERVICE_WEB" "$SERVICE_BOT"
|
||||||
echo "==> deploy done"
|
echo "==> deploy done"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@
|
||||||
термінатор TLS, два systemd-сервіси (backend + bot), SQLite (або Postgres,
|
термінатор TLS, два systemd-сервіси (backend + bot), SQLite (або Postgres,
|
||||||
якщо очікується більше навантаження).
|
якщо очікується більше навантаження).
|
||||||
|
|
||||||
|
Цей документ описує розгортання **нового, окремого** інстансу (напр. для
|
||||||
|
іншого клієнта на тому самому коді). Він не стосується і не повинен
|
||||||
|
застосовуватись до вже працюючого продакшн-сервера Bober BBQ.
|
||||||
|
|
||||||
|
Далі `$INSTALL_DIR` — шлях, куди клонується репозиторій (напр.
|
||||||
|
`/opt/<client-slug>-bot`); підставляйте свій на кожному кроці.
|
||||||
|
|
||||||
## 1. Підготовка сервера
|
## 1. Підготовка сервера
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -13,8 +20,8 @@ sudo apt update && sudo apt install -y python3.11 python3.11-venv nginx certbot
|
||||||
## 2. Код і залежності
|
## 2. Код і залежності
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <репозиторій> /opt/bober-bbq
|
git clone <репозиторій> $INSTALL_DIR
|
||||||
cd /opt/bober-bbq
|
cd $INSTALL_DIR
|
||||||
python3.11 -m venv .venv
|
python3.11 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
@ -33,6 +40,10 @@ cd webapp && npm install && npm run build && cd ..
|
||||||
- `MONOBANK_TOKEN` — токен еквайрингу від замовника (розділ 22.8 ТЗ)
|
- `MONOBANK_TOKEN` — токен еквайрингу від замовника (розділ 22.8 ТЗ)
|
||||||
- `DATABASE_URL` — для Postgres: `postgresql+psycopg2://user:pass@localhost/bober_bbq`
|
- `DATABASE_URL` — для Postgres: `postgresql+psycopg2://user:pass@localhost/bober_bbq`
|
||||||
- `SECRET_KEY`, `FLASK_ADMIN_USERNAME`, `FLASK_ADMIN_PASSWORD` — змінити на бойові значення
|
- `SECRET_KEY`, `FLASK_ADMIN_USERNAME`, `FLASK_ADMIN_PASSWORD` — змінити на бойові значення
|
||||||
|
- `SERVICE_WEB`, `SERVICE_BOT`, `LOG_FILE_WEB`, `LOG_FILE_BOT` — лише якщо
|
||||||
|
systemd-юніти цього інстансу названі не `bober-bbq-web`/`bober-bbq-bot`
|
||||||
|
(напр. другий клієнт на тій самій VPS). Не задавайте їх, якщо юніти саме
|
||||||
|
так і називаються — значення за замовчуванням підійдуть.
|
||||||
|
|
||||||
## 4. Nginx + TLS
|
## 4. Nginx + TLS
|
||||||
|
|
||||||
|
|
@ -59,35 +70,41 @@ Telegram Mini App і monobank webhook **вимагають дійсний HTTPS-
|
||||||
|
|
||||||
## 5. systemd-юніти
|
## 5. systemd-юніти
|
||||||
|
|
||||||
`/etc/systemd/system/bober-bbq-web.service`:
|
Оберіть імена юнітів для цього клієнта (типово `bober-bbq-web`/
|
||||||
|
`bober-bbq-bot`, якщо це єдиний інстанс на сервері; інакше — щось
|
||||||
|
унікальне, напр. `<client-slug>-web`/`<client-slug>-bot`, і не забудьте
|
||||||
|
задати ці ж імена в `.env` через `SERVICE_WEB`/`SERVICE_BOT`, інакше
|
||||||
|
`deploy.sh` перезапускатиме не ті юніти).
|
||||||
|
|
||||||
|
`/etc/systemd/system/<SERVICE_WEB>.service`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Bober BBQ backend (API + admin)
|
Description=<Cafe Name> backend (API + admin)
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=/opt/bober-bbq
|
WorkingDirectory=$INSTALL_DIR
|
||||||
ExecStart=/opt/bober-bbq/.venv/bin/python run_web.py
|
ExecStart=$INSTALL_DIR/.venv/bin/python run_web.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
EnvironmentFile=/opt/bober-bbq/.env
|
EnvironmentFile=$INSTALL_DIR/.env
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
`/etc/systemd/system/bober-bbq-bot.service`:
|
`/etc/systemd/system/<SERVICE_BOT>.service`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Bober BBQ Telegram bot
|
Description=<Cafe Name> Telegram bot
|
||||||
After=network.target bober-bbq-web.service
|
After=network.target <SERVICE_WEB>.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=/opt/bober-bbq
|
WorkingDirectory=$INSTALL_DIR
|
||||||
ExecStart=/opt/bober-bbq/.venv/bin/python run_bot.py
|
ExecStart=$INSTALL_DIR/.venv/bin/python run_bot.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
EnvironmentFile=/opt/bober-bbq/.env
|
EnvironmentFile=$INSTALL_DIR/.env
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
@ -95,9 +112,15 @@ WantedBy=multi-user.target
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now bober-bbq-web bober-bbq-bot
|
sudo systemctl enable --now <SERVICE_WEB> <SERVICE_BOT>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Якщо обрали нестандартні імена, також вкажіть логи цього інстансу в
|
||||||
|
`StandardOutput=append:/var/log/<SERVICE_WEB>.log` /
|
||||||
|
`StandardError=append:/var/log/<SERVICE_WEB>.log` (аналогічно для бота) і
|
||||||
|
задайте ті самі шляхи в `.env` через `LOG_FILE_WEB`/`LOG_FILE_BOT` — інакше
|
||||||
|
перегляд логів в Адмінці → Система буде показувати не той файл.
|
||||||
|
|
||||||
## 6. Telegram-налаштування
|
## 6. Telegram-налаштування
|
||||||
|
|
||||||
1. У [@BotFather](https://t.me/BotFather): `/setmenubutton` → вкажіть
|
1. У [@BotFather](https://t.me/BotFather): `/setmenubutton` → вкажіть
|
||||||
|
|
@ -115,7 +138,7 @@ sudo systemctl enable --now bober-bbq-web bober-bbq-bot
|
||||||
SQLite: досить копіювати файл `instance/bober_bbq.db` за розкладом (cron):
|
SQLite: досить копіювати файл `instance/bober_bbq.db` за розкладом (cron):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
0 3 * * * cp /opt/bober-bbq/instance/bober_bbq.db /opt/backups/bober_bbq-$(date +\%F).db
|
0 3 * * * cp $INSTALL_DIR/instance/bober_bbq.db /opt/backups/bober_bbq-$(date +\%F).db
|
||||||
```
|
```
|
||||||
|
|
||||||
Postgres: стандартний `pg_dump` за розкладом. Не забудьте також бекапити
|
Postgres: стандартний `pg_dump` за розкладом. Не забудьте також бекапити
|
||||||
|
|
@ -124,21 +147,44 @@ Postgres: стандартний `pg_dump` за розкладом. Не заб
|
||||||
|
|
||||||
## 8. Оновлення коду
|
## 8. Оновлення коду
|
||||||
|
|
||||||
Одна команда (є на живому сервері як `/opt/bober-bbq-bot/deploy.sh`):
|
`deploy.sh` — закомічений у репозиторії корінь (`$INSTALL_DIR/deploy.sh`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/bober-bbq-bot/deploy.sh
|
$INSTALL_DIR/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Робить послідовно: `git pull` → `pip install -r requirements.txt` →
|
Робить послідовно: `git pull` → `pip install -r requirements.txt` →
|
||||||
`npm install && npm run build` у `webapp/` → `systemctl restart bober-bbq-web bober-bbq-bot`.
|
`npm install && npm run build` у `webapp/` → `systemctl restart` тих
|
||||||
|
юнітів, що вказані в `.env` через `SERVICE_WEB`/`SERVICE_BOT` (або
|
||||||
|
`bober-bbq-web`/`bober-bbq-bot`, якщо не вказано). Той самий скрипт,
|
||||||
|
без змін, коректно працює для будь-якого клієнта на цьому коді — імена
|
||||||
|
юнітів він бере з `.env` цього конкретного інстансу.
|
||||||
|
|
||||||
Якщо розгортаєте вручну на новому сервері — ті самі кроки:
|
Якщо розгортаєте вручну на новому сервері — ті самі кроки:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/bober-bbq-bot
|
cd $INSTALL_DIR
|
||||||
git pull
|
git pull
|
||||||
.venv/bin/pip install -r requirements.txt
|
.venv/bin/pip install -r requirements.txt
|
||||||
cd webapp && npm install && npm run build && cd ..
|
cd webapp && npm install && npm run build && cd ..
|
||||||
sudo systemctl restart bober-bbq-web bober-bbq-bot
|
sudo systemctl restart <SERVICE_WEB> <SERVICE_BOT>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 9. Чекліст ребрендингу для нового клієнта
|
||||||
|
|
||||||
|
Після першого запуску (перед тим, як показувати клієнту):
|
||||||
|
|
||||||
|
1. Адмінка → Налаштування: `cafe_name`, телефон, адреса, графік роботи,
|
||||||
|
`logo_url`, кольори теми (адмінки й вебапки) — усе інше вже підхопить
|
||||||
|
ці значення (заголовки листування з Telegram-ботом, назва в API, титул
|
||||||
|
сторінки адмінки тощо).
|
||||||
|
2. `webapp/.env.local` (створити, НЕ редагувати закомічений `webapp/.env`):
|
||||||
|
`VITE_CAFE_NAME=<Назва Кафе>` — впливає на `<title>` вебапки та PWA
|
||||||
|
manifest (`name`/`short_name`); підхопиться при наступному
|
||||||
|
`npm run build`.
|
||||||
|
3. Замінити 4 файли іконок під `webapp/public/icons/icon-{32,180,192,512}.png`
|
||||||
|
на артворк клієнта (поточні — це бренд-арт Bober BBQ, бобер з шампуром;
|
||||||
|
автоматично не генеруються).
|
||||||
|
4. Адмінка → Меню: видалити демо-меню (Шашлик/BBQ категорії й товари, що
|
||||||
|
приходять із `seed.py` при першому запуску порожньої БД) і наповнити
|
||||||
|
реальним асортиментом клієнта.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from bober_bbq.app import create_app
|
||||||
from bober_bbq.config import config
|
from bober_bbq.config import config
|
||||||
from bober_bbq.extensions import db
|
from bober_bbq.extensions import db
|
||||||
from bober_bbq.migrate import migrate
|
from bober_bbq.migrate import migrate
|
||||||
|
from bober_bbq.models import Setting
|
||||||
from bober_bbq.seed import seed
|
from bober_bbq.seed import seed
|
||||||
from bober_bbq.utils.checkbox_prro import maybe_close_shift
|
from bober_bbq.utils.checkbox_prro import maybe_close_shift
|
||||||
from bober_bbq.utils.gdrive_backup import run_scheduled_backup
|
from bober_bbq.utils.gdrive_backup import run_scheduled_backup
|
||||||
|
|
@ -60,5 +61,7 @@ if __name__ == "__main__":
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
port = int(os.getenv("PORT", "8000"))
|
port = int(os.getenv("PORT", "8000"))
|
||||||
print(f"Bober BBQ backend listening on http://0.0.0.0:{port}")
|
with app.app_context():
|
||||||
|
cafe_name = Setting.get("cafe_name", "Bober BBQ")
|
||||||
|
print(f"{cafe_name} backend listening on http://0.0.0.0:{port}")
|
||||||
serve(app, host="0.0.0.0", port=port)
|
serve(app, host="0.0.0.0", port=port)
|
||||||
|
|
|
||||||
8
webapp/.env
Normal file
8
webapp/.env
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Build-time branding for the webapp shell (index.html <title>/meta, and
|
||||||
|
# manifest.json name/short_name via scripts/gen-manifest.mjs). Committed
|
||||||
|
# with this instance's current default — safe, since it holds no secrets.
|
||||||
|
#
|
||||||
|
# To rebrand a per-client deployment, create webapp/.env.local instead of
|
||||||
|
# editing this file (Vite loads it with higher precedence, so it overrides
|
||||||
|
# this default without creating a diff against the shared codebase).
|
||||||
|
VITE_CAFE_NAME=Bober BBQ
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>Bober BBQ</title>
|
<title>%VITE_CAFE_NAME%</title>
|
||||||
<link rel="manifest" href="/webapp/manifest.json" />
|
<link rel="manifest" href="/webapp/manifest.json" />
|
||||||
<link rel="icon" href="/webapp/icons/icon-32.png" />
|
<link rel="icon" href="/webapp/icons/icon-32.png" />
|
||||||
<link rel="apple-touch-icon" href="/webapp/icons/icon-180.png" />
|
<link rel="apple-touch-icon" href="/webapp/icons/icon-180.png" />
|
||||||
<meta name="theme-color" content="#0a0a0c" />
|
<meta name="theme-color" content="#0a0a0c" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Bober BBQ" />
|
<meta name="apple-mobile-web-app-title" content="%VITE_CAFE_NAME%" />
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "node scripts/gen-manifest.mjs && tsc --noEmit && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
15
webapp/public/manifest.template.json
Normal file
15
webapp/public/manifest.template.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "{{CAFE_NAME}}",
|
||||||
|
"short_name": "{{CAFE_NAME}}",
|
||||||
|
"description": "Замовлення страв на доставку та самовивіз",
|
||||||
|
"start_url": "/webapp/",
|
||||||
|
"scope": "/webapp/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0a0a0c",
|
||||||
|
"theme_color": "#0a0a0c",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/webapp/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/webapp/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
32
webapp/scripts/gen-manifest.mjs
Normal file
32
webapp/scripts/gen-manifest.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Regenerates public/manifest.json from manifest.template.json, substituting
|
||||||
|
// the cafe name read the same way Vite resolves it (.env.local overrides
|
||||||
|
// .env). Needed because Vite's native %VAR% HTML replacement only applies
|
||||||
|
// to index.html — files under public/ are copied to the build as-is, never
|
||||||
|
// processed. Falls back to "Bober BBQ" if no env file overrides it, so a
|
||||||
|
// clone with no webapp/.env.local (like the current production instance)
|
||||||
|
// produces byte-identical output.
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
|
||||||
|
function readEnvFile(path) {
|
||||||
|
if (!existsSync(path)) return {};
|
||||||
|
const vars = {};
|
||||||
|
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
||||||
|
}
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = { ...readEnvFile(join(root, ".env")), ...readEnvFile(join(root, ".env.local")) };
|
||||||
|
const cafeName = merged.VITE_CAFE_NAME || "Bober BBQ";
|
||||||
|
|
||||||
|
const template = readFileSync(join(root, "public", "manifest.template.json"), "utf-8");
|
||||||
|
writeFileSync(join(root, "public", "manifest.json"), template.replaceAll("{{CAFE_NAME}}", cafeName));
|
||||||
|
console.log(`manifest.json generated (VITE_CAFE_NAME="${cafeName}")`);
|
||||||
Loading…
Add table
Reference in a new issue