| [95cf703] | 1 | import datetime
|
|---|
| 2 | import psycopg2
|
|---|
| [527c097] | 3 | from psycopg2 import pool
|
|---|
| 4 | from contextlib import contextmanager
|
|---|
| [95cf703] | 5 |
|
|---|
| 6 | DB_CONFIG = {
|
|---|
| 7 | "host": "localhost",
|
|---|
| 8 | "port": 5432,
|
|---|
| 9 | "dbname": "frruas_db",
|
|---|
| 10 | "user": "frruas_user",
|
|---|
| 11 | "password": "frruas_pass",
|
|---|
| 12 | "options": "-c search_path=project,public",
|
|---|
| 13 | }
|
|---|
| 14 |
|
|---|
| [527c097] | 15 | # -- Connection Pool ----------------------------------------------------------
|
|---|
| 16 | # Reuses database connections instead of opening a new one per query.
|
|---|
| 17 | # SimpleConnectionPool maintains between minconn and maxconn connections.
|
|---|
| [95cf703] | 18 |
|
|---|
| [527c097] | 19 | _pool = None
|
|---|
| 20 |
|
|---|
| 21 |
|
|---|
| 22 | def _get_pool():
|
|---|
| 23 | global _pool
|
|---|
| 24 | if _pool is None:
|
|---|
| 25 | _pool = pool.SimpleConnectionPool(minconn=2, maxconn=10, **DB_CONFIG)
|
|---|
| 26 | return _pool
|
|---|
| 27 |
|
|---|
| 28 |
|
|---|
| 29 | @contextmanager
|
|---|
| [95cf703] | 30 | def get_connection():
|
|---|
| [527c097] | 31 | """Get a read-only connection from the pool.
|
|---|
| 32 | Autocommit is enabled so SELECTs don't need explicit commit.
|
|---|
| 33 | Connection is returned to the pool on exit."""
|
|---|
| 34 | p = _get_pool()
|
|---|
| 35 | conn = p.getconn()
|
|---|
| 36 | try:
|
|---|
| 37 | conn.autocommit = True
|
|---|
| 38 | yield conn
|
|---|
| 39 | finally:
|
|---|
| 40 | conn.autocommit = False
|
|---|
| 41 | p.putconn(conn)
|
|---|
| 42 |
|
|---|
| 43 |
|
|---|
| 44 | @contextmanager
|
|---|
| 45 | def get_transaction():
|
|---|
| 46 | """Get a connection with explicit transaction management.
|
|---|
| 47 | Commits on successful exit, rolls back on exception.
|
|---|
| 48 | Connection is returned to the pool in both cases."""
|
|---|
| 49 | p = _get_pool()
|
|---|
| 50 | conn = p.getconn()
|
|---|
| 51 | try:
|
|---|
| 52 | conn.autocommit = False
|
|---|
| 53 | yield conn
|
|---|
| 54 | conn.commit()
|
|---|
| 55 | except Exception:
|
|---|
| 56 | conn.rollback()
|
|---|
| 57 | raise
|
|---|
| 58 | finally:
|
|---|
| 59 | p.putconn(conn)
|
|---|
| 60 |
|
|---|
| 61 |
|
|---|
| 62 | def close_pool():
|
|---|
| 63 | """Close all connections in the pool."""
|
|---|
| 64 | global _pool
|
|---|
| 65 | if _pool is not None:
|
|---|
| 66 | _pool.closeall()
|
|---|
| 67 | _pool = None
|
|---|
| 68 |
|
|---|
| 69 |
|
|---|
| 70 | # -- UI Helpers ---------------------------------------------------------------
|
|---|
| [95cf703] | 71 |
|
|---|
| 72 |
|
|---|
| 73 | def pick_from_list(prompt, items):
|
|---|
| 74 | """Display items as a numbered list, return selected 0-based index or None."""
|
|---|
| 75 | if not items:
|
|---|
| 76 | print(" (no items)")
|
|---|
| 77 | return None
|
|---|
| 78 | for i, item in enumerate(items, start=1):
|
|---|
| 79 | print(f" {i}. {item}")
|
|---|
| 80 | print(f" 0. Cancel / Go back")
|
|---|
| 81 | while True:
|
|---|
| 82 | choice = input(prompt).strip()
|
|---|
| 83 | if choice == "0" or choice == "":
|
|---|
| 84 | return None
|
|---|
| 85 | try:
|
|---|
| 86 | idx = int(choice) - 1
|
|---|
| 87 | if 0 <= idx < len(items):
|
|---|
| 88 | return idx
|
|---|
| 89 | except ValueError:
|
|---|
| 90 | pass
|
|---|
| 91 | print(" Invalid choice. Please try again.")
|
|---|
| 92 |
|
|---|
| 93 |
|
|---|
| 94 | def print_table(headers, rows):
|
|---|
| 95 | """Print a formatted ASCII table."""
|
|---|
| 96 | if not rows:
|
|---|
| 97 | print(" (no data)")
|
|---|
| 98 | return
|
|---|
| 99 | str_rows = [[str(v) for v in row] for row in rows]
|
|---|
| 100 | widths = [len(h) for h in headers]
|
|---|
| 101 | for row in str_rows:
|
|---|
| 102 | for i, val in enumerate(row):
|
|---|
| 103 | widths[i] = max(widths[i], len(val))
|
|---|
| 104 | header_line = " | ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
|
|---|
| 105 | print(f" {header_line}")
|
|---|
| 106 | print(f" {'-+-'.join('-' * w for w in widths)}")
|
|---|
| 107 | for row in str_rows:
|
|---|
| 108 | print(f" {' | '.join(val.ljust(widths[i]) for i, val in enumerate(row))}")
|
|---|
| 109 |
|
|---|
| 110 |
|
|---|
| 111 | def input_date(prompt):
|
|---|
| 112 | """Prompt until valid YYYY-MM-DD entered. Returns datetime.date."""
|
|---|
| 113 | while True:
|
|---|
| 114 | val = input(prompt).strip()
|
|---|
| 115 | try:
|
|---|
| 116 | return datetime.datetime.strptime(val, "%Y-%m-%d").date()
|
|---|
| 117 | except ValueError:
|
|---|
| 118 | print(" Invalid date format. Please use YYYY-MM-DD.")
|
|---|
| 119 |
|
|---|
| 120 |
|
|---|
| 121 | def input_time(prompt):
|
|---|
| 122 | """Prompt until valid HH:MM entered. Returns datetime.time."""
|
|---|
| 123 | while True:
|
|---|
| 124 | val = input(prompt).strip()
|
|---|
| 125 | try:
|
|---|
| 126 | return datetime.datetime.strptime(val, "%H:%M").time()
|
|---|
| 127 | except ValueError:
|
|---|
| 128 | print(" Invalid time format. Please use HH:MM.")
|
|---|