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 = ( + '
Sorgente dati attuale:
' + f'{current_badge}
' + '' + '' + ) + 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, '