feat(database): add database configuration wizard and migration tool
Add a guided database configuration wizard and a data migration tool that allows users to: - Build database connection strings through a step-by-step UI - Test database connections before applying - Preview and execute data migrations from SQLite to PostgreSQL/MySQL - Copy to clipboard for easy .env file updates New files: - app/utils/db_wizard.py — connection string builder, parser, and tester - app/utils/db_migrate.py — table-by-table data migration utility - app/api/database.py — REST API endpoints for wizard operations - app/views/db_wizard.py — view route for the wizard page - frontend/templates/db_wizard.html — multi-tab wizard UI - tests/test_db_wizard.py — unit tests for db_wizard utilities - tests/test_db_migrate.py — unit tests for db_migrate utilities - tests/test_db_wizard_api.py — integration tests for API and views Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import logging
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.azure import router as azure_router
|
||||
from app.api.database import router as database_router
|
||||
from app.api.diagnostic import router as diagnostic_router
|
||||
from app.api.dropbox import router as dropbox_router
|
||||
from app.api.duplicates import router as duplicates_router
|
||||
@@ -52,3 +53,4 @@ router.include_router(saved_searches_router)
|
||||
router.include_router(similarity_router)
|
||||
router.include_router(duplicates_router)
|
||||
router.include_router(webhooks_router)
|
||||
router.include_router(database_router)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
API endpoints for the database configuration wizard and migration tool.
|
||||
|
||||
Provides REST endpoints for:
|
||||
- Testing database connections
|
||||
- Building connection strings from form components
|
||||
- Previewing and executing data migrations between databases
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.utils.db_migrate import migrate_data, preview_migration
|
||||
from app.utils.db_wizard import (
|
||||
build_connection_string,
|
||||
get_supported_backends,
|
||||
parse_connection_string,
|
||||
test_connection,
|
||||
validate_url_format,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/database", tags=["database"])
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
||||
user = request.session.get("user")
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ConnectionStringRequest(BaseModel):
|
||||
"""Request body for building a connection string."""
|
||||
|
||||
backend: str = Field(..., description="Database backend: sqlite, postgresql, mysql")
|
||||
host: str = Field("", description="Database server hostname")
|
||||
port: int | None = Field(None, description="Database server port")
|
||||
database: str = Field("", description="Database name")
|
||||
username: str = Field("", description="Authentication username")
|
||||
password: str = Field("", description="Authentication password")
|
||||
ssl_mode: str = Field("", description="SSL mode (e.g. require, verify-full)")
|
||||
extra_options: str = Field("", description="Additional query-string options")
|
||||
sqlite_path: str = Field("", description="File path for SQLite databases")
|
||||
|
||||
|
||||
class TestConnectionRequest(BaseModel):
|
||||
"""Request body for testing a database connection."""
|
||||
|
||||
url: str = Field(..., description="Full SQLAlchemy connection URL to test")
|
||||
|
||||
|
||||
class MigrateRequest(BaseModel):
|
||||
"""Request body for data migration."""
|
||||
|
||||
source_url: str = Field(..., description="Source database connection URL")
|
||||
target_url: str = Field(..., description="Target database connection URL")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/backends")
|
||||
async def list_backends() -> list[dict]:
|
||||
"""List all supported database backends with metadata."""
|
||||
return get_supported_backends()
|
||||
|
||||
|
||||
@router.post("/build-url")
|
||||
async def build_url(body: ConnectionStringRequest, request: Request) -> dict:
|
||||
"""Build a SQLAlchemy connection string from individual components.
|
||||
|
||||
Returns the assembled URL string.
|
||||
"""
|
||||
_require_admin(request)
|
||||
try:
|
||||
url = build_connection_string(
|
||||
backend=body.backend,
|
||||
host=body.host,
|
||||
port=body.port,
|
||||
database=body.database,
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
ssl_mode=body.ssl_mode,
|
||||
extra_options=body.extra_options,
|
||||
sqlite_path=body.sqlite_path,
|
||||
)
|
||||
return {"url": url}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/parse-url")
|
||||
async def parse_url(body: TestConnectionRequest, request: Request) -> dict:
|
||||
"""Parse a connection string into its components."""
|
||||
_require_admin(request)
|
||||
return parse_connection_string(body.url)
|
||||
|
||||
|
||||
@router.post("/validate-url")
|
||||
async def validate_url(body: TestConnectionRequest, request: Request) -> dict:
|
||||
"""Validate a connection string format without connecting."""
|
||||
_require_admin(request)
|
||||
return validate_url_format(body.url)
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_db_connection(body: TestConnectionRequest, request: Request) -> dict:
|
||||
"""Test connectivity to a database and return status info.
|
||||
|
||||
This creates a temporary engine, executes ``SELECT 1``, and disposes
|
||||
of the engine. It does **not** modify any global application state.
|
||||
"""
|
||||
_require_admin(request)
|
||||
return test_connection(body.url)
|
||||
|
||||
|
||||
@router.post("/preview-migration")
|
||||
async def preview_db_migration(body: TestConnectionRequest, request: Request) -> dict:
|
||||
"""Preview what a migration from the given source would include.
|
||||
|
||||
Returns a table-by-table row count without actually copying data.
|
||||
"""
|
||||
_require_admin(request)
|
||||
return preview_migration(body.url)
|
||||
|
||||
|
||||
@router.post("/migrate")
|
||||
async def execute_migration(body: MigrateRequest, request: Request) -> dict:
|
||||
"""Execute a full data migration from source to target database.
|
||||
|
||||
**Warning:** This copies all data from the source database into the
|
||||
target. The target schema is created from the current application
|
||||
models. Existing data in the target is **not** deleted first — use
|
||||
on an empty target database.
|
||||
"""
|
||||
_require_admin(request)
|
||||
|
||||
# Validate both URLs first
|
||||
src_check = validate_url_format(body.source_url)
|
||||
if not src_check.get("valid"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid source URL: {src_check.get('error', 'unknown')}",
|
||||
)
|
||||
tgt_check = validate_url_format(body.target_url)
|
||||
if not tgt_check.get("valid"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid target URL: {tgt_check.get('error', 'unknown')}",
|
||||
)
|
||||
|
||||
result = migrate_data(body.source_url, body.target_url)
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"message": "Migration completed with errors", **result},
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Database migration utility for transferring data between databases.
|
||||
|
||||
Copies all table rows from a *source* SQLAlchemy database to a *target*
|
||||
database. This is designed for the common scenario of migrating from the
|
||||
built-in SQLite database to an external PostgreSQL / MySQL instance.
|
||||
|
||||
The utility:
|
||||
1. Creates the schema in the target via ``Base.metadata.create_all``.
|
||||
2. Copies rows table-by-table in dependency order.
|
||||
3. Stamps the Alembic version in the target to ``head``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import MetaData, create_engine, inspect, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tables to skip during migration (Alembic manages its own state).
|
||||
_SKIP_TABLES = {"alembic_version"}
|
||||
|
||||
# Ordered list — parent tables first to respect foreign-key constraints.
|
||||
_TABLE_ORDER = [
|
||||
"documents",
|
||||
"files",
|
||||
"file_processing_steps",
|
||||
"processing_logs",
|
||||
"application_settings",
|
||||
"settings_audit_log",
|
||||
"saved_searches",
|
||||
"webhook_configs",
|
||||
]
|
||||
|
||||
|
||||
def _make_engine(url: str) -> Engine:
|
||||
"""Create a SQLAlchemy engine from *url* with sensible defaults."""
|
||||
parsed = make_url(url)
|
||||
connect_args: dict[str, Any] = {}
|
||||
if parsed.get_backend_name() == "sqlite":
|
||||
connect_args["check_same_thread"] = False
|
||||
return create_engine(url, connect_args=connect_args)
|
||||
|
||||
|
||||
def _ordered_tables(inspector: Any) -> list[str]:
|
||||
"""Return table names in safe insertion order.
|
||||
|
||||
Tables listed in ``_TABLE_ORDER`` come first (in that order); any
|
||||
remaining tables are appended alphabetically.
|
||||
"""
|
||||
existing = set(inspector.get_table_names())
|
||||
ordered: list[str] = []
|
||||
for name in _TABLE_ORDER:
|
||||
if name in existing and name not in _SKIP_TABLES:
|
||||
ordered.append(name)
|
||||
for name in sorted(existing):
|
||||
if name not in ordered and name not in _SKIP_TABLES:
|
||||
ordered.append(name)
|
||||
return ordered
|
||||
|
||||
|
||||
def preview_migration(source_url: str) -> dict[str, Any]:
|
||||
"""Preview what a migration would do without actually copying data.
|
||||
|
||||
Args:
|
||||
source_url: Connection string for the source database.
|
||||
|
||||
Returns:
|
||||
Dict with ``tables`` (list of dicts with ``name`` and ``row_count``)
|
||||
and ``total_rows``.
|
||||
"""
|
||||
try:
|
||||
src_engine = _make_engine(source_url)
|
||||
src_inspector = inspect(src_engine)
|
||||
tables = _ordered_tables(src_inspector)
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
with src_engine.connect() as conn:
|
||||
for table_name in tables:
|
||||
row = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')).fetchone() # noqa: S608
|
||||
count = row[0] if row else 0
|
||||
result.append({"name": table_name, "row_count": count})
|
||||
total += count
|
||||
|
||||
src_engine.dispose()
|
||||
return {"tables": result, "total_rows": total, "success": True}
|
||||
except Exception as exc:
|
||||
logger.error(f"Migration preview failed: {exc}")
|
||||
return {"success": False, "error": str(exc), "tables": [], "total_rows": 0}
|
||||
|
||||
|
||||
def migrate_data(
|
||||
source_url: str,
|
||||
target_url: str,
|
||||
*,
|
||||
batch_size: int = 500,
|
||||
progress_callback: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Copy all data from *source_url* to *target_url*.
|
||||
|
||||
The target schema is created automatically from the application models.
|
||||
Alembic is stamped to ``head`` in the target after a successful copy.
|
||||
|
||||
Args:
|
||||
source_url: SQLAlchemy connection string for the source DB.
|
||||
target_url: SQLAlchemy connection string for the target DB.
|
||||
batch_size: Number of rows to insert per batch.
|
||||
progress_callback: Optional ``callable(table_name, copied, total)``
|
||||
invoked after each batch.
|
||||
|
||||
Returns:
|
||||
Dict with ``success`` (bool), ``tables_copied`` (int),
|
||||
``rows_copied`` (int), and ``errors`` (list of str).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
tables_copied = 0
|
||||
rows_copied = 0
|
||||
|
||||
try:
|
||||
src_engine = _make_engine(source_url)
|
||||
tgt_engine = _make_engine(target_url)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Create schema in target from application models
|
||||
# ------------------------------------------------------------------
|
||||
from app.database import Base # local import to avoid circular deps
|
||||
|
||||
Base.metadata.create_all(bind=tgt_engine)
|
||||
logger.info("Target schema created from application models.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Reflect source schema & determine copy order
|
||||
# ------------------------------------------------------------------
|
||||
src_meta = MetaData()
|
||||
src_meta.reflect(bind=src_engine)
|
||||
|
||||
src_inspector = inspect(src_engine)
|
||||
table_names = _ordered_tables(src_inspector)
|
||||
|
||||
SrcSession = sessionmaker(bind=src_engine)
|
||||
TgtSession = sessionmaker(bind=tgt_engine)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Copy data table-by-table
|
||||
# ------------------------------------------------------------------
|
||||
for table_name in table_names:
|
||||
try:
|
||||
src_session = SrcSession()
|
||||
tgt_session = TgtSession()
|
||||
|
||||
src_table = src_meta.tables.get(table_name)
|
||||
if src_table is None:
|
||||
continue
|
||||
|
||||
# Read all rows from source
|
||||
rows = src_session.execute(src_table.select()).fetchall()
|
||||
column_names = [c.name for c in src_table.columns]
|
||||
|
||||
if not rows:
|
||||
logger.info(f"Skipping empty table: {table_name}")
|
||||
tables_copied += 1
|
||||
src_session.close()
|
||||
tgt_session.close()
|
||||
continue
|
||||
|
||||
# Reflect the target table to insert into
|
||||
tgt_meta = MetaData()
|
||||
tgt_meta.reflect(bind=tgt_engine, only=[table_name])
|
||||
tgt_table = tgt_meta.tables.get(table_name)
|
||||
if tgt_table is None:
|
||||
errors.append(f"Target table {table_name} not found after schema creation")
|
||||
src_session.close()
|
||||
tgt_session.close()
|
||||
continue
|
||||
|
||||
# Batch insert
|
||||
total_for_table = len(rows)
|
||||
for i in range(0, total_for_table, batch_size):
|
||||
batch = rows[i : i + batch_size]
|
||||
insert_data = [dict(zip(column_names, row, strict=False)) for row in batch]
|
||||
tgt_session.execute(tgt_table.insert(), insert_data)
|
||||
tgt_session.commit()
|
||||
|
||||
rows_copied += len(batch)
|
||||
if progress_callback:
|
||||
progress_callback(table_name, min(i + batch_size, total_for_table), total_for_table)
|
||||
|
||||
tables_copied += 1
|
||||
logger.info(f"Copied {total_for_table} rows from {table_name}")
|
||||
src_session.close()
|
||||
tgt_session.close()
|
||||
|
||||
except Exception as exc:
|
||||
msg = f"Error copying table {table_name}: {exc}"
|
||||
logger.error(msg)
|
||||
errors.append(msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Stamp Alembic to head in the target
|
||||
# ------------------------------------------------------------------
|
||||
try:
|
||||
_stamp_alembic_head(tgt_engine)
|
||||
logger.info("Alembic version stamped to head in target database.")
|
||||
except Exception as exc:
|
||||
msg = f"Failed to stamp Alembic version: {exc}"
|
||||
logger.error(msg)
|
||||
errors.append(msg)
|
||||
|
||||
src_engine.dispose()
|
||||
tgt_engine.dispose()
|
||||
|
||||
return {
|
||||
"success": len(errors) == 0,
|
||||
"tables_copied": tables_copied,
|
||||
"rows_copied": rows_copied,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Migration failed: {exc}")
|
||||
return {
|
||||
"success": False,
|
||||
"tables_copied": tables_copied,
|
||||
"rows_copied": rows_copied,
|
||||
"errors": errors + [str(exc)],
|
||||
}
|
||||
|
||||
|
||||
def _stamp_alembic_head(engine: Engine) -> None:
|
||||
"""Stamp the Alembic version table to ``head`` in the given engine."""
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
migrations_dir = str(Path(__file__).resolve().parent.parent.parent / "migrations")
|
||||
alembic_cfg = Config()
|
||||
alembic_cfg.set_main_option("script_location", migrations_dir)
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", "")
|
||||
|
||||
with engine.begin() as connection:
|
||||
alembic_cfg.attributes["connection"] = connection
|
||||
command.stamp(alembic_cfg, "head")
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Database configuration wizard utilities.
|
||||
|
||||
Provides helpers for building, validating, and testing database connection
|
||||
strings. Used by both the interactive wizard UI and the REST API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Supported database backends with human-readable labels and defaults.
|
||||
SUPPORTED_BACKENDS: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "sqlite",
|
||||
"label": "SQLite (Development)",
|
||||
"driver": "",
|
||||
"default_port": None,
|
||||
"description": "File-based database. Best for development and single-user setups.",
|
||||
"requires_host": False,
|
||||
},
|
||||
{
|
||||
"id": "postgresql",
|
||||
"label": "PostgreSQL (Recommended for Production)",
|
||||
"driver": "",
|
||||
"default_port": 5432,
|
||||
"description": "Robust, full-featured database. Recommended for production.",
|
||||
"requires_host": True,
|
||||
},
|
||||
{
|
||||
"id": "mysql",
|
||||
"label": "MySQL / MariaDB",
|
||||
"driver": "pymysql",
|
||||
"default_port": 3306,
|
||||
"description": "Popular open-source database. Requires pymysql driver.",
|
||||
"requires_host": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_supported_backends() -> list[dict[str, Any]]:
|
||||
"""Return the list of supported database backends with metadata.
|
||||
|
||||
Returns:
|
||||
List of backend descriptor dicts.
|
||||
"""
|
||||
return SUPPORTED_BACKENDS
|
||||
|
||||
|
||||
def build_connection_string(
|
||||
backend: str,
|
||||
host: str = "",
|
||||
port: int | None = None,
|
||||
database: str = "",
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
ssl_mode: str = "",
|
||||
extra_options: str = "",
|
||||
sqlite_path: str = "",
|
||||
) -> str:
|
||||
"""Build a SQLAlchemy connection string from individual components.
|
||||
|
||||
Args:
|
||||
backend: Database backend identifier (``sqlite``, ``postgresql``, ``mysql``).
|
||||
host: Database server hostname or IP.
|
||||
port: Database server port (uses backend default when ``None``).
|
||||
database: Database / schema name.
|
||||
username: Authentication username.
|
||||
password: Authentication password.
|
||||
ssl_mode: SSL mode (e.g. ``require``, ``verify-full``). PostgreSQL only.
|
||||
extra_options: Additional query-string options appended to the URL.
|
||||
sqlite_path: File path for SQLite databases.
|
||||
|
||||
Returns:
|
||||
A SQLAlchemy-compatible connection URL string.
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields are missing for the chosen backend.
|
||||
"""
|
||||
if backend == "sqlite":
|
||||
path = sqlite_path.strip() if sqlite_path else "./app/database.db"
|
||||
return f"sqlite:///{path}"
|
||||
|
||||
# Resolve driver prefix
|
||||
backend_info = next((b for b in SUPPORTED_BACKENDS if b["id"] == backend), None)
|
||||
if backend_info is None:
|
||||
raise ValueError(f"Unsupported backend: {backend}")
|
||||
|
||||
if not host:
|
||||
raise ValueError("Host is required for non-SQLite backends")
|
||||
if not database:
|
||||
raise ValueError("Database name is required for non-SQLite backends")
|
||||
if not username:
|
||||
raise ValueError("Username is required for non-SQLite backends")
|
||||
|
||||
driver_suffix = f"+{backend_info['driver']}" if backend_info["driver"] else ""
|
||||
scheme = f"{backend}{driver_suffix}"
|
||||
|
||||
resolved_port = port if port else backend_info["default_port"]
|
||||
|
||||
# Build query parameters
|
||||
params: list[str] = []
|
||||
if ssl_mode:
|
||||
params.append(f"sslmode={ssl_mode}")
|
||||
if extra_options:
|
||||
params.append(extra_options)
|
||||
if backend == "mysql" and "charset=" not in extra_options:
|
||||
params.append("charset=utf8mb4")
|
||||
|
||||
query_string = "&".join(params)
|
||||
|
||||
# Construct URL
|
||||
auth = username
|
||||
if password:
|
||||
auth = f"{username}:{password}"
|
||||
|
||||
url = f"{scheme}://{auth}@{host}:{resolved_port}/{database}"
|
||||
if query_string:
|
||||
url = f"{url}?{query_string}"
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def parse_connection_string(url: str) -> dict[str, Any]:
|
||||
"""Parse a SQLAlchemy connection string into its components.
|
||||
|
||||
Args:
|
||||
url: A SQLAlchemy database URL string.
|
||||
|
||||
Returns:
|
||||
Dict with keys: ``backend``, ``host``, ``port``, ``database``,
|
||||
``username``, ``password``, ``ssl_mode``, ``is_sqlite``.
|
||||
"""
|
||||
try:
|
||||
parsed = make_url(url)
|
||||
backend_name = parsed.get_backend_name()
|
||||
return {
|
||||
"backend": backend_name,
|
||||
"host": parsed.host or "",
|
||||
"port": parsed.port,
|
||||
"database": parsed.database or "",
|
||||
"username": parsed.username or "",
|
||||
"password": parsed.password or "",
|
||||
"ssl_mode": "",
|
||||
"is_sqlite": backend_name == "sqlite",
|
||||
"valid": True,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to parse connection string: {exc}")
|
||||
return {"valid": False, "error": str(exc)}
|
||||
|
||||
|
||||
def test_connection(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||
"""Attempt to connect to a database and return status information.
|
||||
|
||||
The function creates a short-lived engine, executes a simple ``SELECT 1``
|
||||
query, and disposes the engine. It does **not** modify any global state.
|
||||
|
||||
Args:
|
||||
url: SQLAlchemy database URL to test.
|
||||
timeout: Connection timeout in seconds.
|
||||
|
||||
Returns:
|
||||
Dict with ``success`` (bool), ``message`` (str), and optional
|
||||
``server_version`` (str).
|
||||
"""
|
||||
try:
|
||||
parsed = make_url(url)
|
||||
backend = parsed.get_backend_name()
|
||||
|
||||
connect_args: dict[str, Any] = {}
|
||||
kwargs: dict[str, Any] = {"pool_pre_ping": True}
|
||||
|
||||
if backend == "sqlite":
|
||||
connect_args["check_same_thread"] = False
|
||||
else:
|
||||
kwargs["pool_timeout"] = timeout
|
||||
|
||||
test_engine = create_engine(
|
||||
url,
|
||||
connect_args=connect_args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
with test_engine.connect() as conn:
|
||||
result = conn.execute(text("SELECT 1"))
|
||||
result.fetchone()
|
||||
|
||||
# Try to fetch server version for informational display
|
||||
server_version = _get_server_version(conn, backend)
|
||||
|
||||
test_engine.dispose()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"backend": backend,
|
||||
"server_version": server_version,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning(f"Connection test failed: {exc}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"backend": "",
|
||||
"server_version": "",
|
||||
}
|
||||
|
||||
|
||||
def _get_server_version(conn: Any, backend: str) -> str:
|
||||
"""Retrieve a human-readable server version string.
|
||||
|
||||
Args:
|
||||
conn: An active SQLAlchemy connection.
|
||||
backend: Backend identifier (``sqlite``, ``postgresql``, ``mysql``).
|
||||
|
||||
Returns:
|
||||
Server version string, or empty string on failure.
|
||||
"""
|
||||
try:
|
||||
if backend == "postgresql":
|
||||
row = conn.execute(text("SELECT version()")).fetchone()
|
||||
return str(row[0]) if row else ""
|
||||
elif backend == "mysql":
|
||||
row = conn.execute(text("SELECT version()")).fetchone()
|
||||
return str(row[0]) if row else ""
|
||||
elif backend == "sqlite":
|
||||
row = conn.execute(text("SELECT sqlite_version()")).fetchone()
|
||||
return f"SQLite {row[0]}" if row else ""
|
||||
except Exception:
|
||||
logger.debug("Could not retrieve server version")
|
||||
return ""
|
||||
|
||||
|
||||
def validate_url_format(url: str) -> dict[str, Any]:
|
||||
"""Validate that a connection string is syntactically correct.
|
||||
|
||||
Args:
|
||||
url: The connection string to validate.
|
||||
|
||||
Returns:
|
||||
Dict with ``valid`` (bool) and optional ``error`` (str).
|
||||
"""
|
||||
try:
|
||||
parsed = make_url(url)
|
||||
backend = parsed.get_backend_name()
|
||||
if backend not in ("sqlite", "postgresql", "mysql"):
|
||||
return {"valid": False, "error": f"Unsupported backend: {backend}"}
|
||||
return {"valid": True, "backend": backend}
|
||||
except Exception as exc:
|
||||
return {"valid": False, "error": str(exc)}
|
||||
@@ -4,6 +4,7 @@ Aggregated view routers for the application.
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.views.db_wizard import router as db_wizard_router
|
||||
from app.views.dropbox import router as dropbox_router
|
||||
from app.views.filemanager import router as filemanager_router
|
||||
|
||||
@@ -21,6 +22,7 @@ from app.views.wizard import router as wizard_router
|
||||
# Create a main router that includes all the view routers
|
||||
router = APIRouter()
|
||||
router.include_router(wizard_router) # Wizard first (for /setup)
|
||||
router.include_router(db_wizard_router) # Database wizard
|
||||
router.include_router(general_router)
|
||||
router.include_router(status_router)
|
||||
router.include_router(onedrive_router)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Database configuration wizard view.
|
||||
|
||||
Serves the guided UI for configuring a database connection string
|
||||
and migrating data from one database to another.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.config import settings
|
||||
from app.views.base import APIRouter, templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/database-wizard")
|
||||
async def database_wizard(request: Request) -> templates.TemplateResponse:
|
||||
"""Render the database configuration wizard page."""
|
||||
return templates.TemplateResponse(
|
||||
"db_wizard.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_database_url": settings.database_url,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user