feat: Add PostgreSQL support and enhance admin dashboard

- Migrate all service files from hardcoded SQLite to dual SQLite/PostgreSQL support
- Add PostgreSQL service to docker-compose.yml
- Create database abstraction layer (database.py) with execute_query, row_to_dict helpers
- Update all 7 service files: account, rating, contact, ai_cost, quota, site_assistant, admin
- Add new admin endpoint /database-stats for table size and row count visualization
- Add database_type field to system health endpoint
- Update .env.example with proper PostgreSQL connection string
This commit is contained in:
Your Name
2026-03-31 21:51:45 +02:00
parent 42b1ad1250
commit 030418f6db
11 changed files with 1930 additions and 1006 deletions

View File

@@ -1,7 +1,7 @@
"""Database abstraction — supports SQLite (dev) and PostgreSQL (production).
Usage:
from app.utils.database import get_connection
from app.utils.database import db_connection, adapt_query
The returned connection behaves like a sqlite3.Connection with row_factory set.
For PostgreSQL it wraps psycopg2 with RealDictCursor for dict-like rows.
@@ -10,8 +10,10 @@ Selection logic:
- If DATABASE_URL env var is set (starts with ``postgres``), use PostgreSQL.
- Otherwise fall back to SQLite via DATABASE_PATH config.
"""
import logging
import os
import re
import sqlite3
from contextlib import contextmanager
@@ -23,6 +25,8 @@ _pg_available = False
try:
import psycopg2
import psycopg2.extras
import psycopg2.errors
_pg_available = True
except ImportError:
pass
@@ -35,7 +39,13 @@ def is_postgres() -> bool:
def _sqlite_connect() -> sqlite3.Connection:
db_path = current_app.config["DATABASE_PATH"]
db_path = current_app.config.get("DATABASE_PATH")
if not db_path:
db_path = os.path.join(
os.path.abspath(os.path.join(os.path.dirname(__file__), "..")),
"data",
"dociva.db",
)
db_dir = os.path.dirname(db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
@@ -50,7 +60,10 @@ def _pg_connect():
if not _pg_available:
raise RuntimeError("psycopg2 is not installed — cannot use PostgreSQL.")
db_url = os.getenv("DATABASE_URL", "")
conn = psycopg2.connect(db_url, cursor_factory=psycopg2.extras.RealDictCursor)
conn = psycopg2.connect(
db_url,
cursor_factory=psycopg2.extras.RealDictCursor,
)
conn.autocommit = False
return conn
@@ -76,16 +89,94 @@ def db_connection():
conn.close()
def adapt_sql(sql: str) -> str:
"""Adapt SQLite SQL to PostgreSQL if needed.
def adapt_query(sql: str, params: tuple = ()) -> tuple:
"""Adapt SQLite SQL and parameters to PostgreSQL if needed.
Converts:
- INTEGER PRIMARY KEY AUTOINCREMENT -> SERIAL PRIMARY KEY
- ? placeholders -> %s placeholders
- params tuple unchanged (psycopg2 accepts tuple with %s)
Returns (adapted_sql, adapted_params).
"""
if not is_postgres():
return sql
return sql, params
sql = sql.replace("INTEGER PRIMARY KEY AUTOINCREMENT", "SERIAL PRIMARY KEY")
sql = sql.replace("?", "%s")
return sql
sql = sql.replace("INTEGER PRIMARY KEY", "SERIAL PRIMARY KEY")
sql = sql.replace("BOOLEAN DEFAULT 1", "BOOLEAN DEFAULT TRUE")
sql = sql.replace("BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE")
sql = re.sub(r"\?", "%s", sql)
return sql, params
def execute_query(conn, sql: str, params: tuple = ()):
"""Execute a query, adapting SQL for the current database.
Returns the cursor.
"""
adapted_sql, adapted_params = adapt_query(sql, params)
if is_postgres():
cursor = conn.cursor()
cursor.execute(adapted_sql, adapted_params)
return cursor
return conn.execute(adapted_sql, adapted_params)
def get_last_insert_id(conn, cursor=None):
"""Get the last inserted row ID, compatible with both SQLite and PostgreSQL."""
if is_postgres():
if cursor is None:
raise ValueError("cursor is required for PostgreSQL to get last insert ID")
result = cursor.fetchone()
if result:
if isinstance(result, dict):
return result.get("id") or result.get("lastval")
return result[0]
return None
return cursor.lastrowid
def get_integrity_error():
"""Get the appropriate IntegrityError exception for the current database."""
if is_postgres():
if _pg_available:
return psycopg2.IntegrityError
raise RuntimeError("psycopg2 is not installed")
return sqlite3.IntegrityError
def get_row_value(row, key: str):
"""Get a value from a row by key, compatible with both SQLite Row and psycopg2 dict."""
if row is None:
return None
if isinstance(row, dict):
return row.get(key)
return row[key]
def row_to_dict(row):
"""Convert a database row to a plain dict."""
if row is None:
return None
if isinstance(row, dict):
return dict(row)
return dict(row)
def init_tables(conn):
"""Run initialization SQL for all tables, adapting for the current database."""
if is_postgres():
cursor = conn.cursor()
cursor.execute(
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'users')"
)
if cursor.fetchone()[0]:
return
else:
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
)
if cursor.fetchone():
return