Merge branch 'develop_ntfy_brin_advisor'
- install NTFY for notification for mobile (WIP) - install script to validate brin psql and do advisor - can deploy ssh remote with TODO - TODO with command erase database
This commit is contained in:
commit
54730243d5
8 changed files with 1399 additions and 0 deletions
1
Makefile
1
Makefile
|
|
@ -47,6 +47,7 @@ endif
|
|||
-include ./conf/make.installation.Makefile
|
||||
-include ./conf/make.installation.poetry.Makefile
|
||||
-include ./conf/make.test.Makefile
|
||||
-include ./conf/make.ssh.Makefile
|
||||
-include ./conf/make.todo.Makefile
|
||||
|
||||
# Include private Makefile
|
||||
|
|
|
|||
130
conf/make.ssh.Makefile
Normal file
130
conf/make.ssh.Makefile
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
#######
|
||||
# SSH #
|
||||
#######
|
||||
|
||||
# Connection variables (override on command line or via environment)
|
||||
SSH_HOST ?=
|
||||
SSH_USER ?= erplibre
|
||||
SSH_PORT ?= 22
|
||||
SSH_KEY ?=
|
||||
SSH_PATH ?= ~/erplibre_deploy_2
|
||||
|
||||
# Target to run remotely via ssh_make
|
||||
SSH_TARGET ?= run
|
||||
|
||||
# Build SSH/rsync options from variables
|
||||
_SSH_KEY_OPT = $(if $(SSH_KEY),-i $(SSH_KEY),)
|
||||
_SSH_CMD = ssh -p $(SSH_PORT) -o StrictHostKeyChecking=accept-new $(_SSH_KEY_OPT)
|
||||
_RSYNC_SSH = ssh -p $(SSH_PORT) -o StrictHostKeyChecking=accept-new $(_SSH_KEY_OPT)
|
||||
|
||||
define _require_host
|
||||
@test -n "$(SSH_HOST)" || \
|
||||
(echo "Error: SSH_HOST is required. Usage: make $@ SSH_HOST=hostname [SSH_USER=erplibre] [SSH_PORT=22] [SSH_KEY=~/.ssh/id_rsa]" && exit 1)
|
||||
endef
|
||||
|
||||
# Test SSH connectivity
|
||||
.PHONY: ssh_check
|
||||
ssh_check:
|
||||
$(call _require_host)
|
||||
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "echo 'SSH connection to $(SSH_HOST) OK && uname -a'"
|
||||
|
||||
# Sync project files to remote (excludes venvs, addons, odoo sources, git)
|
||||
.PHONY: ssh_push
|
||||
ssh_push:
|
||||
$(call _require_host)
|
||||
rsync -avz --delete \
|
||||
--exclude='.venv.*/' \
|
||||
--exclude='addons/' \
|
||||
--exclude='odoo12.0/' \
|
||||
--exclude='odoo13.0/' \
|
||||
--exclude='odoo14.0/' \
|
||||
--exclude='odoo15.0/' \
|
||||
--exclude='odoo16.0/' \
|
||||
--exclude='odoo17.0/' \
|
||||
--exclude='odoo18.0/' \
|
||||
--exclude='.git/' \
|
||||
--exclude='private/' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='__pycache__/' \
|
||||
-e "$(_RSYNC_SSH)" \
|
||||
./ $(SSH_USER)@$(SSH_HOST):$(SSH_PATH)/
|
||||
|
||||
# Install ERPLibre on remote server (Odoo 18 default)
|
||||
.PHONY: ssh_install
|
||||
ssh_install:
|
||||
$(call _require_host)
|
||||
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "cd $(SSH_PATH) && make install_odoo_18"
|
||||
|
||||
# Start Odoo on remote server
|
||||
.PHONY: ssh_run
|
||||
ssh_run:
|
||||
$(call _require_host)
|
||||
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "cd $(SSH_PATH) && make run"
|
||||
|
||||
# Stop Odoo on remote server (via systemd if available, else kill process)
|
||||
.PHONY: ssh_stop
|
||||
ssh_stop:
|
||||
$(call _require_host)
|
||||
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||
"systemctl --user stop erplibre 2>/dev/null || \
|
||||
sudo systemctl stop erplibre 2>/dev/null || \
|
||||
cd $(SSH_PATH) && make process_kill_odoo"
|
||||
|
||||
# Restart Odoo on remote server
|
||||
.PHONY: ssh_restart
|
||||
ssh_restart:
|
||||
$(call _require_host)
|
||||
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||
"systemctl --user restart erplibre 2>/dev/null || \
|
||||
sudo systemctl restart erplibre 2>/dev/null || \
|
||||
(cd $(SSH_PATH) && make process_kill_odoo && make run)"
|
||||
|
||||
# Show service status on remote server
|
||||
.PHONY: ssh_status
|
||||
ssh_status:
|
||||
$(call _require_host)
|
||||
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||
"systemctl --user status erplibre 2>/dev/null || \
|
||||
sudo systemctl status erplibre 2>/dev/null || \
|
||||
ps aux | grep odoo | grep -v grep"
|
||||
|
||||
# Stream logs from remote server
|
||||
.PHONY: ssh_logs
|
||||
ssh_logs:
|
||||
$(call _require_host)
|
||||
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||
"journalctl --user -u erplibre -f 2>/dev/null || \
|
||||
sudo journalctl -u erplibre -f"
|
||||
|
||||
# Execute any make target on remote server (e.g.: make ssh_make SSH_HOST=host SSH_TARGET=db_create_db_test)
|
||||
.PHONY: ssh_make
|
||||
ssh_make:
|
||||
$(call _require_host)
|
||||
@test -n "$(SSH_TARGET)" || \
|
||||
(echo "Error: SSH_TARGET is required. Usage: make ssh_make SSH_HOST=hostname SSH_TARGET=make_target" && exit 1)
|
||||
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) "cd $(SSH_PATH) && make $(SSH_TARGET)"
|
||||
|
||||
# Install systemd service on remote server
|
||||
.PHONY: ssh_install_systemd
|
||||
ssh_install_systemd:
|
||||
$(call _require_host)
|
||||
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||
"cd $(SSH_PATH) && python3 script/systemd/install_daemon.py \
|
||||
--user $(SSH_USER) \
|
||||
--home-erplibre $(SSH_PATH) \
|
||||
--port 8069"
|
||||
|
||||
# Configure nginx + SSL on remote server (requires SSH_DOMAIN)
|
||||
SSH_DOMAIN ?=
|
||||
SSH_ADMIN_EMAIL ?=
|
||||
.PHONY: ssh_install_nginx
|
||||
ssh_install_nginx:
|
||||
$(call _require_host)
|
||||
@test -n "$(SSH_DOMAIN)" || \
|
||||
(echo "Error: SSH_DOMAIN is required. Usage: make ssh_install_nginx SSH_HOST=hostname SSH_DOMAIN=example.com [SSH_ADMIN_EMAIL=admin@example.com]" && exit 1)
|
||||
$(_SSH_CMD) $(SSH_USER)@$(SSH_HOST) \
|
||||
"cd $(SSH_PATH) && sudo python3 script/nginx/deploy_nginx_and_certbot.py \
|
||||
--generate_nginx \
|
||||
--run_certbot \
|
||||
--domain $(SSH_DOMAIN) \
|
||||
$(if $(SSH_ADMIN_EMAIL),--admin_email $(SSH_ADMIN_EMAIL),)"
|
||||
303
script/install/install_ntfy.sh
Executable file
303
script/install/install_ntfy.sh
Executable file
|
|
@ -0,0 +1,303 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install NTFY self-hosted push notification server
|
||||
# https://github.com/binwiederhier/ntfy
|
||||
# Supports: Ubuntu 20.04+, Debian 11+, Arch Linux (and derivatives)
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash install_ntfy.sh
|
||||
# sudo NTFY_PORT=8080 NTFY_BASE_URL=http://192.168.1.100:8080 bash install_ntfy.sh
|
||||
|
||||
set -e
|
||||
|
||||
NTFY_PORT="${NTFY_PORT:-8080}"
|
||||
NTFY_BASE_URL="${NTFY_BASE_URL:-http://localhost:${NTFY_PORT}}"
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
CONFIG_DIR="/etc/ntfy"
|
||||
CACHE_DIR="/var/cache/ntfy"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
log() { echo "[NTFY] $*"; }
|
||||
die() { echo "[NTFY] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
check_root() {
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
die "This script must be run as root. Use: sudo bash $0"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
OS="${ID}"
|
||||
OS_LIKE="${ID_LIKE:-}"
|
||||
else
|
||||
die "Cannot detect OS (no /etc/os-release)"
|
||||
fi
|
||||
log "Detected OS: ${OS}"
|
||||
}
|
||||
|
||||
is_ubuntu_like() {
|
||||
case "$OS" in
|
||||
ubuntu|debian|linuxmint|pop|elementary|raspbian) return 0 ;;
|
||||
*) echo "$OS_LIKE" | grep -qE "debian|ubuntu" && return 0 || return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_arch_like() {
|
||||
case "$OS" in
|
||||
arch|manjaro|endeavouros|artix|garuda) return 0 ;;
|
||||
*) echo "$OS_LIKE" | grep -q "arch" && return 0 || return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Get latest ntfy version from GitHub
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
get_latest_version() {
|
||||
local ver
|
||||
ver=$(curl -fsSL \
|
||||
"https://api.github.com/repos/binwiederhier/ntfy/releases/latest" \
|
||||
2>/dev/null | grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/' | head -1)
|
||||
if [ -z "$ver" ]; then
|
||||
# Fallback to a known stable version if GitHub API is unreachable
|
||||
ver="2.11.0"
|
||||
log "Warning: could not reach GitHub API, falling back to v${ver}"
|
||||
fi
|
||||
echo "$ver"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installation methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
install_deb() {
|
||||
local version="$1"
|
||||
local arch
|
||||
arch=$(dpkg --print-architecture) # amd64, arm64, armhf, ...
|
||||
|
||||
local deb_file="ntfy_${version}_linux_${arch}.deb"
|
||||
local url="https://github.com/binwiederhier/ntfy/releases/download/v${version}/${deb_file}"
|
||||
local tmp
|
||||
tmp=$(mktemp -d)
|
||||
|
||||
log "Downloading ${deb_file}..."
|
||||
if ! curl -fsSL "$url" -o "${tmp}/${deb_file}"; then
|
||||
log "Warning: .deb not found for arch=${arch}, trying binary install..."
|
||||
rm -rf "$tmp"
|
||||
install_binary "$version"
|
||||
return
|
||||
fi
|
||||
|
||||
log "Installing .deb package..."
|
||||
dpkg -i "${tmp}/${deb_file}" || apt-get install -f -y
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
install_binary() {
|
||||
local version="$1"
|
||||
local machine
|
||||
machine=$(uname -m)
|
||||
local arch_str="amd64"
|
||||
case "$machine" in
|
||||
aarch64|arm64) arch_str="arm64" ;;
|
||||
armv7l|armhf) arch_str="armv7" ;;
|
||||
esac
|
||||
|
||||
local tarball="ntfy_${version}_linux_${arch_str}.tar.gz"
|
||||
local url="https://github.com/binwiederhier/ntfy/releases/download/v${version}/${tarball}"
|
||||
local tmp
|
||||
tmp=$(mktemp -d)
|
||||
|
||||
log "Downloading ${tarball}..."
|
||||
curl -fsSL "$url" -o "${tmp}/${tarball}" || \
|
||||
die "Failed to download ntfy from GitHub. Check your internet connection."
|
||||
tar -xzf "${tmp}/${tarball}" -C "$tmp"
|
||||
|
||||
local binary
|
||||
binary=$(find "$tmp" -name "ntfy" -type f | head -1)
|
||||
[ -n "$binary" ] || die "Binary not found in archive"
|
||||
|
||||
install -m 755 "$binary" "${INSTALL_DIR}/ntfy"
|
||||
log "ntfy binary installed to ${INSTALL_DIR}/ntfy"
|
||||
rm -rf "$tmp"
|
||||
|
||||
# Install systemd service manually
|
||||
create_systemd_service
|
||||
}
|
||||
|
||||
install_arch_aur() {
|
||||
local aur_pkg="ntfy"
|
||||
|
||||
if command -v yay &>/dev/null; then
|
||||
log "Installing via yay (AUR)..."
|
||||
sudo -u "${SUDO_USER:-$USER}" yay -S --noconfirm "$aur_pkg"
|
||||
elif command -v paru &>/dev/null; then
|
||||
log "Installing via paru (AUR)..."
|
||||
sudo -u "${SUDO_USER:-$USER}" paru -S --noconfirm "$aur_pkg"
|
||||
else
|
||||
log "No AUR helper found (yay/paru). Falling back to binary install..."
|
||||
local version
|
||||
version=$(get_latest_version)
|
||||
install_binary "$version"
|
||||
fi
|
||||
}
|
||||
|
||||
create_systemd_service() {
|
||||
cat > /etc/systemd/system/ntfy.service <<'SYSTEMD'
|
||||
[Unit]
|
||||
Description=ntfy push notification server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/ntfy serve
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=ntfy
|
||||
Group=ntfy
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/cache/ntfy /etc/ntfy
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SYSTEMD
|
||||
|
||||
# Create dedicated system user if it doesn't exist
|
||||
if ! id ntfy &>/dev/null; then
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin ntfy
|
||||
fi
|
||||
|
||||
log "Systemd service created."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
configure_ntfy() {
|
||||
log "Configuring NTFY..."
|
||||
mkdir -p "$CONFIG_DIR" "$CACHE_DIR"
|
||||
|
||||
# Set ownership for cache dir if ntfy user exists
|
||||
if id ntfy &>/dev/null; then
|
||||
chown ntfy:ntfy "$CACHE_DIR"
|
||||
fi
|
||||
|
||||
if [ -f "${CONFIG_DIR}/server.yml" ]; then
|
||||
log "Config already exists at ${CONFIG_DIR}/server.yml — skipping."
|
||||
return
|
||||
fi
|
||||
|
||||
cat > "${CONFIG_DIR}/server.yml" <<YAML
|
||||
# NTFY Server configuration
|
||||
# Documentation: https://ntfy.sh/docs/config/
|
||||
|
||||
# Public URL of this server (used in notification links)
|
||||
base-url: "${NTFY_BASE_URL}"
|
||||
|
||||
# Listening address — change to ":443" + TLS for production
|
||||
listen-http: ":${NTFY_PORT}"
|
||||
|
||||
# Message cache (enables message history for reconnecting subscribers)
|
||||
cache-file: "${CACHE_DIR}/cache.db"
|
||||
cache-duration: "12h"
|
||||
|
||||
log-level: info
|
||||
|
||||
# Attachment support
|
||||
attachment-cache-dir: "${CACHE_DIR}/attachments"
|
||||
attachment-total-size-limit: "5G"
|
||||
attachment-file-size-limit: "15M"
|
||||
attachment-expiry-duration: "3h"
|
||||
|
||||
# ─── Authentication (optional) ───────────────────────────────────────────────
|
||||
# Uncomment and run "ntfy user add <user>" to require login.
|
||||
# auth-file: "${CONFIG_DIR}/user.db"
|
||||
# auth-default-access: "deny-all"
|
||||
YAML
|
||||
log "Configuration written to ${CONFIG_DIR}/server.yml"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enable & start service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
enable_service() {
|
||||
log "Enabling and starting ntfy service..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable ntfy
|
||||
systemctl restart ntfy
|
||||
sleep 1
|
||||
systemctl is-active ntfy --quiet && log "ntfy is running." \
|
||||
|| log "Warning: ntfy may not have started. Run: systemctl status ntfy"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
main() {
|
||||
check_root
|
||||
detect_os
|
||||
|
||||
if command -v ntfy &>/dev/null && ntfy version &>/dev/null 2>&1; then
|
||||
log "ntfy is already installed: $(ntfy version 2>/dev/null | head -1)"
|
||||
log "Reconfiguring and restarting..."
|
||||
configure_ntfy
|
||||
enable_service
|
||||
elif is_ubuntu_like; then
|
||||
log "Using Ubuntu/Debian install path..."
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends curl ca-certificates
|
||||
local version
|
||||
version=$(get_latest_version)
|
||||
log "Installing ntfy v${version}..."
|
||||
install_deb "$version"
|
||||
configure_ntfy
|
||||
enable_service
|
||||
elif is_arch_like; then
|
||||
log "Using Arch Linux install path..."
|
||||
install_arch_aur
|
||||
configure_ntfy
|
||||
enable_service
|
||||
else
|
||||
log "Unknown OS '${OS}', attempting generic binary install..."
|
||||
apt-get install -y --no-install-recommends curl ca-certificates 2>/dev/null \
|
||||
|| pacman -S --noconfirm curl ca-certificates 2>/dev/null \
|
||||
|| true
|
||||
local version
|
||||
version=$(get_latest_version)
|
||||
install_binary "$version"
|
||||
configure_ntfy
|
||||
enable_service
|
||||
fi
|
||||
|
||||
local ip
|
||||
ip=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "127.0.0.1")
|
||||
|
||||
echo ""
|
||||
echo "======================================================="
|
||||
echo " NTFY push notification server installed successfully!"
|
||||
echo "======================================================="
|
||||
echo " Local URL : http://localhost:${NTFY_PORT}"
|
||||
echo " Network URL: http://${ip}:${NTFY_PORT}"
|
||||
echo " Config : ${CONFIG_DIR}/server.yml"
|
||||
echo "-------------------------------------------------------"
|
||||
echo " Test (send a notification):"
|
||||
echo " curl -d 'Hello World' http://localhost:${NTFY_PORT}/my-topic"
|
||||
echo ""
|
||||
echo " Subscribe on mobile (ntfy app):"
|
||||
echo " 1. Install 'ntfy' from Google Play / F-Droid / App Store"
|
||||
echo " 2. Add server: http://${ip}:${NTFY_PORT}"
|
||||
echo " 3. Subscribe to topic: my-topic"
|
||||
echo "======================================================="
|
||||
}
|
||||
|
||||
main "$@"
|
||||
191
script/shell_script_odoo/brin_advisor.py
Normal file
191
script/shell_script_odoo/brin_advisor.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
brin_advisor.py — Recommends the optimal index type for an Odoo 18 model
|
||||
|
||||
Usage (via odoo-bin shell):
|
||||
source .venv/bin/activate
|
||||
echo "MODEL='account.move.line'" | cat - brin_advisor.py \
|
||||
| odoo-bin shell -d my_database --no-http
|
||||
|
||||
# Or with export:
|
||||
export MODEL=mail.message
|
||||
cat brin_advisor.py | odoo-bin shell -d my_database --no-http
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
DATE_COLUMNS = [
|
||||
"date",
|
||||
"create_date",
|
||||
"write_date",
|
||||
"invoice_date",
|
||||
"scheduled_date",
|
||||
"date_done",
|
||||
"in_date",
|
||||
"expiration_date",
|
||||
]
|
||||
|
||||
THRESHOLDS = [
|
||||
(0.9, "✅ BRIN ideal", "\033[0;32m"),
|
||||
(0.5, "⚠️ BRIN acceptable", "\033[1;33m"),
|
||||
(0.1, "❌ Prefer B-tree", "\033[0;31m"),
|
||||
(-1.0, "❌ B-tree mandatory", "\033[0;31m"),
|
||||
]
|
||||
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
CYAN = "\033[0;36m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
RED = "\033[0;31m"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def recommend(correlation):
|
||||
for threshold, label, color in THRESHOLDS:
|
||||
if correlation >= threshold:
|
||||
return label, color
|
||||
return THRESHOLDS[-1][1], THRESHOLDS[-1][2]
|
||||
|
||||
|
||||
def model_to_table(model_name):
|
||||
"""
|
||||
Resolve model name to table via the Odoo ORM — more reliable than a plain
|
||||
replace('.', '_') since some models override _table explicitly.
|
||||
"""
|
||||
if model_name not in env:
|
||||
# Fallback: standard Odoo naming convention
|
||||
return model_name.replace(".", "_")
|
||||
return env[model_name]._table
|
||||
|
||||
|
||||
# ── Model resolution ──────────────────────────────────────────────────────────
|
||||
# Priority: MODEL variable defined before the pipe, then environment variable
|
||||
model_name = (
|
||||
globals().get("MODEL") or os.environ.get("MODEL") or "account.move.line"
|
||||
)
|
||||
table = model_to_table(model_name)
|
||||
cr = env.cr # PostgreSQL cursor provided by odoo-bin shell
|
||||
|
||||
print(f"\n{BOLD}{CYAN}=== BRIN Advisor for: {model_name} ==={RESET}\n")
|
||||
print(f" Model : {model_name}")
|
||||
print(f" Table : {table}\n")
|
||||
|
||||
# ── Check table exists ────────────────────────────────────────────────────────
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
""",
|
||||
(table,),
|
||||
)
|
||||
if cr.fetchone()[0] == 0:
|
||||
print(f"{RED}❌ Table '{table}' not found in the database.{RESET}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
# ── Automatic ANALYZE ─────────────────────────────────────────────────────────
|
||||
# odoo-bin shell runs inside an open transaction — ANALYZE is not allowed
|
||||
# inside a transaction, so we temporarily switch to AUTOCOMMIT.
|
||||
print(
|
||||
f" {YELLOW}⟳ Running ANALYZE on {table}...{RESET}", end=" ", flush=True
|
||||
)
|
||||
with env.cr._cnx.cursor() as auto_cr:
|
||||
old_isolation = env.cr._cnx.isolation_level
|
||||
env.cr._cnx.set_isolation_level(0) # AUTOCOMMIT
|
||||
auto_cr.execute(
|
||||
f"ANALYZE {table}"
|
||||
) # table name validated via ORM, no injection risk
|
||||
env.cr._cnx.set_isolation_level(old_isolation)
|
||||
print(f"{GREEN}OK{RESET}\n")
|
||||
|
||||
# ── Table size ────────────────────────────────────────────────────────────────
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT
|
||||
pg_size_pretty(pg_total_relation_size(%s::regclass)),
|
||||
reltuples::bigint
|
||||
FROM pg_class WHERE relname = %s
|
||||
""",
|
||||
(table, table),
|
||||
)
|
||||
row = cr.fetchone()
|
||||
if row:
|
||||
size_pretty, row_count = row
|
||||
print(
|
||||
f" Size : {BOLD}{size_pretty}{RESET} (~{row_count:,} estimated rows)\n"
|
||||
)
|
||||
|
||||
# ── Correlation analysis ──────────────────────────────────────────────────────
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT attname, correlation
|
||||
FROM pg_stats
|
||||
WHERE tablename = %s
|
||||
AND attname = ANY(%s)
|
||||
ORDER BY ABS(correlation) DESC NULLS LAST
|
||||
""",
|
||||
(table, DATE_COLUMNS),
|
||||
)
|
||||
stats = cr.fetchall()
|
||||
|
||||
print(f"{BOLD}Date columns found:{RESET}")
|
||||
print("─" * 70)
|
||||
print(f" {'COLUMN':<25} {'CORRELATION':<14} {'IDX SIZE':<12} RECOMMENDATION")
|
||||
print("─" * 70)
|
||||
|
||||
if not stats:
|
||||
print(f" {YELLOW}No standard date columns found on this table.{RESET}")
|
||||
else:
|
||||
for col, corr in stats:
|
||||
if corr is None:
|
||||
continue
|
||||
# Size of existing BRIN index on this column, if any
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT pg_size_pretty(pg_relation_size(idx.indexrelid))
|
||||
FROM pg_index idx
|
||||
JOIN pg_class i ON i.oid = idx.indexrelid
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = %s::regclass
|
||||
AND a.attnum = ANY(idx.indkey)
|
||||
AND a.attname = %s
|
||||
JOIN pg_am am ON am.oid = i.relam AND am.amname = 'brin'
|
||||
WHERE idx.indrelid = %s::regclass
|
||||
LIMIT 1
|
||||
""",
|
||||
(table, col, table),
|
||||
)
|
||||
idx_row = cr.fetchone()
|
||||
idx_size = idx_row[0] if idx_row else "none"
|
||||
|
||||
label, color = recommend(corr)
|
||||
print(
|
||||
f" {col:<25} {corr:<14.4f} {idx_size:<12} {color}{label}{RESET}"
|
||||
)
|
||||
|
||||
print("─" * 70)
|
||||
|
||||
# ── Existing BRIN indexes ─────────────────────────────────────────────────────
|
||||
print(f"\n{BOLD}Existing BRIN indexes:{RESET}")
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = %s
|
||||
AND indexdef LIKE '%%USING brin%%'
|
||||
""",
|
||||
(table,),
|
||||
)
|
||||
brin_indexes = cr.fetchall()
|
||||
|
||||
if not brin_indexes:
|
||||
print(f" {YELLOW}No BRIN indexes found on this table.{RESET}")
|
||||
else:
|
||||
for iname, _ in brin_indexes:
|
||||
print(f" {GREEN}✔{RESET} {iname}")
|
||||
|
||||
print()
|
||||
286
script/shell_script_odoo/brin_cluster.py
Normal file
286
script/shell_script_odoo/brin_cluster.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
brin_cluster.py — Reorders table rows physically to optimize BRIN indexes.
|
||||
|
||||
Strategy:
|
||||
1. Check correlation of candidate columns via pg_stats
|
||||
2. Pick the best column (highest absolute correlation, ideally create_date)
|
||||
3. Create a temporary B-tree index on that column
|
||||
4. CLUSTER the table on that index (rewrites rows in physical order)
|
||||
5. ANALYZE to refresh statistics
|
||||
6. Drop the temporary B-tree index
|
||||
7. Report the new correlation
|
||||
|
||||
WARNING:
|
||||
- CLUSTER acquires an ACCESS EXCLUSIVE lock — the table is fully locked.
|
||||
- On large tables, this can take minutes to hours.
|
||||
- Run during a maintenance window or off-peak hours.
|
||||
- Always pg_dump before running on production.
|
||||
|
||||
Usage:
|
||||
export MODEL=account.move.line
|
||||
cat brin_cluster.py | odoo-bin shell -d my_database --no-http
|
||||
|
||||
# Dry run (no changes, just shows what would be done):
|
||||
export MODEL=account.move.line DRY_RUN=1
|
||||
cat brin_cluster.py | odoo-bin shell -d my_database --no-http
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Columns considered as clustering candidates, in priority order.
|
||||
# create_date is first: it's never updated, so correlation is always perfect.
|
||||
CANDIDATE_COLUMNS = [
|
||||
"create_date",
|
||||
"date",
|
||||
"invoice_date",
|
||||
"write_date",
|
||||
"scheduled_date",
|
||||
"date_done",
|
||||
"in_date",
|
||||
]
|
||||
|
||||
BRIN_IDEAL_THRESHOLD = 0.9
|
||||
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
CYAN = "\033[0;36m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
RED = "\033[0;31m"
|
||||
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
model_name = (
|
||||
globals().get("MODEL") or os.environ.get("MODEL") or "account.move.line"
|
||||
)
|
||||
dry_run = bool(globals().get("DRY_RUN") or os.environ.get("DRY_RUN"))
|
||||
|
||||
if model_name not in env:
|
||||
table = model_name.replace(".", "_")
|
||||
else:
|
||||
table = env[model_name]._table
|
||||
|
||||
cr = env.cr
|
||||
cnx = env.cr._cnx
|
||||
|
||||
print(f"\n{BOLD}{CYAN}=== BRIN Cluster for: {model_name} ==={RESET}")
|
||||
print(f" Table : {table}")
|
||||
print(f" Dry run : {dry_run}\n")
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def autocommit(sql, params=None):
|
||||
"""Execute a DDL statement outside the current transaction (AUTOCOMMIT)."""
|
||||
old = cnx.isolation_level
|
||||
cnx.set_isolation_level(0)
|
||||
with cnx.cursor() as c:
|
||||
c.execute(sql, params)
|
||||
cnx.set_isolation_level(old)
|
||||
|
||||
|
||||
def get_correlations():
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT attname, correlation
|
||||
FROM pg_stats
|
||||
WHERE tablename = %s
|
||||
AND attname = ANY(%s)
|
||||
AND correlation IS NOT NULL
|
||||
ORDER BY ABS(correlation) DESC
|
||||
""",
|
||||
(table, CANDIDATE_COLUMNS),
|
||||
)
|
||||
return {row[0]: row[1] for row in cr.fetchall()}
|
||||
|
||||
|
||||
def table_size():
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT
|
||||
pg_size_pretty(pg_total_relation_size(%s::regclass)),
|
||||
reltuples::bigint
|
||||
FROM pg_class WHERE relname = %s
|
||||
""",
|
||||
(table, table),
|
||||
)
|
||||
return cr.fetchone()
|
||||
|
||||
|
||||
def index_exists(index_name):
|
||||
cr.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM pg_indexes
|
||||
WHERE tablename = %s AND indexname = %s
|
||||
""",
|
||||
(table, index_name),
|
||||
)
|
||||
return cr.fetchone()[0] > 0
|
||||
|
||||
|
||||
# ── Step 1: ANALYZE to get fresh stats ───────────────────────────────────────
|
||||
|
||||
print(f" {YELLOW}⟳ ANALYZE {table}...{RESET}", end=" ", flush=True)
|
||||
autocommit(f"ANALYZE {table}")
|
||||
print(f"{GREEN}OK{RESET}\n")
|
||||
|
||||
size_pretty, row_count = table_size()
|
||||
print(
|
||||
f" Size : {BOLD}{size_pretty}{RESET} (~{row_count:,} estimated rows)\n"
|
||||
)
|
||||
|
||||
# ── Step 2: Check current correlations ───────────────────────────────────────
|
||||
|
||||
correlations = get_correlations()
|
||||
|
||||
if not correlations:
|
||||
print(f"{RED}❌ No date columns found on {table}. Aborting.{RESET}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
print(f"{BOLD}Current correlations:{RESET}")
|
||||
for col, corr in sorted(correlations.items(), key=lambda x: -abs(x[1])):
|
||||
if abs(corr) >= BRIN_IDEAL_THRESHOLD:
|
||||
icon = f"{GREEN}✅ BRIN ideal{RESET}"
|
||||
else:
|
||||
icon = f"{YELLOW}⚠️ needs clustering{RESET}"
|
||||
print(f" {col:<25} {corr:+.4f} {icon}")
|
||||
|
||||
# ── Step 3: Pick the best clustering column ───────────────────────────────────
|
||||
|
||||
# Only cluster if at least one *candidate* column is suboptimal.
|
||||
# Note: a column like `date` (user-editable) may never reach ideal correlation
|
||||
# regardless of clustering — that's expected and not a reason to cluster.
|
||||
suboptimal = {
|
||||
col: corr
|
||||
for col, corr in correlations.items()
|
||||
if abs(corr) < BRIN_IDEAL_THRESHOLD
|
||||
}
|
||||
ideal = {
|
||||
col: corr
|
||||
for col, corr in correlations.items()
|
||||
if abs(corr) >= BRIN_IDEAL_THRESHOLD
|
||||
}
|
||||
|
||||
if suboptimal:
|
||||
print(f"\n {YELLOW}⚠️ Suboptimal columns: {', '.join(suboptimal)}{RESET}")
|
||||
print(
|
||||
f" {CYAN}Note: columns like 'date' or 'invoice_date' may stay suboptimal"
|
||||
)
|
||||
print(
|
||||
f" after clustering if users enter backdated records — that's expected.{RESET}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"\n{GREEN}✅ All columns already have ideal correlation. No clustering needed.{RESET}\n"
|
||||
)
|
||||
raise SystemExit(0)
|
||||
|
||||
# Pick the best clustering column: highest absolute correlation among candidates.
|
||||
# Prefer create_date first (never updated → guaranteed monotonic order),
|
||||
# then fall back to the column with highest abs(correlation).
|
||||
cluster_col = None
|
||||
for preferred in CANDIDATE_COLUMNS:
|
||||
if preferred in ideal: # already ideal → good anchor for physical order
|
||||
cluster_col = preferred
|
||||
break
|
||||
if not cluster_col:
|
||||
# All candidates are suboptimal — pick the least bad one
|
||||
cluster_col = max(suboptimal, key=lambda c: abs(suboptimal[c]))
|
||||
|
||||
if not cluster_col:
|
||||
print(f"{RED}❌ No suitable clustering column found. Aborting.{RESET}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
print(
|
||||
f"\n {BOLD}Clustering column chosen:{RESET} {cluster_col} "
|
||||
f"(current correlation: {correlations[cluster_col]:+.4f})\n"
|
||||
)
|
||||
|
||||
# ── Step 4: Create temporary B-tree index ────────────────────────────────────
|
||||
|
||||
tmp_index = f"_brin_cluster_tmp_{table}_{cluster_col}"
|
||||
|
||||
if index_exists(tmp_index):
|
||||
print(
|
||||
f" {YELLOW}⚠️ Temporary index already exists, dropping it first...{RESET}"
|
||||
)
|
||||
if not dry_run:
|
||||
autocommit(f'DROP INDEX IF EXISTS "{tmp_index}"')
|
||||
|
||||
print(
|
||||
f" {YELLOW}⟳ Creating temporary B-tree index on {cluster_col}...{RESET}",
|
||||
end=" ",
|
||||
flush=True,
|
||||
)
|
||||
if not dry_run:
|
||||
autocommit(f'CREATE INDEX "{tmp_index}" ON "{table}" ("{cluster_col}")')
|
||||
print(f"{GREEN}OK{RESET}")
|
||||
else:
|
||||
print(f"{CYAN}[DRY RUN]{RESET}")
|
||||
|
||||
# ── Step 5: CLUSTER ───────────────────────────────────────────────────────────
|
||||
|
||||
print(f"\n {YELLOW}⟳ CLUSTER {table} on {cluster_col}...{RESET}")
|
||||
print(
|
||||
f" {YELLOW} (ACCESS EXCLUSIVE lock — table unavailable during this operation){RESET}",
|
||||
end=" ",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
t0 = time.time()
|
||||
autocommit(f'CLUSTER "{table}" USING "{tmp_index}"')
|
||||
elapsed = time.time() - t0
|
||||
print(f"{GREEN}OK{RESET} ({elapsed:.1f}s)")
|
||||
else:
|
||||
print(f"{CYAN}[DRY RUN]{RESET}")
|
||||
|
||||
# ── Step 6: Drop temporary index ─────────────────────────────────────────────
|
||||
|
||||
print(
|
||||
f"\n {YELLOW}⟳ Dropping temporary index...{RESET}", end=" ", flush=True
|
||||
)
|
||||
if not dry_run:
|
||||
autocommit(f'DROP INDEX IF EXISTS "{tmp_index}"')
|
||||
print(f"{GREEN}OK{RESET}")
|
||||
else:
|
||||
print(f"{CYAN}[DRY RUN]{RESET}")
|
||||
|
||||
# ── Step 7: ANALYZE again + report new correlations ──────────────────────────
|
||||
|
||||
print(
|
||||
f"\n {YELLOW}⟳ ANALYZE after clustering...{RESET}", end=" ", flush=True
|
||||
)
|
||||
if not dry_run:
|
||||
autocommit(f"ANALYZE {table}")
|
||||
print(f"{GREEN}OK{RESET}\n")
|
||||
|
||||
new_correlations = get_correlations()
|
||||
|
||||
print(f"{BOLD}Correlations after clustering:{RESET}")
|
||||
print("─" * 55)
|
||||
print(f" {'COLUMN':<25} {'BEFORE':>8} {'AFTER':>8} RESULT")
|
||||
print("─" * 55)
|
||||
for col in sorted(correlations.keys()):
|
||||
before = correlations.get(col, 0.0)
|
||||
after = new_correlations.get(col, 0.0)
|
||||
delta = after - before
|
||||
if abs(after) >= BRIN_IDEAL_THRESHOLD:
|
||||
result = f"{GREEN}✅ BRIN ideal{RESET}"
|
||||
else:
|
||||
result = f"{YELLOW}⚠️ still suboptimal{RESET}"
|
||||
arrow = "↑" if delta > 0.01 else ("↓" if delta < -0.01 else "→")
|
||||
print(
|
||||
f" {col:<25} {before:>+8.4f} {after:>+8.4f} {arrow} {result}"
|
||||
)
|
||||
print("─" * 55)
|
||||
else:
|
||||
print(f"{CYAN}[DRY RUN]{RESET}")
|
||||
|
||||
print(f"\n{GREEN}✅ Done.{RESET}\n")
|
||||
|
|
@ -54,6 +54,70 @@ class DatabaseManager:
|
|||
else:
|
||||
print(t("cmd_not_found"))
|
||||
|
||||
def _confirm_drop(self, message: str) -> bool:
|
||||
"""Ask for an explicit 'oui'/'yes' confirmation, default is no."""
|
||||
print(f"⚠️ {message}")
|
||||
answer = (
|
||||
input(t("Type 'oui' to confirm (default: no): ")).strip().lower()
|
||||
)
|
||||
return answer in ("oui", "yes")
|
||||
|
||||
def drop_database(self) -> None:
|
||||
print(f"⚠️ {t('Erase a database — irreversible operation!')}")
|
||||
choices = [
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Erase ALL databases (make db_drop_all)"
|
||||
)
|
||||
},
|
||||
{"prompt_description": t("Erase a single database")},
|
||||
]
|
||||
help_info = self._fill_help_info(choices)
|
||||
|
||||
while True:
|
||||
status = click.prompt(help_info)
|
||||
print()
|
||||
if status == "0":
|
||||
return
|
||||
elif status == "1":
|
||||
self._drop_all_databases()
|
||||
return
|
||||
elif status == "2":
|
||||
self._drop_single_database()
|
||||
return
|
||||
else:
|
||||
print(t("Command not found !"))
|
||||
|
||||
def _drop_all_databases(self) -> None:
|
||||
if not self._confirm_drop(
|
||||
t("You are about to erase ALL databases. This cannot be undone.")
|
||||
):
|
||||
print(t("Database deletion cancelled."))
|
||||
return
|
||||
self._execute.exec_command_live(
|
||||
"make db_drop_all",
|
||||
source_erplibre=False,
|
||||
single_source_erplibre=True,
|
||||
)
|
||||
|
||||
def _drop_single_database(self) -> None:
|
||||
database_name = self.select_database()
|
||||
if not database_name:
|
||||
print(t("No database selected."))
|
||||
return
|
||||
message = t(
|
||||
"You are about to erase the database '{database}'."
|
||||
" This cannot be undone."
|
||||
).format(database=database_name)
|
||||
if not self._confirm_drop(message):
|
||||
print(t("Database deletion cancelled."))
|
||||
return
|
||||
self._execute.exec_command_live(
|
||||
f"./odoo_bin.sh db --drop --database {database_name}",
|
||||
source_erplibre=False,
|
||||
single_source_erplibre=True,
|
||||
)
|
||||
|
||||
def restore_from_database(self, show_remote_list: bool = True) -> None:
|
||||
path_image_db = os.path.join(os.getcwd(), "image_db")
|
||||
print("[1] By filename from image_db")
|
||||
|
|
|
|||
|
|
@ -651,6 +651,22 @@ class TODO:
|
|||
choices = [
|
||||
{"prompt_description": t("Clone ERPLibre locally (git clone)")},
|
||||
{"prompt_description": t("Configure sshfs")},
|
||||
{"prompt_description": t("SSH - Check connection")},
|
||||
{"prompt_description": t("SSH - Sync files (rsync)")},
|
||||
{"prompt_description": t("SSH - Install ERPLibre")},
|
||||
{"prompt_description": t("SSH - Start Odoo")},
|
||||
{"prompt_description": t("SSH - Stop Odoo")},
|
||||
{"prompt_description": t("SSH - Restart Odoo")},
|
||||
{"prompt_description": t("SSH - Service status")},
|
||||
{"prompt_description": t("SSH - View logs")},
|
||||
{"prompt_description": t("SSH - Run make target")},
|
||||
{"prompt_description": t("SSH - Install systemd service")},
|
||||
{"prompt_description": t("SSH - Configure nginx + SSL")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Deploy - Install NTFY notification server"
|
||||
)
|
||||
},
|
||||
]
|
||||
help_info = self.fill_help_info(choices)
|
||||
|
||||
|
|
@ -663,6 +679,30 @@ class TODO:
|
|||
self._deploy_clone_erplibre()
|
||||
elif status == "2":
|
||||
self._configure_sshfs()
|
||||
elif status == "3":
|
||||
self._deploy_ssh_check()
|
||||
elif status == "4":
|
||||
self._deploy_ssh_push()
|
||||
elif status == "5":
|
||||
self._deploy_ssh_install()
|
||||
elif status == "6":
|
||||
self._deploy_ssh_run()
|
||||
elif status == "7":
|
||||
self._deploy_ssh_stop()
|
||||
elif status == "8":
|
||||
self._deploy_ssh_restart()
|
||||
elif status == "9":
|
||||
self._deploy_ssh_status()
|
||||
elif status == "10":
|
||||
self._deploy_ssh_logs()
|
||||
elif status == "11":
|
||||
self._deploy_ssh_make()
|
||||
elif status == "12":
|
||||
self._deploy_ssh_install_systemd()
|
||||
elif status == "13":
|
||||
self._deploy_ssh_install_nginx()
|
||||
elif status == "14":
|
||||
self._deploy_ntfy_server()
|
||||
else:
|
||||
print(t("Command not found !"))
|
||||
|
||||
|
|
@ -689,6 +729,54 @@ class TODO:
|
|||
except Exception as e:
|
||||
print(f"{t('Error cloning ERPLibre: ')}{e}")
|
||||
|
||||
def _deploy_ntfy_server(self):
|
||||
print(
|
||||
f"\n{t('Deploy a local NTFY push notification server (Ubuntu/Arch)')}"
|
||||
)
|
||||
port = (
|
||||
input(t("NTFY server port (default: 8080): ")).strip() or "8080"
|
||||
)
|
||||
import socket
|
||||
|
||||
hostname = socket.gethostname()
|
||||
try:
|
||||
local_ip = socket.gethostbyname(hostname)
|
||||
except Exception:
|
||||
local_ip = "127.0.0.1"
|
||||
|
||||
default_url = f"http://{local_ip}:{port}"
|
||||
base_url = (
|
||||
input(
|
||||
f"{t('NTFY base URL')} (default: {default_url}): "
|
||||
).strip()
|
||||
or default_url
|
||||
)
|
||||
|
||||
script_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..",
|
||||
"install",
|
||||
"install_ntfy.sh",
|
||||
)
|
||||
script_path = os.path.realpath(script_path)
|
||||
|
||||
if not os.path.isfile(script_path):
|
||||
print(f"{t('NTFY install script not found: ')}{script_path}")
|
||||
return
|
||||
|
||||
print(f"\n{t('Installing NTFY server (requires sudo)...')}")
|
||||
cmd = (
|
||||
f"sudo NTFY_PORT={port}"
|
||||
f" NTFY_BASE_URL={base_url}"
|
||||
f" bash {script_path}"
|
||||
)
|
||||
print(f"{t('Will execute:')} {cmd}\n")
|
||||
try:
|
||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||
print(f"\n{t('NTFY server installed and started successfully!')}")
|
||||
except Exception as e:
|
||||
print(f"{t('Error installing NTFY server: ')}{e}")
|
||||
|
||||
def _configure_sshfs(self):
|
||||
import getpass
|
||||
import re
|
||||
|
|
@ -793,6 +881,171 @@ class TODO:
|
|||
except Exception as e:
|
||||
print(f"{t('Error mounting sshfs: ')}{e}")
|
||||
|
||||
def _get_ssh_params(self):
|
||||
"""Prompt for SSH connection parameters. Returns dict or None on cancel."""
|
||||
host = click.prompt(t("Remote host (user@hostname or hostname): ")).strip()
|
||||
if not host:
|
||||
print(t("SSH host is required!"))
|
||||
return None
|
||||
user = (
|
||||
click.prompt(t("SSH user (default: erplibre): ")).strip()
|
||||
or "erplibre"
|
||||
)
|
||||
port = click.prompt(t("SSH port (default: 22): ")).strip() or "22"
|
||||
key = (
|
||||
click.prompt(
|
||||
t("SSH key path (default: ~/.ssh/id_rsa, empty for none): ")
|
||||
).strip()
|
||||
)
|
||||
path = (
|
||||
click.prompt(t("Remote path (default: ~/erplibre_deploy_2): ")).strip()
|
||||
or "~/erplibre_deploy_2"
|
||||
)
|
||||
return {
|
||||
"SSH_HOST": host,
|
||||
"SSH_USER": user,
|
||||
"SSH_PORT": port,
|
||||
"SSH_KEY": key,
|
||||
"SSH_PATH": path,
|
||||
}
|
||||
|
||||
def _build_ssh_make_cmd(self, target, params, extra=None):
|
||||
"""Build a make SSH command string from params dict."""
|
||||
parts = [f"make {target}"]
|
||||
for k, v in params.items():
|
||||
if v:
|
||||
parts.append(f'{k}="{v}"')
|
||||
if extra:
|
||||
for k, v in extra.items():
|
||||
if v:
|
||||
parts.append(f'{k}="{v}"')
|
||||
return " ".join(parts)
|
||||
|
||||
def _deploy_ssh_check(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
cmd = self._build_ssh_make_cmd("ssh_check", params)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def _deploy_ssh_push(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
cmd = self._build_ssh_make_cmd("ssh_push", params)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def _deploy_ssh_install(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
cmd = self._build_ssh_make_cmd("ssh_install", params)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def _deploy_ssh_run(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
cmd = self._build_ssh_make_cmd("ssh_run", params)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def _deploy_ssh_stop(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
cmd = self._build_ssh_make_cmd("ssh_stop", params)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def _deploy_ssh_restart(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
cmd = self._build_ssh_make_cmd("ssh_restart", params)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def _deploy_ssh_status(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
cmd = self._build_ssh_make_cmd("ssh_status", params)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def _deploy_ssh_logs(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
cmd = self._build_ssh_make_cmd("ssh_logs", params)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def _deploy_ssh_make(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
target = click.prompt(t("Make target to run remotely: ")).strip()
|
||||
if not target:
|
||||
print(t("SSH host is required!"))
|
||||
return
|
||||
cmd = self._build_ssh_make_cmd(
|
||||
"ssh_make", params, extra={"SSH_TARGET": target}
|
||||
)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def _deploy_ssh_install_systemd(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
cmd = self._build_ssh_make_cmd("ssh_install_systemd", params)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def _deploy_ssh_install_nginx(self):
|
||||
params = self._get_ssh_params()
|
||||
if not params:
|
||||
return
|
||||
domain = click.prompt(t("Domain name (e.g.: example.com): ")).strip()
|
||||
if not domain:
|
||||
print(t("SSH host is required!"))
|
||||
return
|
||||
email = click.prompt(t("Admin email for SSL certificate: ")).strip()
|
||||
cmd = self._build_ssh_make_cmd(
|
||||
"ssh_install_nginx",
|
||||
params,
|
||||
extra={"SSH_DOMAIN": domain, "SSH_ADMIN_EMAIL": email},
|
||||
)
|
||||
print(f"{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd, source_erplibre=False, single_source_erplibre=True
|
||||
)
|
||||
|
||||
def prompt_execute_code(self):
|
||||
print(f"🤖 {t('What do you need for development?')}")
|
||||
# help_info = """Commande :
|
||||
|
|
@ -1220,6 +1473,7 @@ class TODO:
|
|||
},
|
||||
{"prompt_description": t("Restore from backup (.zip)")},
|
||||
{"prompt_description": t("Create backup (.zip)")},
|
||||
{"prompt_description": t("Erase a database")},
|
||||
]
|
||||
help_info = self.fill_help_info(choices)
|
||||
|
||||
|
|
@ -1234,6 +1488,8 @@ class TODO:
|
|||
self.db_manager.restore_from_database()
|
||||
elif status == "3":
|
||||
self.db_manager.create_backup_from_database()
|
||||
elif status == "4":
|
||||
self.db_manager.drop_database()
|
||||
else:
|
||||
print(t("Command not found !"))
|
||||
|
||||
|
|
|
|||
|
|
@ -299,6 +299,52 @@ TRANSLATIONS = {
|
|||
"fr": "Faites des modifications sur les bases de données!",
|
||||
"en": "Make changes to databases!",
|
||||
},
|
||||
# Database drop / erase
|
||||
"Erase a database": {
|
||||
"fr": "Effacer une base de données",
|
||||
"en": "Erase a database",
|
||||
},
|
||||
"Erase a database — irreversible operation!": {
|
||||
"fr": "Effacer une base de données — opération irréversible !",
|
||||
"en": "Erase a database — irreversible operation!",
|
||||
},
|
||||
"Erase ALL databases (make db_drop_all)": {
|
||||
"fr": "Effacer TOUTES les bases de données (make db_drop_all)",
|
||||
"en": "Erase ALL databases (make db_drop_all)",
|
||||
},
|
||||
"Erase a single database": {
|
||||
"fr": "Effacer une seule base de données",
|
||||
"en": "Erase a single database",
|
||||
},
|
||||
"You are about to erase ALL databases. This cannot be undone.": {
|
||||
"fr": (
|
||||
"Tu es sur le point d'effacer TOUTES les bases de données."
|
||||
" C'est irréversible."
|
||||
),
|
||||
"en": "You are about to erase ALL databases. This cannot be undone.",
|
||||
},
|
||||
"You are about to erase the database '{database}'. This cannot be undone.": {
|
||||
"fr": (
|
||||
"Tu es sur le point d'effacer la base de données « {database} »."
|
||||
" C'est irréversible."
|
||||
),
|
||||
"en": (
|
||||
"You are about to erase the database '{database}'."
|
||||
" This cannot be undone."
|
||||
),
|
||||
},
|
||||
"Type 'oui' to confirm (default: no): ": {
|
||||
"fr": "Tape « oui » pour confirmer (défaut : non) : ",
|
||||
"en": "Type 'oui'/'yes' to confirm (default: no): ",
|
||||
},
|
||||
"Database deletion cancelled.": {
|
||||
"fr": "Effacement de la base de données annulé.",
|
||||
"en": "Database deletion cancelled.",
|
||||
},
|
||||
"No database selected.": {
|
||||
"fr": "Aucune base de données sélectionnée.",
|
||||
"en": "No database selected.",
|
||||
},
|
||||
"Manage execution processes!": {
|
||||
"fr": "Manipuler les processus d'exécution!",
|
||||
"en": "Manage execution processes!",
|
||||
|
|
@ -722,6 +768,95 @@ TRANSLATIONS = {
|
|||
"fr": "Erreur lors de l'ajout de l'automatisation : ",
|
||||
"en": "Error adding automation: ",
|
||||
},
|
||||
# SSH Deploy section
|
||||
"Deploy - Deploy ERPLibre via SSH": {
|
||||
"fr": "Déploiement - Déployer ERPLibre via SSH",
|
||||
"en": "Deploy - Deploy ERPLibre via SSH",
|
||||
},
|
||||
"SSH deployment tools!": {
|
||||
"fr": "Outils de déploiement SSH!",
|
||||
"en": "SSH deployment tools!",
|
||||
},
|
||||
"SSH - Check connection": {
|
||||
"fr": "SSH - Vérifier la connexion",
|
||||
"en": "SSH - Check connection",
|
||||
},
|
||||
"SSH - Sync files (rsync)": {
|
||||
"fr": "SSH - Synchroniser les fichiers (rsync)",
|
||||
"en": "SSH - Sync files (rsync)",
|
||||
},
|
||||
"SSH - Install ERPLibre": {
|
||||
"fr": "SSH - Installer ERPLibre",
|
||||
"en": "SSH - Install ERPLibre",
|
||||
},
|
||||
"SSH - Start Odoo": {
|
||||
"fr": "SSH - Démarrer Odoo",
|
||||
"en": "SSH - Start Odoo",
|
||||
},
|
||||
"SSH - Stop Odoo": {
|
||||
"fr": "SSH - Arrêter Odoo",
|
||||
"en": "SSH - Stop Odoo",
|
||||
},
|
||||
"SSH - Restart Odoo": {
|
||||
"fr": "SSH - Redémarrer Odoo",
|
||||
"en": "SSH - Restart Odoo",
|
||||
},
|
||||
"SSH - Service status": {
|
||||
"fr": "SSH - Statut du service",
|
||||
"en": "SSH - Service status",
|
||||
},
|
||||
"SSH - View logs": {
|
||||
"fr": "SSH - Voir les logs",
|
||||
"en": "SSH - View logs",
|
||||
},
|
||||
"SSH - Run make target": {
|
||||
"fr": "SSH - Exécuter une cible make",
|
||||
"en": "SSH - Run make target",
|
||||
},
|
||||
"SSH - Install systemd service": {
|
||||
"fr": "SSH - Installer le service systemd",
|
||||
"en": "SSH - Install systemd service",
|
||||
},
|
||||
"SSH - Configure nginx + SSL": {
|
||||
"fr": "SSH - Configurer nginx + SSL",
|
||||
"en": "SSH - Configure nginx + SSL",
|
||||
},
|
||||
"Remote host (user@hostname or hostname): ": {
|
||||
"fr": "Hôte distant (user@hostname ou hostname) : ",
|
||||
"en": "Remote host (user@hostname or hostname): ",
|
||||
},
|
||||
"SSH user (default: erplibre): ": {
|
||||
"fr": "Utilisateur SSH (défaut: erplibre) : ",
|
||||
"en": "SSH user (default: erplibre): ",
|
||||
},
|
||||
"SSH port (default: 22): ": {
|
||||
"fr": "Port SSH (défaut: 22) : ",
|
||||
"en": "SSH port (default: 22): ",
|
||||
},
|
||||
"SSH key path (default: ~/.ssh/id_rsa, empty for none): ": {
|
||||
"fr": "Chemin de la clé SSH (défaut: ~/.ssh/id_rsa, vide pour aucune) : ",
|
||||
"en": "SSH key path (default: ~/.ssh/id_rsa, empty for none): ",
|
||||
},
|
||||
"Remote path (default: ~/erplibre_deploy_2): ": {
|
||||
"fr": "Chemin distant (défaut: ~/erplibre_deploy_2) : ",
|
||||
"en": "Remote path (default: ~/erplibre_deploy_2): ",
|
||||
},
|
||||
"Make target to run remotely: ": {
|
||||
"fr": "Cible make à exécuter à distance : ",
|
||||
"en": "Make target to run remotely: ",
|
||||
},
|
||||
"Domain name (e.g.: example.com): ": {
|
||||
"fr": "Nom de domaine (ex: example.com) : ",
|
||||
"en": "Domain name (e.g.: example.com): ",
|
||||
},
|
||||
"Admin email for SSL certificate: ": {
|
||||
"fr": "Email administrateur pour le certificat SSL : ",
|
||||
"en": "Admin email for SSL certificate: ",
|
||||
},
|
||||
"SSH host is required!": {
|
||||
"fr": "L'hôte SSH est requis!",
|
||||
"en": "SSH host is required!",
|
||||
},
|
||||
# Language selection
|
||||
"Choose language / Choisir la langue": {
|
||||
"fr": "Choisir la langue / Choose language",
|
||||
|
|
@ -816,6 +951,39 @@ TRANSLATIONS = {
|
|||
"fr": "Erreur lors de la création du fichier : ",
|
||||
"en": "Error creating file: ",
|
||||
},
|
||||
# NTFY section
|
||||
"Deploy - Install NTFY notification server": {
|
||||
"fr": "Déployer - Installer le serveur de notifications NTFY",
|
||||
"en": "Deploy - Install NTFY notification server",
|
||||
},
|
||||
"Deploy a local NTFY push notification server (Ubuntu/Arch)": {
|
||||
"fr": "Déployer un serveur local de notifications NTFY (Ubuntu/Arch)",
|
||||
"en": "Deploy a local NTFY push notification server (Ubuntu/Arch)",
|
||||
},
|
||||
"NTFY server port (default: 8080): ": {
|
||||
"fr": "Port du serveur NTFY (défaut : 8080) : ",
|
||||
"en": "NTFY server port (default: 8080): ",
|
||||
},
|
||||
"NTFY base URL": {
|
||||
"fr": "URL de base NTFY",
|
||||
"en": "NTFY base URL",
|
||||
},
|
||||
"Installing NTFY server (requires sudo)...": {
|
||||
"fr": "Installation du serveur NTFY (sudo requis)...",
|
||||
"en": "Installing NTFY server (requires sudo)...",
|
||||
},
|
||||
"NTFY server installed and started successfully!": {
|
||||
"fr": "Serveur NTFY installé et démarré avec succès!",
|
||||
"en": "NTFY server installed and started successfully!",
|
||||
},
|
||||
"Error installing NTFY server: ": {
|
||||
"fr": "Erreur lors de l'installation du serveur NTFY : ",
|
||||
"en": "Error installing NTFY server: ",
|
||||
},
|
||||
"NTFY install script not found: ": {
|
||||
"fr": "Script d'installation NTFY introuvable : ",
|
||||
"en": "NTFY install script not found: ",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue