From ba0b919f834dbba653ee326df9542f22d28e47c9 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 10 Apr 2026 22:53:07 -0400 Subject: [PATCH 1/4] [ADD] make: add SSH remote deployment targets and todo.py integration Enable deploying and managing ERPLibre on remote servers via SSH directly from make and the interactive todo.py CLI, since only local deployment was previously supported. - New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push, ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs, ssh_make, ssh_install_systemd, ssh_install_nginx - Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY, SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL - Execute > Deploy menu extended with 11 SSH options in todo.py - All strings translated fr/en in todo_i18n.py Generated by Claude Code 2.1.101 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- Makefile | 1 + conf/make.ssh.Makefile | 130 +++++++++++++++++++++++++ script/todo/todo.py | 198 +++++++++++++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 89 ++++++++++++++++++ 4 files changed, 418 insertions(+) create mode 100644 conf/make.ssh.Makefile diff --git a/Makefile b/Makefile index fc9920f..465190a 100644 --- a/Makefile +++ b/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 diff --git a/conf/make.ssh.Makefile b/conf/make.ssh.Makefile new file mode 100644 index 0000000..c899fe9 --- /dev/null +++ b/conf/make.ssh.Makefile @@ -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),)" diff --git a/script/todo/todo.py b/script/todo/todo.py index 53cccd2..7e9e5fc 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -651,6 +651,17 @@ 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")}, ] help_info = self.fill_help_info(choices) @@ -663,6 +674,28 @@ 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() else: print(t("Command not found !")) @@ -793,6 +826,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 : diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6867282..a09e17c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -722,6 +722,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", From 627056b7475ca0a28d1dd50d85480923ebea2cd0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 11 Apr 2026 00:13:56 -0400 Subject: [PATCH 2/4] [ADD] install: add NTFY self-hosted push notification server Add a one-command installer for the ntfy push notification server (Ubuntu/Debian and Arch Linux), wired into the todo.py Deploy menu. Users can now deploy a local ntfy server from the CLI and subscribe to topics from their mobile device (ntfy app) to receive push notifications from ERPLibre. Generated by Claude Code 2.1.101 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/install/install_ntfy.sh | 303 +++++++++++++++++++++++++++++++++ script/todo/todo.py | 55 ++++++ script/todo/todo_i18n.py | 33 ++++ 3 files changed, 391 insertions(+) create mode 100755 script/install/install_ntfy.sh diff --git a/script/install/install_ntfy.sh b/script/install/install_ntfy.sh new file mode 100755 index 0000000..4cac17b --- /dev/null +++ b/script/install/install_ntfy.sh @@ -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" <" 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 "$@" diff --git a/script/todo/todo.py b/script/todo/todo.py index 7e9e5fc..73d71d3 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -662,6 +662,11 @@ class TODO: {"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) @@ -696,6 +701,8 @@ class TODO: 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 !")) @@ -722,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 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index a09e17c..8390470 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -905,6 +905,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: ", + }, } From 864f07c68128f71ab9d384b35753f2067cd6cbf1 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 25 Jun 2026 02:25:40 -0400 Subject: [PATCH 3/4] [IMP] todo: add erase-database menu command Give users a guided, confirmation-gated way to drop one or all databases from the interactive CLI. Previously this meant running make db_drop_all or odoo_bin db --drop by hand, which is easy to mistype and offers no safeguard. The new entry requires an explicit 'oui'/'yes' (default no) before any irreversible deletion. Generated by Claude Code 2.1.191 claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit --- script/todo/database_manager.py | 64 +++++++++++++++++++++++++++++++++ script/todo/todo.py | 3 ++ script/todo/todo_i18n.py | 46 ++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/script/todo/database_manager.py b/script/todo/database_manager.py index 617a8e6..798a30e 100644 --- a/script/todo/database_manager.py +++ b/script/todo/database_manager.py @@ -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") diff --git a/script/todo/todo.py b/script/todo/todo.py index 73d71d3..cedf8bf 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1473,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) @@ -1487,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 !")) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8390470..fe50a7b 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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!", From 4acd397c11315841625ac0a24b8ad388ebe13182 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 22 Mar 2026 01:23:27 -0400 Subject: [PATCH 4/4] [ADD] brin_advisor brin_cluster --- script/shell_script_odoo/brin_advisor.py | 191 +++++++++++++++ script/shell_script_odoo/brin_cluster.py | 286 +++++++++++++++++++++++ 2 files changed, 477 insertions(+) create mode 100644 script/shell_script_odoo/brin_advisor.py create mode 100644 script/shell_script_odoo/brin_cluster.py diff --git a/script/shell_script_odoo/brin_advisor.py b/script/shell_script_odoo/brin_advisor.py new file mode 100644 index 0000000..2f27518 --- /dev/null +++ b/script/shell_script_odoo/brin_advisor.py @@ -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() diff --git a/script/shell_script_odoo/brin_cluster.py b/script/shell_script_odoo/brin_cluster.py new file mode 100644 index 0000000..1bcbc57 --- /dev/null +++ b/script/shell_script_odoo/brin_cluster.py @@ -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")