diff --git a/.gitignore b/.gitignore index be0e5d7..f265e49 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ __pycache__/ *.db-journal *.db-wal *.db-shm + +# Daily log files +logs/ diff --git a/Tool/config.py b/Tool/config.py index 672cb21..39c0de0 100644 --- a/Tool/config.py +++ b/Tool/config.py @@ -22,6 +22,11 @@ class DbType(StrEnum): UNKNOWN = 'unknown' +class ConnType(StrEnum): + SQLITE = 'sqlite' + POSTGRES = 'postgres' + + _db_dir_env = os.environ.get('RPA_DB_DIR', '') DB_DEFAULT_DIR = Path(_db_dir_env) if _db_dir_env else REPORTS_DIR.parent / "data_rpa" / "db" @@ -45,3 +50,14 @@ _auth: dict = { 'password': os.environ.get('DASHBOARD_PASSWORD', ''), } _sessions: set = set() # active session tokens + +# Database connection — mutable so settings can be updated at runtime +_conn_type_raw = os.environ.get('RPA_CONN_TYPE', 'sqlite').strip().lower() +_conn: dict = { + 'type': ConnType.POSTGRES if _conn_type_raw == 'postgres' else ConnType.SQLITE, + 'pg_host': os.environ.get('PG_HOST', 'localhost'), + 'pg_port': os.environ.get('PG_PORT', '5432'), + 'pg_db': os.environ.get('PG_DB', ''), + 'pg_user': os.environ.get('PG_USER', ''), + 'pg_password': os.environ.get('PG_PASSWORD', ''), +} diff --git a/Tool/env_manager.py b/Tool/env_manager.py index c2a4add..d16cb4f 100644 --- a/Tool/env_manager.py +++ b/Tool/env_manager.py @@ -4,7 +4,7 @@ import re -from .config import REPORTS_DIR, _auth +from .config import REPORTS_DIR, _auth, _conn, ConnType def _set_env_key(text: str, key: str, value: str) -> str: @@ -31,3 +31,32 @@ def update_auth(enabled: bool, user: str | None = None, password: str | None = N _auth['user'] = user if password is not None: _auth['password'] = password + + +def update_db_conn( + conn_type: str, + pg_host: str = '', + pg_port: str = '5432', + pg_db: str = '', + pg_user: str = '', + pg_password: str = '', +) -> None: + """Write RPA_CONN_TYPE and PG_* vars to .env and update _conn in memory.""" + env_path = REPORTS_DIR / '.env' + text = env_path.read_text(encoding='utf-8') + + text = _set_env_key(text, 'RPA_CONN_TYPE', conn_type) + text = _set_env_key(text, 'PG_HOST', pg_host or 'localhost') + text = _set_env_key(text, 'PG_PORT', pg_port or '5432') + text = _set_env_key(text, 'PG_DB', pg_db) + text = _set_env_key(text, 'PG_USER', pg_user) + text = _set_env_key(text, 'PG_PASSWORD', pg_password) + + env_path.write_text(text, encoding='utf-8') + + _conn['type'] = ConnType.POSTGRES if conn_type == 'postgres' else ConnType.SQLITE + _conn['pg_host'] = pg_host or 'localhost' + _conn['pg_port'] = pg_port or '5432' + _conn['pg_db'] = pg_db + _conn['pg_user'] = pg_user + _conn['pg_password'] = pg_password diff --git a/Tool/logger.py b/Tool/logger.py new file mode 100644 index 0000000..20cfa85 --- /dev/null +++ b/Tool/logger.py @@ -0,0 +1,47 @@ +import logging +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path + +_logger: logging.Logger | None = None + + +def get_logger() -> logging.Logger: + global _logger + if _logger is None: + _logger = logging.getLogger('rpa_dashboard') + return _logger + + +def setup_logging(log_dir: Path, backup_count: int = 30) -> logging.Logger: + global _logger + log_dir.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger('rpa_dashboard') + logger.setLevel(logging.INFO) + + if logger.handlers: + return logger + + fmt = logging.Formatter( + '[%(asctime)s] %(levelname)-5s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + ) + + # Daily rotation at midnight — keeps last `backup_count` days + # Current day: logs/dashboard.log + # Past days: logs/dashboard.log.YYYY-MM-DD + file_handler = TimedRotatingFileHandler( + log_dir / 'dashboard.log', + when='midnight', + backupCount=backup_count, + encoding='utf-8', + ) + file_handler.setFormatter(fmt) + logger.addHandler(file_handler) + + console = logging.StreamHandler() + console.setFormatter(fmt) + logger.addHandler(console) + + _logger = logger + return logger diff --git a/Tool/pages.py b/Tool/pages.py index f4e940f..7363963 100644 --- a/Tool/pages.py +++ b/Tool/pages.py @@ -5,11 +5,6 @@ from datetime import date as _date from .config import _state, _access_log, DbType, LOGS_PER_PAGE, STEPS_ROWS from .renderer import _base, _card, _page_css, _page_js, _tmpl, _css from .queries.db import e as _e - -_IT_MONTHS = [ - '', 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', - 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre' -] from .queries import processes as q_processes from .queries import pec as q_pec from .queries import logs as q_logs @@ -20,6 +15,11 @@ from .queries import api_iscrizioni as q_api_iscrizioni from .queries import email as q_email from .queries import sharepoint as q_sharepoint +_IT_MONTHS = [ + '', 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', + 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre' +] + def _load_data(db_path: str) -> dict: db_type = _state.get('db_type', 'unknown') diff --git a/Tool/queries/db.py b/Tool/queries/db.py index 72529df..c5da65a 100644 --- a/Tool/queries/db.py +++ b/Tool/queries/db.py @@ -1,4 +1,4 @@ -"""queries/db.py — helper condiviso per query SQLite""" +"""queries/db.py — helper condiviso per query SQLite / PostgreSQL""" import sqlite3 from datetime import datetime @@ -8,7 +8,33 @@ if TYPE_CHECKING: from ..config import DbType +def _pg_query(sql: str, params: tuple = ()) -> list: + """Execute a query against PostgreSQL using connection params from _conn.""" + import psycopg2 + import psycopg2.extras + from ..config import _conn + conn = psycopg2.connect( + host=_conn['pg_host'], + port=int(_conn['pg_port'] or 5432), + dbname=_conn['pg_db'], + user=_conn['pg_user'], + password=_conn['pg_password'], + connect_timeout=10, + ) + try: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + # SQLite uses ? placeholders; psycopg2 uses %s + pg_sql = sql.replace('?', '%s') + cur.execute(pg_sql, params) + return [dict(r) for r in cur.fetchall()] + finally: + conn.close() + + def query(db_path: str, sql: str, params: tuple = ()) -> list: + from ..config import _conn, ConnType + if _conn['type'] == ConnType.POSTGRES: + return _pg_query(sql, params) conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row try: @@ -70,13 +96,19 @@ def badge(cls: str, text: str) -> str: def detect_db(db_path: str) -> 'DbType': - """Returns DB_TYPE_REG_LOMB, DB_TYPE_INTRAZ, or DB_TYPE_UNKNOWN based on DB schema. + """Returns REG_LOMB, INTRAZ, or UNKNOWN based on DB schema. reg_Lomb (db_reg_lombardia) → has table 'sessione_documenti' Intraz (db_corsi_intraziendali) → has table 'rpa_intra_api_iscrizione' """ - from ..config import DbType - rows = query(db_path, "SELECT name FROM sqlite_master WHERE type='table'") + from ..config import DbType, _conn, ConnType + if _conn['type'] == ConnType.POSTGRES: + rows = _pg_query( + "SELECT table_name AS name FROM information_schema.tables " + "WHERE table_schema='public'" + ) + else: + rows = query(db_path, "SELECT name FROM sqlite_master WHERE type='table'") tables = {r['name'] for r in rows} if 'sessione_documenti' in tables: return DbType.REG_LOMB diff --git a/Tool/renderer.py b/Tool/renderer.py index 3d86398..eb5eaef 100644 --- a/Tool/renderer.py +++ b/Tool/renderer.py @@ -2,7 +2,7 @@ from datetime import datetime from pathlib import Path from string import Template -from .config import TMPL_DIR, CSS_PATH, PAGES_CSS_DIR, JS_DIR, _state, DbType, _auth +from .config import TMPL_DIR, CSS_PATH, PAGES_CSS_DIR, JS_DIR, _state, DbType, _auth, _conn, ConnType from .queries.db import e as _e @@ -57,6 +57,81 @@ def _card(label, value, color='#2563eb') -> str: ) +def _build_db_conn_modal() -> str: + is_pg = _conn['type'] == ConnType.POSTGRES + inp = ( + 'style="width:100%;padding:.5rem .75rem;border:1.5px solid #cbd5e1;' + 'border-radius:6px;font-size:.9rem;box-sizing:border-box"' + ) + lbl = 'style="font-size:.8rem;font-weight:600;color:#475569;display:block;margin-bottom:.25rem"' + badge_sqlite = ( + '📄 SQLite' + ) + badge_pg = ( + '' + '📡 PostgreSQL' + ) + current_badge = badge_pg if is_pg else badge_sqlite + + pg_host = _e(_conn.get('pg_host', 'localhost')) + pg_port = _e(_conn.get('pg_port', '5432')) + pg_db = _e(_conn.get('pg_db', '')) + pg_user = _e(_conn.get('pg_user', '')) + + body = ( + '' + f'

{current_badge}

' + '
' + '
' + f'' + f'' + '
' + '
' + 'Connessione PostgreSQL' + f'
' + f'
' + f'
' + '
' + f'
' + f'
' + f'
' + '
' + '' + '
' + '' + ) + return ( + '' + ) + + def _build_login_modal() -> str: enabled = _auth['enabled'] inp = ( @@ -141,9 +216,14 @@ def _base(title: str, content: str, active: str, db_path: str, page_css: str = ' nav_report_link = f'Report' nav_sharepoint_link = '' + if _conn['type'] == ConnType.POSTGRES: + db_display = _e(f"{_conn['pg_host']}:{_conn['pg_port']}/{_conn['pg_db']}") + else: + db_display = _e(Path(db_path).name) if db_path else '-' + return _tmpl('_base.html').substitute( css=_css(), page_css=page_css, page_js=page_js, title=title, now=now, - db_name=_e(Path(db_path).name), + db_name=db_display, h1_title=h1_title, content=content, nav_step_link=nav_step_link, @@ -151,6 +231,7 @@ def _base(title: str, content: str, active: str, db_path: str, page_css: str = ' nav_sharepoint_link=nav_sharepoint_link, nav_pec_link=nav_pec_link, modal_login=_build_login_modal(), + modal_db_conn=_build_db_conn_modal(), logout_btn='🔒 Esci' if _auth['enabled'] else '', **nav, ) diff --git a/Tool/server.py b/Tool/server.py index 6c702ba..c355e7c 100644 --- a/Tool/server.py +++ b/Tool/server.py @@ -6,7 +6,9 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse, parse_qs, unquote_plus from .config import _state, _access_log, DbType, _auth, _sessions, LOGS_PER_PAGE, STEPS_ROWS -from .env_manager import update_auth as _update_env_auth +from .config import _conn, ConnType, REPORTS_DIR +from .logger import get_logger +from .env_manager import update_auth as _update_env_auth, update_db_conn as _update_db_conn from .db_finder import _find_db, _pick_db_file from .renderer import _base as _render_base from .pages import ( @@ -51,7 +53,7 @@ def make_handler(db_path: str): class ReportHandler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): - print(f"[{datetime.now().strftime('%H:%M:%S')}] {fmt % args}") + get_logger().info(fmt % args) def _send(self, code: int, body: str, content_type: str = 'text/html; charset=utf-8'): encoded = body.encode('utf-8') @@ -129,6 +131,45 @@ def make_handler(db_path: str): 'method': 'POST', 'path': '/settings/login', 'code': 302, 'client': self.client_address[0], }) + elif parsed.path == '/settings/db': + if not _is_authenticated(self.headers.get('Cookie', '')): + self.send_response(302) + self.send_header('Location', '/login') + self.end_headers() + return + length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(length).decode('utf-8') + fields = {} + for part in body.split('&'): + if '=' in part: + k, v = part.split('=', 1) + fields[unquote_plus(k)] = unquote_plus(v) + conn_type = fields.get('conn_type', 'sqlite').strip().lower() + pg_host = fields.get('pg_host', '').strip() + pg_port = fields.get('pg_port', '5432').strip() + pg_db = fields.get('pg_db', '').strip() + pg_user = fields.get('pg_user', '').strip() + pg_password = fields.get('pg_password', '').strip() + # Keep existing password if field left blank + if not pg_password and _conn['type'] == ConnType.POSTGRES: + pg_password = _conn.get('pg_password', '') + _update_db_conn(conn_type, pg_host, pg_port, pg_db, pg_user, pg_password) + # Update db_path and db_type in _state + if conn_type == 'postgres': + new_path = f"postgresql://{pg_host}:{pg_port}/{pg_db}" + _state['db_path'] = new_path + try: + _refresh_db_type(new_path) + except Exception: + _state['db_type'] = None + self.send_response(302) + self.send_header('Location', '/') + self.end_headers() + _access_log.append({ + 'ts': datetime.now().strftime('%d/%m/%Y %H:%M:%S'), + 'method': 'POST', 'path': '/settings/db', + 'code': 302, 'client': self.client_address[0], + }) else: self._send(405, '

405 Method Not Allowed

') @@ -191,10 +232,11 @@ def make_handler(db_path: str): return if self.path == '/change-db': - chosen = _pick_db_file() - if chosen and os.path.exists(chosen): - _state['db_path'] = chosen - _refresh_db_type(chosen) + if _conn['type'] != ConnType.POSTGRES: + chosen = _pick_db_file() + if chosen and os.path.exists(chosen): + _state['db_path'] = chosen + _refresh_db_type(chosen) self.send_response(302) self.send_header('Location', '/') self.end_headers() @@ -375,47 +417,63 @@ def run_server(db_path: str | None, host: str = '0.0.0.0', port: int = 8473, db_ _state['db_type'] = None handler = make_handler(db_path) server = HTTPServer((host, port), handler) + log = get_logger() display_host = 'localhost' if host in ('0.0.0.0', '') else host base = f"http://{display_host}:{port}" if db_error: - print(f"ATTENZIONE: {db_error}") - print(f"RPA Report server avviato senza DB — pagina di errore su {base}/") + log.warning(f"ATTENZIONE: {db_error}") + log.info(f"RPA Report server avviato senza DB — pagina di errore su {base}/") else: - print(f"RPA Report server avviato [db: {db_type}]") - print(f" Dashboard : {base}/") - print(f" Processi : {base}/runs") + log.info(f"RPA Report server avviato [db: {db_type}]") + log.info(f" Dashboard : {base}/") + log.info(f" Processi : {base}/runs") if db_type == DbType.INTRAZ: - print(f" RPA Steps : {base}/steps") - print(f" Iscr. API : {base}/iscrizioni-api") - print(f" SharePoint : {base}/sharepoint") - print(f" Email : {base}/email") + log.info(f" RPA Steps : {base}/steps") + log.info(f" Iscr. API : {base}/iscrizioni-api") + log.info(f" SharePoint : {base}/sharepoint") + log.info(f" Email : {base}/email") else: - print(f" PEC : {base}/pec") - print(f" Documenti : {base}/documenti") - print(f" Report : {base}/report") - print(f" Schema : {base}/schema") - print(f" Log DB : {base}/logs") - print(f" Server log : {base}/server-logs") - print(f" DB : {db_path}") - print("Ctrl+C per fermare.\n") + log.info(f" PEC : {base}/pec") + log.info(f" Documenti : {base}/documenti") + log.info(f" Report : {base}/report") + log.info(f" Schema : {base}/schema") + log.info(f" Log DB : {base}/logs") + log.info(f" Server log : {base}/server-logs") + log.info(f" DB : {db_path}") + log.info("Ctrl+C per fermare.") try: server.serve_forever() except KeyboardInterrupt: - print("\nServer fermato.") + log.info("Server fermato.") server.server_close() def main(): from dotenv import load_dotenv + from pathlib import Path + from .logger import setup_logging load_dotenv() - db_file = os.environ.get('RPA_DB_FILE', 'rpa_FORMAZIONE.db') + log_dir_env = os.environ.get('RPA_LOG_DIR', '') + log_dir = Path(log_dir_env) if log_dir_env else REPORTS_DIR / 'logs' + setup_logging(log_dir) + db_error = None - try: - db_path = _find_db(db_file) - except FileNotFoundError as exc: - db_path = None - db_error = str(exc) + if _conn['type'] == ConnType.POSTGRES: + db_path = f"postgresql://{_conn['pg_host']}:{_conn['pg_port']}/{_conn['pg_db']}" + try: + from .queries.db import _pg_query + _pg_query("SELECT 1") + except Exception as exc: + db_path = None + db_error = f"Connessione PostgreSQL fallita: {exc}" + else: + db_file = os.environ.get('RPA_DB_FILE', 'rpa_FORMAZIONE.db') + try: + db_path = _find_db(db_file) + except FileNotFoundError as exc: + db_path = None + db_error = str(exc) port = int(os.environ.get('RPA_REPORT_PORT', 8473)) run_server(db_path, port=port, db_error=db_error) diff --git a/requirements.txt b/requirements.txt index 0eed382..e3aa830 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ python-dotenv>=1.0.0 +psycopg2-binary>=2.9 # optional — only needed for PostgreSQL mode diff --git a/runner/install_as_service.ps1 b/runner/install_as_service.ps1 new file mode 100644 index 0000000..97f17e5 --- /dev/null +++ b/runner/install_as_service.ps1 @@ -0,0 +1,103 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Install (or remove) RpaDashboard as a Windows service via NSSM. + +.PARAMETER PythonExe + Full path to python.exe. Leave blank to auto-detect from PATH + (or the active virtual environment if the script is run inside one). + +.PARAMETER Uninstall + Stop and remove the service instead of installing it. + +.EXAMPLE + .\install_service.ps1 + .\install_service.ps1 -PythonExe "C:\Python314\python.exe" + .\install_service.ps1 -Uninstall +#> +param( + [string]$PythonExe = '', + [switch]$Uninstall +) + +$ServiceName = 'RpaDashboard' +$RootDir = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) + +# --- Uninstall path --- +if ($Uninstall) { + Write-Host "Stopping and removing service '$ServiceName'..." + nssm stop $ServiceName 2>$null + nssm remove $ServiceName confirm + Write-Host "Done." + exit 0 +} + +# --- Resolve Python executable --- +if (-not $PythonExe) { + $venvPython = Join-Path $RootDir '.venv\Scripts\python.exe' + if (Test-Path $venvPython) { + $PythonExe = $venvPython + } else { + $found = Get-Command python -ErrorAction SilentlyContinue + if ($found) { $PythonExe = $found.Source } + } +} + +if (-not $PythonExe -or -not (Test-Path $PythonExe)) { + Write-Error "Python not found. Pass -PythonExe 'C:\path\to\python.exe'" + exit 1 +} + +if (-not (Get-Command nssm -ErrorAction SilentlyContinue)) { + Write-Error "NSSM not found in PATH. Download from https://nssm.cc and add it to PATH." + exit 1 +} + +$Script = Join-Path $RootDir 'Dashboard.py' +$LogDir = Join-Path $RootDir 'logs' + +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null + +Write-Host "Installing service '$ServiceName'..." +Write-Host " Python : $PythonExe" +Write-Host " Script : $Script" +Write-Host " Logs : $LogDir" +Write-Host "" + +# Install service +nssm install $ServiceName $PythonExe "-u" $Script + +# Working directory (important for .env loading) +nssm set $ServiceName AppDirectory $RootDir + +# Display name and description visible in services.msc +nssm set $ServiceName DisplayName 'RPA Dashboard' +nssm set $ServiceName Description 'Dashboard per il monitoraggio dei processi RPA — autodeployed by viXEvo' + +# Start automatically with Windows +nssm set $ServiceName Start SERVICE_AUTO_START + +# Redirect stdout/stderr (catches crashes before logging initialises) +nssm set $ServiceName AppStdout (Join-Path $LogDir 'service_stdout.log') +nssm set $ServiceName AppStderr (Join-Path $LogDir 'service_stderr.log') + +# Rotate NSSM's own stdout/stderr files daily +nssm set $ServiceName AppRotateFiles 1 +nssm set $ServiceName AppRotateSeconds 86400 + +# Restart automatically 5 s after a crash +nssm set $ServiceName AppExit Default Restart +nssm set $ServiceName AppRestartDelay 5000 + +# Start the service now +nssm start $ServiceName + +Write-Host "" +Write-Host "Service '$ServiceName' installed and started." +Write-Host "" +Write-Host "Useful commands:" +Write-Host " Status : Get-Service $ServiceName" +Write-Host " Stop : Stop-Service $ServiceName" +Write-Host " Restart : Restart-Service $ServiceName" +Write-Host " Logs : Get-Content '$LogDir\dashboard.log' -Wait" +Write-Host " Uninstall : .\install_service.ps1 -Uninstall" diff --git a/templates/_base.html b/templates/_base.html index 7c64da3..6412c6b 100644 --- a/templates/_base.html +++ b/templates/_base.html @@ -26,6 +26,7 @@
📂 Cambia DB… +
@@ -55,6 +56,7 @@ ${content} ${modal_login} +${modal_db_conn}