Merge pull request #482 from christianlouis/copilot/add-database-configuration-wizard
feat(database): integrate wizard into settings page, improve accessibility and test coverage
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,170 @@
|
||||
"""
|
||||
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"]:
|
||||
error_summary = "; ".join(result.get("errors", ["Unknown error"]))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Migration completed with errors: {error_summary}",
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
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:
|
||||
# table_name is safe — sourced from inspect().get_table_names(), not user input
|
||||
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]
|
||||
# strict=False: column count should always match, but tolerate
|
||||
# minor schema drift (e.g. extra columns) to avoid crashing mid-migration.
|
||||
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,257 @@
|
||||
"""
|
||||
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 ""
|
||||
if not path:
|
||||
path = "./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)}
|
||||
@@ -23,11 +23,13 @@ SETTING_METADATA = {
|
||||
# Core Settings
|
||||
"database_url": {
|
||||
"category": "Core",
|
||||
"description": "Database connection URL (e.g., sqlite:///path/to/db.sqlite)",
|
||||
"description": "Database connection URL (e.g., sqlite:///path/to/db.sqlite). Use the Database Wizard for guided setup.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": True,
|
||||
"restart_required": True,
|
||||
"help_link": "/database-wizard",
|
||||
"help_link_label": "Open Database Wizard",
|
||||
},
|
||||
"redis_url": {
|
||||
"category": "Core",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
+119
@@ -1238,6 +1238,125 @@ Send a test notification through all configured notification channels.
|
||||
The API implements rate limiting to ensure system stability. If you exceed the limits, you'll receive a `429 Too Many Requests` response.
|
||||
|
||||
|
||||
## Database Configuration Wizard
|
||||
|
||||
Endpoints for building and testing database connection strings and migrating data between databases. All write endpoints require admin authentication.
|
||||
|
||||
### GET /api/database/backends
|
||||
|
||||
List supported database backends with metadata.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "sqlite",
|
||||
"label": "SQLite (Development)",
|
||||
"default_port": null,
|
||||
"description": "File-based database. Best for development and single-user setups.",
|
||||
"requires_host": false
|
||||
},
|
||||
{
|
||||
"id": "postgresql",
|
||||
"label": "PostgreSQL (Recommended for Production)",
|
||||
"default_port": 5432,
|
||||
"description": "Robust, full-featured database. Recommended for production.",
|
||||
"requires_host": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### POST /api/database/build-url
|
||||
|
||||
Build a SQLAlchemy connection string from individual components.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"backend": "postgresql",
|
||||
"host": "my-db.rds.amazonaws.com",
|
||||
"port": 5432,
|
||||
"database": "docuelevate",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
"ssl_mode": "require"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"url": "postgresql://admin:secret@my-db.rds.amazonaws.com:5432/docuelevate?sslmode=require"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/database/test-connection
|
||||
|
||||
Test connectivity to a database.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"url": "postgresql://admin:secret@my-db.rds.amazonaws.com:5432/docuelevate?sslmode=require"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Connection successful",
|
||||
"backend": "postgresql",
|
||||
"server_version": "PostgreSQL 16.2 on x86_64-pc-linux-gnu"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/database/preview-migration
|
||||
|
||||
Preview a data migration (table-by-table row counts) without copying data.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"url": "sqlite:///./app/database.db"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"tables": [
|
||||
{"name": "documents", "row_count": 42},
|
||||
{"name": "files", "row_count": 150}
|
||||
],
|
||||
"total_rows": 192
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/database/migrate
|
||||
|
||||
Execute a full data migration from source to target database.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"source_url": "sqlite:///./app/database.db",
|
||||
"target_url": "postgresql://admin:secret@host:5432/docuelevate"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"tables_copied": 8,
|
||||
"rows_copied": 192,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Further Assistance
|
||||
|
||||
For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md).
|
||||
|
||||
@@ -10,7 +10,7 @@ Configuration is primarily done through environment variables specified in a `.e
|
||||
|
||||
| **Variable** | **Description** | **Example** |
|
||||
|------------------------|----------------------------------------------------------|--------------------------------|
|
||||
| `DATABASE_URL` | Path/URL to the SQLite database (or other SQL backend). | `sqlite:///./app/database.db` |
|
||||
| `DATABASE_URL` | Path/URL to the SQLite database (or other SQL backend). Use the [Database Wizard](/database-wizard) for guided setup. See [Database Configuration](DatabaseConfiguration.md). | `sqlite:///./app/database.db` |
|
||||
| `REDIS_URL` | URL for Redis, used by Celery for broker & result store. | `redis://redis:6379/0` |
|
||||
| `WORKDIR` | Working directory for the application. | `/workdir` |
|
||||
| `GOTENBERG_URL` | Gotenberg PDF processing URL. | `http://gotenberg:3000` |
|
||||
|
||||
@@ -4,6 +4,8 @@ DocuElevate uses [SQLAlchemy](https://www.sqlalchemy.org/) as its ORM and [Alemb
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Database Configuration Wizard](#database-configuration-wizard)
|
||||
- [Database Migration Tool](#database-migration-tool)
|
||||
- [Supported Databases](#supported-databases)
|
||||
- [Configuration](#configuration)
|
||||
- [SQLite (Development)](#sqlite-development)
|
||||
@@ -17,6 +19,74 @@ DocuElevate uses [SQLAlchemy](https://www.sqlalchemy.org/) as its ORM and [Alemb
|
||||
|
||||
---
|
||||
|
||||
## Database Configuration Wizard
|
||||
|
||||
DocuElevate includes a guided **Database Configuration Wizard** accessible at `/database-wizard`. The wizard walks you through building a connection string step by step — no need to remember the exact URL format.
|
||||
|
||||
### How to Access
|
||||
|
||||
Navigate to **`/database-wizard`** in your browser, or find the link under **Admin → Settings**. On the Settings page, click the **DB Wizard** button in the toolbar, or look for the **Open Database Wizard** link next to the `database_url` setting.
|
||||
|
||||
### Wizard Steps
|
||||
|
||||
1. **Choose Database Type** — select SQLite, PostgreSQL, or MySQL/MariaDB.
|
||||
2. **Connection Details** — enter host, port, database name, credentials, and SSL mode (auto-populated with sensible defaults).
|
||||
3. **Test & Apply** — test the connection before committing, then copy the generated `DATABASE_URL` into your `.env` file.
|
||||
|
||||
The wizard generates the full SQLAlchemy connection string and lets you test connectivity directly from the UI. After testing, copy the `DATABASE_URL=…` line into your `.env` file (or Docker Compose environment) and restart DocuElevate.
|
||||
|
||||
### REST API
|
||||
|
||||
The wizard is backed by a REST API under `/api/database/`:
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/database/backends` | GET | List supported database backends |
|
||||
| `/api/database/build-url` | POST | Build a connection string from components |
|
||||
| `/api/database/parse-url` | POST | Parse a connection string into components |
|
||||
| `/api/database/validate-url` | POST | Validate URL format without connecting |
|
||||
| `/api/database/test-connection` | POST | Test connectivity to a database |
|
||||
|
||||
> **Note:** All write endpoints require admin authentication.
|
||||
|
||||
---
|
||||
|
||||
## Database Migration Tool
|
||||
|
||||
The **Migrate Data** tab (on the same `/database-wizard` page) lets you copy all your data from one database to another — for example, migrating from the built-in SQLite database to an external PostgreSQL or MySQL instance.
|
||||
|
||||
### When to Use
|
||||
|
||||
- Moving from a development SQLite database to a production PostgreSQL instance.
|
||||
- Migrating to a managed cloud database (AWS RDS, Google Cloud SQL, Azure Database for PostgreSQL, Supabase, etc.).
|
||||
- Consolidating data from one database engine to another.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Enter (or auto-fill) the **Source Database URL** — this is your current database.
|
||||
2. Enter the **Target Database URL** — the new, empty database to copy data into.
|
||||
3. Click **Test Source** and **Test Target** to verify both connections.
|
||||
4. Click **Preview Migration** to see a table-by-table row count.
|
||||
5. Confirm and click **Start Migration** to copy all data.
|
||||
|
||||
The migration tool:
|
||||
- Creates the full schema in the target database from the application models.
|
||||
- Copies all rows table by table in dependency order (parent tables first).
|
||||
- Stamps the Alembic migration version to `head` in the target.
|
||||
|
||||
After migration, update your `DATABASE_URL` environment variable to point at the new database and restart DocuElevate.
|
||||
|
||||
### REST API
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/database/preview-migration` | POST | Preview tables and row counts |
|
||||
| `/api/database/migrate` | POST | Execute the full data migration |
|
||||
|
||||
> **Warning:** Always run the migration against an **empty** target database. The tool does not delete existing data in the target before copying.
|
||||
|
||||
---
|
||||
|
||||
## Supported Databases
|
||||
|
||||
| Database | Recommended Use | Notes |
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Database Configuration Wizard - DocuElevate{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.wizard-step { display: none; }
|
||||
.wizard-step.active { display: block; }
|
||||
.db-card { transition: all 0.2s ease; }
|
||||
.db-card.selected { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,0.3); }
|
||||
.db-card:hover { border-color: #6366f1; }
|
||||
.mono-input { font-family: 'Courier New', monospace; }
|
||||
.migration-table { border-collapse: collapse; }
|
||||
.migration-table th, .migration-table td { padding: 0.5rem 1rem; text-align: left; }
|
||||
@keyframes pulse-bar { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||||
.animate-pulse-bar { animation: pulse-bar 1.5s ease-in-out infinite; }
|
||||
/* Dark mode overrides for db-wizard specific styles */
|
||||
html.dark .from-blue-50 { --tw-gradient-from: #1e3a5f; }
|
||||
html.dark .to-indigo-100 { --tw-gradient-to: #1e1b4b; }
|
||||
html.dark .db-card { background-color: #1f2937; border-color: #374151; }
|
||||
html.dark .db-card:hover { border-color: #818cf8; }
|
||||
html.dark .db-card.selected { border-color: #818cf8; box-shadow: 0 0 0 3px rgba(129,140,248,0.3); }
|
||||
/* Ensure smooth scroll only when user permits */
|
||||
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8"
|
||||
x-data="dbWizard()" x-init="init()">
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
|
||||
{# ── Header ── #}
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-2">
|
||||
<i class="fas fa-database text-indigo-600" aria-hidden="true"></i>
|
||||
Database Configuration Wizard
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600">
|
||||
Configure a new database connection or migrate your data to an external database.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# ── Tab Navigation ── #}
|
||||
<div class="flex justify-center mb-8 space-x-4" role="tablist" aria-label="Wizard tabs">
|
||||
<button @click="activeTab='configure'"
|
||||
:class="activeTab==='configure' ? 'bg-indigo-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'"
|
||||
class="px-6 py-3 rounded-lg font-semibold shadow transition"
|
||||
role="tab" :aria-selected="activeTab==='configure'" id="tab-configure" aria-controls="panel-configure">
|
||||
<i class="fas fa-cog mr-2" aria-hidden="true"></i> Configure Database
|
||||
</button>
|
||||
<button @click="activeTab='migrate'"
|
||||
:class="activeTab==='migrate' ? 'bg-indigo-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100'"
|
||||
class="px-6 py-3 rounded-lg font-semibold shadow transition"
|
||||
role="tab" :aria-selected="activeTab==='migrate'" id="tab-migrate" aria-controls="panel-migrate">
|
||||
<i class="fas fa-exchange-alt mr-2" aria-hidden="true"></i> Migrate Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
{# TAB 1 — Configure #}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
<div x-show="activeTab==='configure'" role="tabpanel" id="panel-configure" aria-labelledby="tab-configure">
|
||||
<div class="bg-white rounded-lg shadow-xl overflow-hidden">
|
||||
|
||||
{# Step indicator #}
|
||||
<div class="bg-indigo-600 px-6 py-4">
|
||||
<h2 class="text-2xl font-bold text-white">
|
||||
<span x-show="cfgStep===1"><i class="fas fa-server mr-2" aria-hidden="true"></i> Step 1: Choose Database Type</span>
|
||||
<span x-show="cfgStep===2"><i class="fas fa-plug mr-2" aria-hidden="true"></i> Step 2: Connection Details</span>
|
||||
<span x-show="cfgStep===3"><i class="fas fa-check-circle mr-2" aria-hidden="true"></i> Step 3: Test & Apply</span>
|
||||
</h2>
|
||||
<div class="flex mt-3 space-x-2">
|
||||
<template x-for="s in [1,2,3]" :key="s">
|
||||
<div class="h-2 flex-1 rounded-full"
|
||||
:class="s <= cfgStep ? 'bg-white' : 'bg-indigo-400'"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-8">
|
||||
|
||||
{# ── Step 1: Choose backend ── #}
|
||||
<div x-show="cfgStep===1">
|
||||
<p class="text-gray-600 mb-6">Select the database engine you want to use.</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
<button type="button" @click="selectBackend('sqlite')"
|
||||
:class="form.backend==='sqlite' ? 'selected' : ''"
|
||||
class="db-card border-2 rounded-lg p-6 text-left focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
aria-label="Select SQLite">
|
||||
<div class="text-3xl mb-2 text-yellow-600"><i class="fas fa-file-alt" aria-hidden="true"></i></div>
|
||||
<h3 class="font-bold text-lg">SQLite</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">File-based. Best for development & single-user setups.</p>
|
||||
</button>
|
||||
|
||||
<button type="button" @click="selectBackend('postgresql')"
|
||||
:class="form.backend==='postgresql' ? 'selected' : ''"
|
||||
class="db-card border-2 rounded-lg p-6 text-left focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
aria-label="Select PostgreSQL">
|
||||
<div class="text-3xl mb-2 text-blue-600"><i class="fas fa-elephant" aria-hidden="true"></i></div>
|
||||
<h3 class="font-bold text-lg">PostgreSQL</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">Recommended for production. Full feature support.</p>
|
||||
<span class="inline-block mt-2 px-2 py-0.5 bg-green-100 text-green-800 text-xs rounded font-semibold">Recommended</span>
|
||||
</button>
|
||||
|
||||
<button type="button" @click="selectBackend('mysql')"
|
||||
:class="form.backend==='mysql' ? 'selected' : ''"
|
||||
class="db-card border-2 rounded-lg p-6 text-left focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
aria-label="Select MySQL / MariaDB">
|
||||
<div class="text-3xl mb-2 text-orange-600"><i class="fas fa-database" aria-hidden="true"></i></div>
|
||||
<h3 class="font-bold text-lg">MySQL / MariaDB</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">Popular alternative. Requires pymysql driver.</p>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-8">
|
||||
<button @click="cfgStep=2" :disabled="!form.backend"
|
||||
class="px-6 py-3 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Next <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Step 2: Connection details ── #}
|
||||
<div x-show="cfgStep===2">
|
||||
|
||||
{# SQLite path #}
|
||||
<template x-if="form.backend==='sqlite'">
|
||||
<div class="space-y-4">
|
||||
<label for="sqlite_path" class="block text-sm font-medium text-gray-700">Database File Path</label>
|
||||
<input id="sqlite_path" x-model="form.sqlite_path" type="text"
|
||||
class="mono-input w-full px-4 py-3 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="./app/database.db"
|
||||
aria-describedby="sqlite_path_help" />
|
||||
<p id="sqlite_path_help" class="text-xs text-gray-500">Leave blank to use the default path <code>./app/database.db</code>.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Host-based databases #}
|
||||
<template x-if="form.backend!=='sqlite'">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="db_host" class="block text-sm font-medium text-gray-700">Host <span class="text-red-600">*</span></label>
|
||||
<input id="db_host" x-model="form.host" type="text"
|
||||
class="mono-input w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="localhost or my-db.rds.amazonaws.com" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_port" class="block text-sm font-medium text-gray-700">Port</label>
|
||||
<input id="db_port" x-model.number="form.port" type="number"
|
||||
class="mono-input w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
:placeholder="form.backend==='postgresql' ? '5432' : '3306'" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_name" class="block text-sm font-medium text-gray-700">Database Name <span class="text-red-600">*</span></label>
|
||||
<input id="db_name" x-model="form.database" type="text"
|
||||
class="mono-input w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="docuelevate" required />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="db_user" class="block text-sm font-medium text-gray-700">Username <span class="text-red-600">*</span></label>
|
||||
<input id="db_user" x-model="form.username" type="text"
|
||||
class="mono-input w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="docuelevate" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_pass" class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input id="db_pass" x-model="form.password" type="password"
|
||||
class="mono-input w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="form.backend==='postgresql'">
|
||||
<label for="ssl_mode" class="block text-sm font-medium text-gray-700">SSL Mode</label>
|
||||
<select id="ssl_mode" x-model="form.ssl_mode"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
aria-describedby="ssl_mode_help">
|
||||
<option value="">None (disable)</option>
|
||||
<option value="require">require</option>
|
||||
<option value="verify-ca">verify-ca</option>
|
||||
<option value="verify-full">verify-full</option>
|
||||
</select>
|
||||
<p id="ssl_mode_help" class="text-xs text-gray-500 mt-1">Use <code>require</code> for managed cloud databases (AWS RDS, Supabase, etc.).</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Live preview of the URL #}
|
||||
<div class="mt-6 p-4 bg-gray-50 rounded-lg border">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Generated Connection String</label>
|
||||
<code class="block text-sm break-all text-indigo-700" x-text="builtUrl || '(fill in details above)'"></code>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between mt-8">
|
||||
<button @click="cfgStep=1"
|
||||
class="px-6 py-3 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
|
||||
</button>
|
||||
<button @click="cfgStep=3; buildUrl()"
|
||||
class="px-6 py-3 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Next <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Step 3: Test & Apply ── #}
|
||||
<div x-show="cfgStep===3">
|
||||
|
||||
<div class="mb-6 p-4 bg-gray-50 rounded-lg border">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Connection String</label>
|
||||
<code class="block text-sm break-all text-indigo-700" x-text="builtUrl"></code>
|
||||
</div>
|
||||
|
||||
{# Test button #}
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<button @click="testConnection()"
|
||||
:disabled="testLoading"
|
||||
class="px-6 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
|
||||
<i class="fas fa-plug mr-2" aria-hidden="true"></i>
|
||||
<span x-text="testLoading ? 'Testing…' : 'Test Connection'"></span>
|
||||
</button>
|
||||
<div x-show="testResult" class="flex-1">
|
||||
<div x-show="testResult?.success" class="bg-green-100 border-l-4 border-green-500 text-green-800 p-3 rounded" role="status">
|
||||
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i>
|
||||
<span x-text="testResult?.message"></span>
|
||||
<span x-show="testResult?.server_version" class="block text-xs mt-1" x-text="'Server: ' + testResult?.server_version"></span>
|
||||
</div>
|
||||
<div x-show="!testResult?.success" class="bg-red-100 border-l-4 border-red-500 text-red-800 p-3 rounded" role="alert">
|
||||
<i class="fas fa-times-circle mr-1" aria-hidden="true"></i>
|
||||
<span x-text="testResult?.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Apply as DATABASE_URL #}
|
||||
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-6">
|
||||
<p class="text-sm text-amber-800">
|
||||
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||
<strong>To use this database</strong>, set the <code>DATABASE_URL</code> environment variable
|
||||
(in your <code>.env</code> file or Docker Compose config) to the connection string above,
|
||||
then restart DocuElevate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border rounded-lg bg-gray-50">
|
||||
<label for="env_snippet" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Copy this into your <code>.env</code> file:
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<input id="env_snippet" type="text" readonly
|
||||
:value="'DATABASE_URL=' + builtUrl"
|
||||
class="mono-input flex-1 px-4 py-3 border border-gray-300 rounded-l-md bg-white text-sm" />
|
||||
<button @click="copyToClipboard('DATABASE_URL=' + builtUrl)"
|
||||
class="px-4 py-3 bg-indigo-600 text-white rounded-r-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
aria-label="Copy to clipboard">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="copied" x-transition class="text-xs text-green-600 mt-1" role="status">
|
||||
<i class="fas fa-check" aria-hidden="true"></i> Copied!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between mt-8">
|
||||
<button @click="cfgStep=2"
|
||||
class="px-6 py-3 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
|
||||
</button>
|
||||
<a href="/settings"
|
||||
class="px-6 py-3 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 inline-flex items-center">
|
||||
Go to Settings <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
{# TAB 2 — Migrate #}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
<div x-show="activeTab==='migrate'" role="tabpanel" id="panel-migrate" aria-labelledby="tab-migrate">
|
||||
<div class="bg-white rounded-lg shadow-xl overflow-hidden">
|
||||
|
||||
<div class="bg-indigo-600 px-6 py-4">
|
||||
<h2 class="text-2xl font-bold text-white">
|
||||
<i class="fas fa-exchange-alt mr-2" aria-hidden="true"></i>
|
||||
Migrate Data Between Databases
|
||||
</h2>
|
||||
<p class="text-indigo-100 mt-1">
|
||||
Copy all your data from one database to another (e.g. SQLite → PostgreSQL).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-8 space-y-6">
|
||||
|
||||
{# Source URL #}
|
||||
<div>
|
||||
<label for="mig_source" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Source Database URL
|
||||
</label>
|
||||
<input id="mig_source" x-model="migrate.source" type="text"
|
||||
class="mono-input w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
:placeholder="currentDbUrl || 'sqlite:///./app/database.db'"
|
||||
aria-describedby="mig_source_help" />
|
||||
<p id="mig_source_help" class="text-xs text-gray-500 mt-1">
|
||||
This is your current database. Pre-filled with the running configuration.
|
||||
</p>
|
||||
<button @click="migrate.source = currentDbUrl"
|
||||
class="mt-1 text-xs text-indigo-600 hover:underline focus:outline-none"
|
||||
type="button">
|
||||
<i class="fas fa-sync-alt mr-1" aria-hidden="true"></i> Use current database
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Target URL #}
|
||||
<div>
|
||||
<label for="mig_target" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Target Database URL
|
||||
</label>
|
||||
<input id="mig_target" x-model="migrate.target" type="text"
|
||||
class="mono-input w-full px-4 py-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="postgresql://user:pass@host:5432/docuelevate"
|
||||
aria-describedby="mig_target_help" />
|
||||
<p id="mig_target_help" class="text-xs text-gray-500 mt-1">
|
||||
The new database to copy data into. Must be empty (schema will be created automatically).
|
||||
</p>
|
||||
<button @click="if(builtUrl) migrate.target = builtUrl"
|
||||
x-show="builtUrl"
|
||||
class="mt-1 text-xs text-indigo-600 hover:underline focus:outline-none"
|
||||
type="button">
|
||||
<i class="fas fa-magic mr-1" aria-hidden="true"></i> Use URL from Configure tab
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="flex space-x-4">
|
||||
<button @click="testMigrateConnection('source')"
|
||||
:disabled="!migrate.source || migrateLoading"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 disabled:opacity-50 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
|
||||
<i class="fas fa-plug mr-1" aria-hidden="true"></i> Test Source
|
||||
</button>
|
||||
<button @click="testMigrateConnection('target')"
|
||||
:disabled="!migrate.target || migrateLoading"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 disabled:opacity-50 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
|
||||
<i class="fas fa-plug mr-1" aria-hidden="true"></i> Test Target
|
||||
</button>
|
||||
<button @click="previewMigration()"
|
||||
:disabled="!migrate.source || migrateLoading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<i class="fas fa-search mr-1" aria-hidden="true"></i> Preview Migration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Connection test results #}
|
||||
<div x-show="migrate.sourceTest" class="text-sm" role="status">
|
||||
<span :class="migrate.sourceTest?.success ? 'text-green-700' : 'text-red-700'">
|
||||
<i :class="migrate.sourceTest?.success ? 'fas fa-check-circle' : 'fas fa-times-circle'" aria-hidden="true"></i>
|
||||
Source: <span x-text="migrate.sourceTest?.message"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div x-show="migrate.targetTest" class="text-sm" role="status">
|
||||
<span :class="migrate.targetTest?.success ? 'text-green-700' : 'text-red-700'">
|
||||
<i :class="migrate.targetTest?.success ? 'fas fa-check-circle' : 'fas fa-times-circle'" aria-hidden="true"></i>
|
||||
Target: <span x-text="migrate.targetTest?.message"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Preview table #}
|
||||
<div x-show="migrate.preview" class="border rounded-lg overflow-hidden">
|
||||
<table class="migration-table w-full text-sm">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th scope="col">Table</th>
|
||||
<th scope="col" class="text-right">Rows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="t in migrate.preview?.tables || []" :key="t.name">
|
||||
<tr class="border-t">
|
||||
<td x-text="t.name"></td>
|
||||
<td class="text-right" x-text="t.row_count.toLocaleString()"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr class="border-t font-bold bg-gray-50">
|
||||
<td>Total</td>
|
||||
<td class="text-right" x-text="(migrate.preview?.total_rows || 0).toLocaleString()"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Migrate button #}
|
||||
<div x-show="migrate.preview && migrate.preview.success" class="mt-4">
|
||||
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-4">
|
||||
<p class="text-sm text-amber-800">
|
||||
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||
<strong>Warning:</strong> This will copy all data to the target database.
|
||||
The target must be empty. This operation cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="inline-flex items-center mb-4">
|
||||
<input type="checkbox" x-model="migrate.confirmed" class="form-checkbox text-indigo-600 rounded" />
|
||||
<span class="ml-2 text-sm">I understand and want to proceed with the migration</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<button @click="executeMigration()"
|
||||
:disabled="!migrate.confirmed || migrateLoading"
|
||||
class="px-6 py-3 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
<i class="fas fa-play mr-2" aria-hidden="true"></i>
|
||||
<span x-text="migrateLoading ? 'Migrating…' : 'Start Migration'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Migration progress / result #}
|
||||
<div x-show="migrateLoading" class="mt-4" role="status" aria-live="polite">
|
||||
<div class="w-full bg-gray-200 rounded-full h-3" role="progressbar" aria-label="Migration in progress" aria-valuetext="Migrating data">
|
||||
<div class="bg-indigo-600 h-3 rounded-full animate-pulse-bar" style="width:100%"></div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mt-2">Migration in progress — please do not close this page…</p>
|
||||
</div>
|
||||
|
||||
<div x-show="migrate.result" class="mt-4" role="status" aria-live="polite">
|
||||
<div x-show="migrate.result?.success" class="bg-green-100 border-l-4 border-green-500 text-green-800 p-4 rounded">
|
||||
<p class="font-bold"><i class="fas fa-check-circle mr-1" aria-hidden="true"></i> Migration Successful</p>
|
||||
<p class="text-sm mt-1">
|
||||
Copied <strong x-text="migrate.result?.rows_copied?.toLocaleString()"></strong> rows
|
||||
across <strong x-text="migrate.result?.tables_copied"></strong> tables.
|
||||
</p>
|
||||
<p class="text-sm mt-2">
|
||||
Update your <code>DATABASE_URL</code> environment variable to the target URL and restart DocuElevate.
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="!migrate.result?.success" class="bg-red-100 border-l-4 border-red-500 text-red-800 p-4 rounded">
|
||||
<p class="font-bold"><i class="fas fa-times-circle mr-1" aria-hidden="true"></i> Migration Failed</p>
|
||||
<template x-for="err in migrate.result?.errors || migrate.result?.detail?.errors || []" :key="err">
|
||||
<p class="text-sm mt-1" x-text="err"></p>
|
||||
</template>
|
||||
<p x-show="migrate.result?.detail && typeof migrate.result.detail === 'string'" class="text-sm mt-1" x-text="migrate.result?.detail"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Help text ── #}
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
<i class="fas fa-book mr-1" aria-hidden="true"></i>
|
||||
See the <a href="/docs/DatabaseConfiguration" class="text-indigo-600 hover:underline">Database Configuration Guide</a> for more details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function dbWizard() {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
return {
|
||||
activeTab: 'configure',
|
||||
cfgStep: 1,
|
||||
currentDbUrl: '{{ current_database_url | default("", true) }}',
|
||||
|
||||
form: {
|
||||
backend: '',
|
||||
host: '',
|
||||
port: null,
|
||||
database: '',
|
||||
username: '',
|
||||
password: '',
|
||||
ssl_mode: '',
|
||||
sqlite_path: '',
|
||||
},
|
||||
|
||||
builtUrl: '',
|
||||
testLoading: false,
|
||||
testResult: null,
|
||||
copied: false,
|
||||
|
||||
migrate: {
|
||||
source: '{{ current_database_url | default("", true) }}',
|
||||
target: '',
|
||||
sourceTest: null,
|
||||
targetTest: null,
|
||||
preview: null,
|
||||
confirmed: false,
|
||||
result: null,
|
||||
},
|
||||
migrateLoading: false,
|
||||
|
||||
init() {
|
||||
this.$watch('form', () => this.buildUrlLocal(), {deep: true});
|
||||
},
|
||||
|
||||
selectBackend(b) {
|
||||
this.form.backend = b;
|
||||
// Set sensible defaults
|
||||
if (b === 'postgresql') { this.form.port = 5432; }
|
||||
else if (b === 'mysql') { this.form.port = 3306; }
|
||||
else { this.form.port = null; }
|
||||
},
|
||||
|
||||
buildUrlLocal() {
|
||||
const f = this.form;
|
||||
if (f.backend === 'sqlite') {
|
||||
this.builtUrl = 'sqlite:///' + (f.sqlite_path || './app/database.db');
|
||||
return;
|
||||
}
|
||||
if (!f.backend || !f.host || !f.database || !f.username) {
|
||||
this.builtUrl = '';
|
||||
return;
|
||||
}
|
||||
let driver = f.backend === 'mysql' ? 'mysql+pymysql' : f.backend;
|
||||
let auth = f.username;
|
||||
if (f.password) auth += ':' + f.password;
|
||||
let port = f.port || (f.backend === 'postgresql' ? 5432 : 3306);
|
||||
let url = driver + '://' + auth + '@' + f.host + ':' + port + '/' + f.database;
|
||||
let params = [];
|
||||
if (f.ssl_mode) params.push('sslmode=' + f.ssl_mode);
|
||||
if (f.backend === 'mysql') params.push('charset=utf8mb4');
|
||||
if (params.length) url += '?' + params.join('&');
|
||||
this.builtUrl = url;
|
||||
},
|
||||
|
||||
async buildUrl() {
|
||||
this.buildUrlLocal();
|
||||
},
|
||||
|
||||
async testConnection() {
|
||||
this.testLoading = true;
|
||||
this.testResult = null;
|
||||
try {
|
||||
const res = await fetch('/api/database/test-connection', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
|
||||
body: JSON.stringify({url: this.builtUrl})
|
||||
});
|
||||
this.testResult = await res.json();
|
||||
} catch (e) {
|
||||
this.testResult = {success: false, message: 'Request failed: ' + e.message};
|
||||
}
|
||||
this.testLoading = false;
|
||||
},
|
||||
|
||||
async testMigrateConnection(which) {
|
||||
this.migrateLoading = true;
|
||||
const url = which === 'source' ? this.migrate.source : this.migrate.target;
|
||||
try {
|
||||
const res = await fetch('/api/database/test-connection', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
|
||||
body: JSON.stringify({url})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (which === 'source') this.migrate.sourceTest = data;
|
||||
else this.migrate.targetTest = data;
|
||||
} catch (e) {
|
||||
const err = {success: false, message: 'Request failed: ' + e.message};
|
||||
if (which === 'source') this.migrate.sourceTest = err;
|
||||
else this.migrate.targetTest = err;
|
||||
}
|
||||
this.migrateLoading = false;
|
||||
},
|
||||
|
||||
async previewMigration() {
|
||||
this.migrateLoading = true;
|
||||
this.migrate.preview = null;
|
||||
try {
|
||||
const res = await fetch('/api/database/preview-migration', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
|
||||
body: JSON.stringify({url: this.migrate.source})
|
||||
});
|
||||
this.migrate.preview = await res.json();
|
||||
} catch (e) {
|
||||
this.migrate.preview = {success: false, error: e.message, tables: [], total_rows: 0};
|
||||
}
|
||||
this.migrateLoading = false;
|
||||
},
|
||||
|
||||
async executeMigration() {
|
||||
this.migrateLoading = true;
|
||||
this.migrate.result = null;
|
||||
try {
|
||||
const res = await fetch('/api/database/migrate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
|
||||
body: JSON.stringify({source_url: this.migrate.source, target_url: this.migrate.target})
|
||||
});
|
||||
this.migrate.result = await res.json();
|
||||
} catch (e) {
|
||||
this.migrate.result = {success: false, errors: ['Request failed: ' + e.message]};
|
||||
}
|
||||
this.migrateLoading = false;
|
||||
},
|
||||
|
||||
copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.copied = true;
|
||||
setTimeout(() => this.copied = false, 2000);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -60,6 +60,10 @@
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-magic mr-1.5" aria-hidden="true"></i> Wizard
|
||||
</a>
|
||||
<a href="/database-wizard"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
<i class="fas fa-database mr-1.5" aria-hidden="true"></i> DB Wizard
|
||||
</a>
|
||||
<div class="relative" x-data="{ exportOpen: false }">
|
||||
<button @click="exportOpen = !exportOpen"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
@@ -236,6 +240,14 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 mb-2">{{ setting.metadata.description }}</p>
|
||||
{% if setting.metadata.get('help_link') %}
|
||||
<p class="text-xs mb-2">
|
||||
<a href="{{ setting.metadata.help_link }}"
|
||||
class="text-indigo-600 hover:underline focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded">
|
||||
<i class="fas fa-external-link-alt mr-1" aria-hidden="true"></i>{{ setting.metadata.get('help_link_label', 'More info') }}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if setting.metadata.type == 'boolean' %}
|
||||
<!-- Boolean/Checkbox Input -->
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Tests for app/utils/db_migrate.py module."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
from app.utils.db_migrate import (
|
||||
_make_engine,
|
||||
_ordered_tables,
|
||||
_stamp_alembic_head,
|
||||
migrate_data,
|
||||
preview_migration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMakeEngine:
|
||||
"""Tests for _make_engine helper function."""
|
||||
|
||||
def test_sqlite_engine_has_check_same_thread(self):
|
||||
"""Test that SQLite engine has check_same_thread set."""
|
||||
engine = _make_engine("sqlite:///:memory:")
|
||||
assert engine is not None
|
||||
engine.dispose()
|
||||
|
||||
def test_non_sqlite_engine_created(self):
|
||||
"""Test that non-SQLite engine can be created (even if driver is missing)."""
|
||||
# _make_engine only creates the engine object; it doesn't connect.
|
||||
# If the driver isn't installed, create_engine raises at creation time.
|
||||
try:
|
||||
engine = _make_engine("postgresql://u:p@localhost:5432/test")
|
||||
assert engine is not None
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
# Driver not installed in test environment — acceptable
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOrderedTables:
|
||||
"""Tests for _ordered_tables helper function."""
|
||||
|
||||
def test_known_tables_come_first(self):
|
||||
"""Test that known tables from _TABLE_ORDER come first."""
|
||||
mock_inspector = MagicMock()
|
||||
mock_inspector.get_table_names.return_value = [
|
||||
"webhook_configs",
|
||||
"documents",
|
||||
"files",
|
||||
"custom_table",
|
||||
"alembic_version",
|
||||
]
|
||||
result = _ordered_tables(mock_inspector)
|
||||
# alembic_version should be skipped
|
||||
assert "alembic_version" not in result
|
||||
# Known tables should come first in their predefined order
|
||||
assert result.index("documents") < result.index("files")
|
||||
assert result.index("files") < result.index("webhook_configs")
|
||||
# custom_table is not in _TABLE_ORDER so comes after known tables
|
||||
assert "custom_table" in result
|
||||
|
||||
def test_skips_alembic_version(self):
|
||||
"""Test that alembic_version table is always skipped."""
|
||||
mock_inspector = MagicMock()
|
||||
mock_inspector.get_table_names.return_value = ["alembic_version", "documents"]
|
||||
result = _ordered_tables(mock_inspector)
|
||||
assert "alembic_version" not in result
|
||||
assert "documents" in result
|
||||
|
||||
def test_unknown_tables_appended_alphabetically(self):
|
||||
"""Test that tables not in _TABLE_ORDER are appended alphabetically."""
|
||||
mock_inspector = MagicMock()
|
||||
mock_inspector.get_table_names.return_value = ["zebra", "apple", "documents"]
|
||||
result = _ordered_tables(mock_inspector)
|
||||
assert result[0] == "documents"
|
||||
# apple and zebra should be after documents, in alpha order
|
||||
remaining = result[1:]
|
||||
assert remaining == sorted(remaining)
|
||||
|
||||
def test_empty_database(self):
|
||||
"""Test with an empty database returns empty list."""
|
||||
mock_inspector = MagicMock()
|
||||
mock_inspector.get_table_names.return_value = []
|
||||
result = _ordered_tables(mock_inspector)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPreviewMigration:
|
||||
"""Tests for preview_migration function."""
|
||||
|
||||
def test_preview_in_memory_sqlite(self):
|
||||
"""Test previewing an in-memory SQLite database."""
|
||||
# Create a temporary source DB with some data
|
||||
src_engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=src_engine)
|
||||
|
||||
# Insert a test row
|
||||
Session = sessionmaker(bind=src_engine)
|
||||
session = Session()
|
||||
session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')"))
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
# Preview using the engine's URL won't work for :memory:,
|
||||
# but we can test the error path
|
||||
result = preview_migration("sqlite:///:memory:")
|
||||
# For :memory: this creates a new empty DB, so tables are empty
|
||||
assert result["success"] is True
|
||||
assert isinstance(result["tables"], list)
|
||||
|
||||
def test_preview_invalid_url(self):
|
||||
"""Test preview with invalid URL returns error."""
|
||||
result = preview_migration("invalid://not-a-db")
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_preview_with_patched_source_shows_tables(self):
|
||||
"""Test preview with source that has tables and data."""
|
||||
real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=real_src)
|
||||
|
||||
# Insert test data
|
||||
Session = sessionmaker(bind=real_src)
|
||||
session = Session()
|
||||
session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')"))
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
with patch("app.utils.db_migrate._make_engine", return_value=real_src):
|
||||
result = preview_migration("sqlite:///:memory:")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["total_rows"] >= 1
|
||||
# At least the documents table should be in results
|
||||
table_names = [t["name"] for t in result["tables"]]
|
||||
assert "documents" in table_names
|
||||
doc_table = next(t for t in result["tables"] if t["name"] == "documents")
|
||||
assert doc_table["row_count"] >= 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMigrateData:
|
||||
"""Tests for migrate_data function."""
|
||||
|
||||
def test_migrate_empty_sqlite_to_sqlite(self):
|
||||
"""Test migrating an empty SQLite DB to another SQLite DB."""
|
||||
# Both are file-based temp databases for this test
|
||||
src_url = "sqlite:///:memory:"
|
||||
tgt_url = "sqlite://" # Another in-memory DB
|
||||
|
||||
# Create source schema
|
||||
src_engine = create_engine(src_url, connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=src_engine)
|
||||
src_engine.dispose()
|
||||
|
||||
# Run migration from empty source
|
||||
with patch("app.utils.db_migrate._make_engine") as mock_make:
|
||||
# Create real engines for both
|
||||
real_src = create_engine(
|
||||
"sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
Base.metadata.create_all(bind=real_src)
|
||||
real_tgt = create_engine(
|
||||
"sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
mock_make.side_effect = [real_src, real_tgt]
|
||||
|
||||
with patch("app.utils.db_migrate._stamp_alembic_head"):
|
||||
result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["rows_copied"] == 0
|
||||
|
||||
def test_migrate_with_data(self):
|
||||
"""Test migrating a SQLite DB with actual data."""
|
||||
real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=real_src)
|
||||
|
||||
# Insert test data
|
||||
Session = sessionmaker(bind=real_src)
|
||||
session = Session()
|
||||
session.execute(text("INSERT INTO documents (filename) VALUES ('invoice.pdf')"))
|
||||
session.execute(text("INSERT INTO documents (filename) VALUES ('receipt.pdf')"))
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
|
||||
with patch("app.utils.db_migrate._make_engine") as mock_make:
|
||||
mock_make.side_effect = [real_src, real_tgt]
|
||||
with patch("app.utils.db_migrate._stamp_alembic_head"):
|
||||
result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["rows_copied"] >= 2 # At least the 2 documents rows
|
||||
|
||||
def test_migrate_with_progress_callback(self):
|
||||
"""Test that progress callback is invoked during migration."""
|
||||
real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=real_src)
|
||||
|
||||
Session = sessionmaker(bind=real_src)
|
||||
session = Session()
|
||||
session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')"))
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
callback = MagicMock()
|
||||
|
||||
with patch("app.utils.db_migrate._make_engine") as mock_make:
|
||||
mock_make.side_effect = [real_src, real_tgt]
|
||||
with patch("app.utils.db_migrate._stamp_alembic_head"):
|
||||
result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:", progress_callback=callback)
|
||||
|
||||
assert result["success"] is True
|
||||
# Callback should have been called at least once for the non-empty table
|
||||
if result["rows_copied"] > 0:
|
||||
assert callback.call_count > 0
|
||||
|
||||
def test_migrate_global_exception(self):
|
||||
"""Test that a global exception is caught gracefully."""
|
||||
with patch("app.utils.db_migrate._make_engine", side_effect=Exception("boom")):
|
||||
result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")
|
||||
assert result["success"] is False
|
||||
assert len(result["errors"]) > 0
|
||||
|
||||
def test_migrate_stamp_failure_is_recorded(self):
|
||||
"""Test that Alembic stamp failure is recorded as an error."""
|
||||
real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=real_src)
|
||||
real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
|
||||
with patch("app.utils.db_migrate._make_engine") as mock_make:
|
||||
mock_make.side_effect = [real_src, real_tgt]
|
||||
with patch("app.utils.db_migrate._stamp_alembic_head", side_effect=Exception("stamp failed")):
|
||||
result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")
|
||||
|
||||
# Data copy succeeds but stamp fails — errors list non-empty
|
||||
assert len(result["errors"]) > 0
|
||||
assert any("stamp" in e.lower() for e in result["errors"])
|
||||
|
||||
def test_migrate_table_copy_exception(self):
|
||||
"""Test that per-table copy exception is recorded but migration continues."""
|
||||
real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=real_src)
|
||||
|
||||
# Insert data so the table isn't empty
|
||||
Session = sessionmaker(bind=real_src)
|
||||
session = Session()
|
||||
session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')"))
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=real_tgt)
|
||||
|
||||
# Make the target reflect fail for one table to trigger the error path
|
||||
original_reflect = MagicMock(side_effect=Exception("reflect error"))
|
||||
|
||||
with patch("app.utils.db_migrate._make_engine") as mock_make:
|
||||
mock_make.side_effect = [real_src, real_tgt]
|
||||
with patch("app.utils.db_migrate._stamp_alembic_head"):
|
||||
# Patch MetaData so that reflecting target raises for the first table
|
||||
with patch("app.utils.db_migrate.MetaData") as mock_meta_cls:
|
||||
# First MetaData() is for source reflect (should work)
|
||||
src_meta = MagicMock()
|
||||
src_table = MagicMock()
|
||||
src_table.columns = []
|
||||
src_table.select.return_value = text("SELECT 1")
|
||||
src_meta.tables = {"documents": src_table}
|
||||
src_meta.reflect = MagicMock()
|
||||
|
||||
# Second MetaData() is for target reflect (should fail)
|
||||
tgt_meta = MagicMock()
|
||||
tgt_meta.reflect.side_effect = Exception("target reflect error")
|
||||
|
||||
mock_meta_cls.side_effect = [src_meta, tgt_meta]
|
||||
result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")
|
||||
|
||||
assert any("Error copying table" in e for e in result["errors"])
|
||||
|
||||
def test_migrate_target_table_not_found(self):
|
||||
"""Test that missing target table after reflect is recorded."""
|
||||
real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=real_src)
|
||||
|
||||
Session = sessionmaker(bind=real_src)
|
||||
session = Session()
|
||||
session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')"))
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
# Create schema in target so reflect works but returns empty
|
||||
Base.metadata.create_all(bind=real_tgt)
|
||||
|
||||
with patch("app.utils.db_migrate._make_engine") as mock_make:
|
||||
mock_make.side_effect = [real_src, real_tgt]
|
||||
with patch("app.utils.db_migrate._stamp_alembic_head"):
|
||||
result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")
|
||||
|
||||
# With schema in target, migration should succeed normally
|
||||
assert result["success"] is True
|
||||
|
||||
def test_migrate_returns_tables_and_rows_counts(self):
|
||||
"""Test that successful migration returns expected count fields."""
|
||||
real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=real_src)
|
||||
real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
|
||||
with patch("app.utils.db_migrate._make_engine") as mock_make:
|
||||
mock_make.side_effect = [real_src, real_tgt]
|
||||
with patch("app.utils.db_migrate._stamp_alembic_head"):
|
||||
result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:")
|
||||
|
||||
assert "tables_copied" in result
|
||||
assert "rows_copied" in result
|
||||
assert "errors" in result
|
||||
assert isinstance(result["errors"], list)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestStampAlembicHead:
|
||||
"""Tests for _stamp_alembic_head helper function."""
|
||||
|
||||
def test_stamp_calls_alembic_command(self):
|
||||
"""Test that stamping calls alembic command.stamp with 'head'."""
|
||||
mock_engine = MagicMock()
|
||||
mock_connection = MagicMock()
|
||||
mock_engine.begin.return_value.__enter__ = MagicMock(return_value=mock_connection)
|
||||
mock_engine.begin.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("alembic.command.stamp") as mock_stamp:
|
||||
_stamp_alembic_head(mock_engine)
|
||||
mock_stamp.assert_called_once()
|
||||
# Verify it stamps to "head"
|
||||
args = mock_stamp.call_args
|
||||
assert args[0][1] == "head"
|
||||
|
||||
def test_stamp_raises_on_error(self):
|
||||
"""Test that stamp propagates exceptions."""
|
||||
mock_engine = MagicMock()
|
||||
mock_connection = MagicMock()
|
||||
mock_engine.begin.return_value.__enter__ = MagicMock(return_value=mock_connection)
|
||||
mock_engine.begin.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("alembic.command.stamp", side_effect=Exception("stamp error")):
|
||||
with pytest.raises(Exception, match="stamp error"):
|
||||
_stamp_alembic_head(mock_engine)
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Tests for app/utils/db_wizard.py module."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.utils.db_wizard import (
|
||||
_get_server_version,
|
||||
build_connection_string,
|
||||
get_supported_backends,
|
||||
parse_connection_string,
|
||||
validate_url_format,
|
||||
)
|
||||
from app.utils.db_wizard import (
|
||||
test_connection as db_test_connection,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetSupportedBackends:
|
||||
"""Tests for get_supported_backends function."""
|
||||
|
||||
def test_returns_list(self):
|
||||
"""Test that it returns a non-empty list."""
|
||||
result = get_supported_backends()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 3
|
||||
|
||||
def test_each_backend_has_required_keys(self):
|
||||
"""Test that each backend has expected keys."""
|
||||
required_keys = {"id", "label", "description", "requires_host"}
|
||||
for backend in get_supported_backends():
|
||||
assert required_keys.issubset(set(backend.keys())), f"Missing keys in {backend.get('id')}"
|
||||
|
||||
def test_includes_sqlite(self):
|
||||
"""Test that SQLite is included."""
|
||||
ids = [b["id"] for b in get_supported_backends()]
|
||||
assert "sqlite" in ids
|
||||
|
||||
def test_includes_postgresql(self):
|
||||
"""Test that PostgreSQL is included."""
|
||||
ids = [b["id"] for b in get_supported_backends()]
|
||||
assert "postgresql" in ids
|
||||
|
||||
def test_includes_mysql(self):
|
||||
"""Test that MySQL is included."""
|
||||
ids = [b["id"] for b in get_supported_backends()]
|
||||
assert "mysql" in ids
|
||||
|
||||
def test_sqlite_does_not_require_host(self):
|
||||
"""Test that SQLite backend does not require host."""
|
||||
sqlite = next(b for b in get_supported_backends() if b["id"] == "sqlite")
|
||||
assert sqlite["requires_host"] is False
|
||||
|
||||
def test_postgresql_requires_host(self):
|
||||
"""Test that PostgreSQL backend requires host."""
|
||||
pg = next(b for b in get_supported_backends() if b["id"] == "postgresql")
|
||||
assert pg["requires_host"] is True
|
||||
|
||||
def test_mysql_default_port(self):
|
||||
"""Test that MySQL has default port 3306."""
|
||||
mysql = next(b for b in get_supported_backends() if b["id"] == "mysql")
|
||||
assert mysql["default_port"] == 3306
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBuildConnectionString:
|
||||
"""Tests for build_connection_string function."""
|
||||
|
||||
def test_sqlite_default_path(self):
|
||||
"""Test building a SQLite URL with default path."""
|
||||
url = build_connection_string(backend="sqlite")
|
||||
assert url == "sqlite:///./app/database.db"
|
||||
|
||||
def test_sqlite_custom_path(self):
|
||||
"""Test building a SQLite URL with custom path."""
|
||||
url = build_connection_string(backend="sqlite", sqlite_path="/data/mydb.db")
|
||||
assert url == "sqlite:////data/mydb.db"
|
||||
|
||||
def test_sqlite_whitespace_path(self):
|
||||
"""Test building a SQLite URL with whitespace-only path uses default."""
|
||||
url = build_connection_string(backend="sqlite", sqlite_path=" ")
|
||||
assert url == "sqlite:///./app/database.db"
|
||||
|
||||
def test_postgresql_basic(self):
|
||||
"""Test building a basic PostgreSQL URL."""
|
||||
url = build_connection_string(
|
||||
backend="postgresql",
|
||||
host="localhost",
|
||||
database="docuelevate",
|
||||
username="user",
|
||||
password="pass",
|
||||
)
|
||||
assert url == "postgresql://user:pass@localhost:5432/docuelevate"
|
||||
|
||||
def test_postgresql_with_ssl(self):
|
||||
"""Test building a PostgreSQL URL with SSL."""
|
||||
url = build_connection_string(
|
||||
backend="postgresql",
|
||||
host="rds.amazonaws.com",
|
||||
database="docuelevate",
|
||||
username="admin",
|
||||
password="secret",
|
||||
ssl_mode="require",
|
||||
)
|
||||
assert "sslmode=require" in url
|
||||
assert "postgresql://admin:secret@rds.amazonaws.com:5432/docuelevate" in url
|
||||
|
||||
def test_postgresql_custom_port(self):
|
||||
"""Test building a PostgreSQL URL with custom port."""
|
||||
url = build_connection_string(
|
||||
backend="postgresql",
|
||||
host="localhost",
|
||||
port=5433,
|
||||
database="testdb",
|
||||
username="user",
|
||||
password="pass",
|
||||
)
|
||||
assert ":5433/" in url
|
||||
|
||||
def test_mysql_basic(self):
|
||||
"""Test building a MySQL URL."""
|
||||
url = build_connection_string(
|
||||
backend="mysql",
|
||||
host="localhost",
|
||||
database="docuelevate",
|
||||
username="root",
|
||||
password="password",
|
||||
)
|
||||
assert url.startswith("mysql+pymysql://")
|
||||
assert "charset=utf8mb4" in url
|
||||
|
||||
def test_mysql_no_duplicate_charset(self):
|
||||
"""Test that charset is not duplicated when passed in extra_options."""
|
||||
url = build_connection_string(
|
||||
backend="mysql",
|
||||
host="localhost",
|
||||
database="docuelevate",
|
||||
username="root",
|
||||
password="pass",
|
||||
extra_options="charset=utf8mb4",
|
||||
)
|
||||
assert url.count("charset=utf8mb4") == 1
|
||||
|
||||
def test_mysql_extra_options(self):
|
||||
"""Test MySQL URL with extra options appended."""
|
||||
url = build_connection_string(
|
||||
backend="mysql",
|
||||
host="localhost",
|
||||
database="docuelevate",
|
||||
username="root",
|
||||
password="pass",
|
||||
extra_options="connect_timeout=10",
|
||||
)
|
||||
assert "connect_timeout=10" in url
|
||||
assert "charset=utf8mb4" in url
|
||||
|
||||
def test_unsupported_backend_raises(self):
|
||||
"""Test that unsupported backend raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Unsupported backend"):
|
||||
build_connection_string(backend="oracle")
|
||||
|
||||
def test_missing_host_raises(self):
|
||||
"""Test that missing host for non-SQLite raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Host is required"):
|
||||
build_connection_string(backend="postgresql", database="db", username="u")
|
||||
|
||||
def test_missing_database_raises(self):
|
||||
"""Test that missing database name raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Database name is required"):
|
||||
build_connection_string(backend="postgresql", host="localhost", username="u")
|
||||
|
||||
def test_missing_username_raises(self):
|
||||
"""Test that missing username raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Username is required"):
|
||||
build_connection_string(backend="postgresql", host="localhost", database="db")
|
||||
|
||||
def test_no_password(self):
|
||||
"""Test building URL without password."""
|
||||
url = build_connection_string(
|
||||
backend="postgresql",
|
||||
host="localhost",
|
||||
database="db",
|
||||
username="user",
|
||||
)
|
||||
assert "user@localhost" in url
|
||||
assert ":@" not in url
|
||||
|
||||
def test_postgresql_with_extra_options(self):
|
||||
"""Test PostgreSQL URL with extra query options."""
|
||||
url = build_connection_string(
|
||||
backend="postgresql",
|
||||
host="localhost",
|
||||
database="db",
|
||||
username="user",
|
||||
extra_options="application_name=docuelevate",
|
||||
)
|
||||
assert "application_name=docuelevate" in url
|
||||
|
||||
def test_postgresql_ssl_and_extra_options(self):
|
||||
"""Test PostgreSQL URL with both SSL and extra options combined."""
|
||||
url = build_connection_string(
|
||||
backend="postgresql",
|
||||
host="localhost",
|
||||
database="db",
|
||||
username="user",
|
||||
ssl_mode="require",
|
||||
extra_options="application_name=docuelevate",
|
||||
)
|
||||
assert "sslmode=require" in url
|
||||
assert "application_name=docuelevate" in url
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestParseConnectionString:
|
||||
"""Tests for parse_connection_string function."""
|
||||
|
||||
def test_parse_sqlite(self):
|
||||
"""Test parsing a SQLite URL."""
|
||||
result = parse_connection_string("sqlite:///./app/database.db")
|
||||
assert result["valid"] is True
|
||||
assert result["backend"] == "sqlite"
|
||||
assert result["is_sqlite"] is True
|
||||
|
||||
def test_parse_postgresql(self):
|
||||
"""Test parsing a PostgreSQL URL."""
|
||||
result = parse_connection_string("postgresql://user:pass@host:5432/mydb")
|
||||
assert result["valid"] is True
|
||||
assert result["backend"] == "postgresql"
|
||||
assert result["host"] == "host"
|
||||
assert result["port"] == 5432
|
||||
assert result["database"] == "mydb"
|
||||
assert result["username"] == "user"
|
||||
assert result["is_sqlite"] is False
|
||||
|
||||
def test_parse_mysql(self):
|
||||
"""Test parsing a MySQL URL."""
|
||||
result = parse_connection_string("mysql+pymysql://root:pass@localhost:3306/db")
|
||||
assert result["valid"] is True
|
||||
assert result["backend"] == "mysql"
|
||||
|
||||
def test_parse_invalid_url(self):
|
||||
"""Test parsing an invalid URL returns error."""
|
||||
result = parse_connection_string("not-a-valid-url://")
|
||||
# Should still return a dict (make_url may or may not raise)
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_parse_postgresql_no_password(self):
|
||||
"""Test parsing a PostgreSQL URL without password."""
|
||||
result = parse_connection_string("postgresql://user@host:5432/mydb")
|
||||
assert result["valid"] is True
|
||||
assert result["password"] == ""
|
||||
|
||||
def test_parse_sqlite_memory(self):
|
||||
"""Test parsing a SQLite in-memory URL."""
|
||||
result = parse_connection_string("sqlite:///:memory:")
|
||||
assert result["valid"] is True
|
||||
assert result["is_sqlite"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateUrlFormat:
|
||||
"""Tests for validate_url_format function."""
|
||||
|
||||
def test_valid_sqlite(self):
|
||||
"""Test valid SQLite URL."""
|
||||
result = validate_url_format("sqlite:///./db.sqlite")
|
||||
assert result["valid"] is True
|
||||
assert result["backend"] == "sqlite"
|
||||
|
||||
def test_valid_postgresql(self):
|
||||
"""Test valid PostgreSQL URL."""
|
||||
result = validate_url_format("postgresql://u:p@host/db")
|
||||
assert result["valid"] is True
|
||||
|
||||
def test_valid_mysql(self):
|
||||
"""Test valid MySQL URL."""
|
||||
result = validate_url_format("mysql+pymysql://u:p@host/db")
|
||||
assert result["valid"] is True
|
||||
|
||||
def test_unsupported_backend(self):
|
||||
"""Test that unsupported backends are flagged."""
|
||||
result = validate_url_format("mssql://u:p@host/db")
|
||||
assert result["valid"] is False
|
||||
assert "Unsupported" in result.get("error", "")
|
||||
|
||||
def test_invalid_format(self):
|
||||
"""Test that garbage input is invalid."""
|
||||
result = validate_url_format("")
|
||||
assert result["valid"] is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTestConnection:
|
||||
"""Tests for test_connection function."""
|
||||
|
||||
def test_sqlite_memory_succeeds(self):
|
||||
"""Test connecting to an in-memory SQLite database."""
|
||||
result = db_test_connection("sqlite:///:memory:")
|
||||
assert result["success"] is True
|
||||
assert "SQLite" in result.get("server_version", "")
|
||||
|
||||
def test_unreachable_host_fails(self):
|
||||
"""Test that an unreachable host returns failure."""
|
||||
result = db_test_connection("postgresql://u:p@192.0.2.1:5432/db", timeout=2)
|
||||
assert result["success"] is False
|
||||
assert result["message"] # Should contain an error message
|
||||
|
||||
def test_returns_backend_field(self):
|
||||
"""Test that the backend field is populated on success."""
|
||||
result = db_test_connection("sqlite:///:memory:")
|
||||
assert result["backend"] == "sqlite"
|
||||
|
||||
def test_failure_returns_empty_backend(self):
|
||||
"""Test that failure returns empty backend."""
|
||||
result = db_test_connection("postgresql://u:p@192.0.2.1:5432/db", timeout=1)
|
||||
assert result["backend"] == ""
|
||||
assert result["server_version"] == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetServerVersion:
|
||||
"""Tests for _get_server_version internal function."""
|
||||
|
||||
def test_postgresql_version(self):
|
||||
"""Test PostgreSQL version retrieval."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value.fetchone.return_value = ("PostgreSQL 16.2 on x86_64",)
|
||||
result = _get_server_version(mock_conn, "postgresql")
|
||||
assert result == "PostgreSQL 16.2 on x86_64"
|
||||
|
||||
def test_mysql_version(self):
|
||||
"""Test MySQL version retrieval."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value.fetchone.return_value = ("8.0.36",)
|
||||
result = _get_server_version(mock_conn, "mysql")
|
||||
assert result == "8.0.36"
|
||||
|
||||
def test_sqlite_version(self):
|
||||
"""Test SQLite version retrieval."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value.fetchone.return_value = ("3.45.1",)
|
||||
result = _get_server_version(mock_conn, "sqlite")
|
||||
assert result == "SQLite 3.45.1"
|
||||
|
||||
def test_postgresql_empty_row(self):
|
||||
"""Test PostgreSQL version with empty row returns empty string."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value.fetchone.return_value = None
|
||||
result = _get_server_version(mock_conn, "postgresql")
|
||||
assert result == ""
|
||||
|
||||
def test_mysql_empty_row(self):
|
||||
"""Test MySQL version with empty row returns empty string."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value.fetchone.return_value = None
|
||||
result = _get_server_version(mock_conn, "mysql")
|
||||
assert result == ""
|
||||
|
||||
def test_sqlite_empty_row(self):
|
||||
"""Test SQLite version with empty row returns empty string."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value.fetchone.return_value = None
|
||||
result = _get_server_version(mock_conn, "sqlite")
|
||||
assert result == ""
|
||||
|
||||
def test_unknown_backend_returns_empty(self):
|
||||
"""Test that an unknown backend returns empty string."""
|
||||
mock_conn = MagicMock()
|
||||
result = _get_server_version(mock_conn, "oracle")
|
||||
assert result == ""
|
||||
|
||||
def test_exception_returns_empty(self):
|
||||
"""Test that an exception returns empty string."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.side_effect = Exception("Connection lost")
|
||||
result = _get_server_version(mock_conn, "postgresql")
|
||||
assert result == ""
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Tests for app/api/database.py and app/views/db_wizard.py modules."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDatabaseApiEndpoints:
|
||||
"""Tests for the database API endpoints."""
|
||||
|
||||
def test_list_backends(self, client):
|
||||
"""Test GET /api/database/backends returns supported backends."""
|
||||
response = client.get("/api/database/backends")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 3
|
||||
ids = [b["id"] for b in data]
|
||||
assert "sqlite" in ids
|
||||
assert "postgresql" in ids
|
||||
|
||||
def test_build_url_requires_admin(self, client):
|
||||
"""Test POST /api/database/build-url requires admin."""
|
||||
response = client.post(
|
||||
"/api/database/build-url",
|
||||
json={"backend": "sqlite"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_build_url_sqlite(self, client):
|
||||
"""Test building a SQLite URL as admin."""
|
||||
# Simulate admin session
|
||||
with client.session_transaction() if hasattr(client, "session_transaction") else _NoOpContextManager():
|
||||
pass
|
||||
# Use the session cookie approach
|
||||
client.cookies.set("session", "test")
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
response = client.post(
|
||||
"/api/database/build-url",
|
||||
json={"backend": "sqlite", "sqlite_path": "/data/test.db"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "sqlite:////data/test.db" in response.json().get("url", "")
|
||||
|
||||
def test_build_url_missing_host(self, client):
|
||||
"""Test building URL with missing host returns 400."""
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
response = client.post(
|
||||
"/api/database/build-url",
|
||||
json={"backend": "postgresql", "database": "db", "username": "u"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_test_connection_sqlite(self, client):
|
||||
"""Test connection to in-memory SQLite."""
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
response = client.post(
|
||||
"/api/database/test-connection",
|
||||
json={"url": "sqlite:///:memory:"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
|
||||
def test_validate_url_valid(self, client):
|
||||
"""Test validate-url with valid SQLite URL."""
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
response = client.post(
|
||||
"/api/database/validate-url",
|
||||
json={"url": "sqlite:///test.db"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["valid"] is True
|
||||
|
||||
def test_validate_url_invalid(self, client):
|
||||
"""Test validate-url with unsupported backend."""
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
response = client.post(
|
||||
"/api/database/validate-url",
|
||||
json={"url": "mssql://u:p@h/d"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["valid"] is False
|
||||
|
||||
def test_parse_url(self, client):
|
||||
"""Test parse-url endpoint."""
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
response = client.post(
|
||||
"/api/database/parse-url",
|
||||
json={"url": "postgresql://user:pass@host:5432/db"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["backend"] == "postgresql"
|
||||
assert data["host"] == "host"
|
||||
|
||||
def test_preview_migration(self, client):
|
||||
"""Test preview-migration endpoint."""
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
response = client.post(
|
||||
"/api/database/preview-migration",
|
||||
json={"url": "sqlite:///:memory:"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "tables" in data
|
||||
|
||||
def test_migrate_invalid_source(self, client):
|
||||
"""Test migrate endpoint with invalid source URL."""
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
response = client.post(
|
||||
"/api/database/migrate",
|
||||
json={"source_url": "mssql://bad", "target_url": "sqlite:///:memory:"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_migrate_invalid_target(self, client):
|
||||
"""Test migrate endpoint with invalid target URL."""
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
response = client.post(
|
||||
"/api/database/migrate",
|
||||
json={"source_url": "sqlite:///:memory:", "target_url": "mssql://bad"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_migrate_success(self, client):
|
||||
"""Test migrate endpoint with successful migration."""
|
||||
mock_result = {"success": True, "tables_copied": 5, "rows_copied": 100, "errors": []}
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
with patch("app.api.database.migrate_data", return_value=mock_result):
|
||||
response = client.post(
|
||||
"/api/database/migrate",
|
||||
json={
|
||||
"source_url": "sqlite:///:memory:",
|
||||
"target_url": "sqlite:///:memory:",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["rows_copied"] == 100
|
||||
|
||||
def test_migrate_failure_returns_500(self, client):
|
||||
"""Test migrate endpoint returns 500 on migration failure."""
|
||||
mock_result = {
|
||||
"success": False,
|
||||
"tables_copied": 2,
|
||||
"rows_copied": 50,
|
||||
"errors": ["Table X failed", "Stamp failed"],
|
||||
}
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
with patch("app.api.database.migrate_data", return_value=mock_result):
|
||||
response = client.post(
|
||||
"/api/database/migrate",
|
||||
json={
|
||||
"source_url": "sqlite:///:memory:",
|
||||
"target_url": "sqlite:///:memory:",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 500
|
||||
assert "Table X failed" in response.json()["detail"]
|
||||
|
||||
def test_test_connection_requires_admin(self, client):
|
||||
"""Test POST /api/database/test-connection requires admin."""
|
||||
response = client.post(
|
||||
"/api/database/test-connection",
|
||||
json={"url": "sqlite:///:memory:"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_parse_url_requires_admin(self, client):
|
||||
"""Test POST /api/database/parse-url requires admin."""
|
||||
response = client.post(
|
||||
"/api/database/parse-url",
|
||||
json={"url": "sqlite:///:memory:"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_validate_url_requires_admin(self, client):
|
||||
"""Test POST /api/database/validate-url requires admin."""
|
||||
response = client.post(
|
||||
"/api/database/validate-url",
|
||||
json={"url": "sqlite:///:memory:"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_preview_migration_requires_admin(self, client):
|
||||
"""Test POST /api/database/preview-migration requires admin."""
|
||||
response = client.post(
|
||||
"/api/database/preview-migration",
|
||||
json={"url": "sqlite:///:memory:"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_migrate_requires_admin(self, client):
|
||||
"""Test POST /api/database/migrate requires admin."""
|
||||
response = client.post(
|
||||
"/api/database/migrate",
|
||||
json={"source_url": "sqlite:///:memory:", "target_url": "sqlite:///:memory:"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_build_url_postgresql(self, client):
|
||||
"""Test building a PostgreSQL URL."""
|
||||
with patch("app.api.database._require_admin", return_value={"is_admin": True}):
|
||||
response = client.post(
|
||||
"/api/database/build-url",
|
||||
json={
|
||||
"backend": "postgresql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"database": "mydb",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
url = response.json()["url"]
|
||||
assert "postgresql://admin:secret@localhost:5432/mydb" in url
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDatabaseWizardView:
|
||||
"""Tests for the database wizard view."""
|
||||
|
||||
def test_database_wizard_page_loads(self, client):
|
||||
"""Test GET /database-wizard returns 200."""
|
||||
response = client.get("/database-wizard")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_database_wizard_contains_title(self, client):
|
||||
"""Test that the wizard page contains expected content."""
|
||||
response = client.get("/database-wizard")
|
||||
assert response.status_code == 200
|
||||
assert "Database Configuration Wizard" in response.text
|
||||
|
||||
def test_database_wizard_contains_tabs(self, client):
|
||||
"""Test that the wizard page contains configure and migrate tabs."""
|
||||
response = client.get("/database-wizard")
|
||||
assert "Configure Database" in response.text
|
||||
assert "Migrate Data" in response.text
|
||||
|
||||
def test_database_wizard_has_skip_link(self, client):
|
||||
"""Test that the wizard page includes a skip-to-content link."""
|
||||
response = client.get("/database-wizard")
|
||||
assert "Skip to main content" in response.text
|
||||
|
||||
def test_database_wizard_has_main_landmark(self, client):
|
||||
"""Test that the wizard page has a main landmark."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'id="main-content"' in response.text
|
||||
|
||||
def test_database_wizard_has_tablist_role(self, client):
|
||||
"""Test that the tab navigation has proper ARIA tablist role."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'role="tablist"' in response.text
|
||||
assert 'role="tab"' in response.text
|
||||
assert 'role="tabpanel"' in response.text
|
||||
|
||||
def test_database_wizard_has_aria_labels_on_backend_buttons(self, client):
|
||||
"""Test that backend selection buttons have aria-label attributes."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'aria-label="Select SQLite"' in response.text
|
||||
assert 'aria-label="Select PostgreSQL"' in response.text
|
||||
assert 'aria-label="Select MySQL / MariaDB"' in response.text
|
||||
|
||||
def test_database_wizard_has_form_labels(self, client):
|
||||
"""Test that form inputs have associated labels."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'for="sqlite_path"' in response.text
|
||||
assert 'for="db_host"' in response.text
|
||||
assert 'for="db_port"' in response.text
|
||||
assert 'for="db_name"' in response.text
|
||||
assert 'for="db_user"' in response.text
|
||||
assert 'for="db_pass"' in response.text
|
||||
assert 'for="ssl_mode"' in response.text
|
||||
|
||||
def test_database_wizard_has_aria_describedby(self, client):
|
||||
"""Test that inputs have aria-describedby pointing to help text."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'aria-describedby="sqlite_path_help"' in response.text
|
||||
assert 'id="sqlite_path_help"' in response.text
|
||||
assert 'aria-describedby="ssl_mode_help"' in response.text
|
||||
assert 'id="ssl_mode_help"' in response.text
|
||||
assert 'aria-describedby="mig_source_help"' in response.text
|
||||
assert 'aria-describedby="mig_target_help"' in response.text
|
||||
|
||||
def test_database_wizard_has_status_roles(self, client):
|
||||
"""Test that dynamic feedback areas have role=status or role=alert."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'role="status"' in response.text
|
||||
assert 'role="alert"' in response.text
|
||||
|
||||
def test_database_wizard_has_aria_live(self, client):
|
||||
"""Test that dynamic areas have aria-live for screen reader announcements."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'aria-live="polite"' in response.text
|
||||
|
||||
def test_database_wizard_has_progressbar(self, client):
|
||||
"""Test that the migration progress indicator has role=progressbar."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'role="progressbar"' in response.text
|
||||
|
||||
def test_database_wizard_has_focus_ring_styles(self, client):
|
||||
"""Test that interactive elements have focus ring styling."""
|
||||
response = client.get("/database-wizard")
|
||||
assert "focus:ring-2" in response.text
|
||||
assert "focus:outline-none" in response.text
|
||||
|
||||
def test_database_wizard_has_table_scope_headers(self, client):
|
||||
"""Test that migration preview table has proper scope attributes."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'scope="col"' in response.text
|
||||
|
||||
def test_database_wizard_has_dark_mode_styles(self, client):
|
||||
"""Test that the wizard includes dark mode CSS overrides."""
|
||||
response = client.get("/database-wizard")
|
||||
assert "html.dark" in response.text
|
||||
|
||||
def test_database_wizard_copy_button_has_aria_label(self, client):
|
||||
"""Test that the copy-to-clipboard button has an aria-label."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'aria-label="Copy to clipboard"' in response.text
|
||||
|
||||
def test_database_wizard_decorative_icons_hidden(self, client):
|
||||
"""Test that decorative icons have aria-hidden=true."""
|
||||
response = client.get("/database-wizard")
|
||||
assert 'aria-hidden="true"' in response.text
|
||||
|
||||
def test_database_wizard_reduced_motion(self, client):
|
||||
"""Test that wizard respects prefers-reduced-motion media query."""
|
||||
response = client.get("/database-wizard")
|
||||
assert "prefers-reduced-motion" in response.text
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSettingsPageWizardLink:
|
||||
"""Tests for the database wizard link on the settings page."""
|
||||
|
||||
def test_settings_template_has_db_wizard_link(self):
|
||||
"""Test that the settings template contains a link to the database wizard."""
|
||||
from pathlib import Path
|
||||
|
||||
template_path = Path(__file__).resolve().parent.parent / "frontend" / "templates" / "settings.html"
|
||||
content = template_path.read_text()
|
||||
assert "/database-wizard" in content
|
||||
assert "DB Wizard" in content
|
||||
|
||||
def test_settings_template_has_help_link_rendering(self):
|
||||
"""Test that the settings template renders help_link metadata."""
|
||||
from pathlib import Path
|
||||
|
||||
template_path = Path(__file__).resolve().parent.parent / "frontend" / "templates" / "settings.html"
|
||||
content = template_path.read_text()
|
||||
assert "setting.metadata.get('help_link')" in content
|
||||
assert "help_link_label" in content
|
||||
|
||||
def test_database_url_metadata_has_help_link(self):
|
||||
"""Test that database_url SETTING_METADATA includes help_link to wizard."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
meta = SETTING_METADATA["database_url"]
|
||||
assert "help_link" in meta
|
||||
assert meta["help_link"] == "/database-wizard"
|
||||
assert "help_link_label" in meta
|
||||
|
||||
|
||||
# Context manager helper for tests that don't need session_transaction
|
||||
class _NoOpContextManager:
|
||||
"""Dummy context manager for tests that don't need session_transaction."""
|
||||
|
||||
def __enter__(self):
|
||||
return None
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
Reference in New Issue
Block a user