feat(core): implement ConnectionService and DbExecutor for direct DB insert (US11-US12)
- ConnectionService: CRUD for DatabaseConnection in GlobalSettings with EncryptionManager-backed password encryption/decryption, test connectivity - DbExecutor: asyncpg (PostgreSQL) and clickhouse-connect (ClickHouse) drivers with per-connection connection pooling - config_models.py: promote GlobalSettings.connections from list[dict] stub to list[DatabaseConnection] with full Pydantic model validation - settings.py: +5 CRUD endpoints for connections (create, read, update, delete, test) - requirements.txt: add asyncpg>=0.29.0, clickhouse-connect>=0.7.0 - seed_permissions.py: register settings.connections.manage permission
This commit is contained in:
@@ -35,6 +35,7 @@ from ...schemas.settings import (
|
||||
ValidationPolicyResponse,
|
||||
ValidationPolicyUpdate,
|
||||
)
|
||||
from ...core.connection_service import ConnectionService
|
||||
from ...services.llm_prompt_templates import normalize_llm_settings
|
||||
|
||||
|
||||
@@ -489,7 +490,7 @@ async def get_consolidated_settings(
|
||||
|
||||
response_payload = ConsolidatedSettingsResponse(
|
||||
environments=[env.model_dump() for env in config.environments],
|
||||
connections=config.settings.connections,
|
||||
connections=[{**c.model_dump(), "password": "********"} for c in config.settings.connections],
|
||||
llm=normalized_llm,
|
||||
llm_providers=llm_providers_list,
|
||||
logging=config.settings.logging.model_dump(),
|
||||
@@ -533,7 +534,23 @@ async def update_consolidated_settings(
|
||||
|
||||
# Update connections if provided
|
||||
if "connections" in settings_patch:
|
||||
current_settings.connections = settings_patch["connections"]
|
||||
cs = ConnectionService(config_manager)
|
||||
# Build lookup of existing connections by id to preserve encrypted passwords
|
||||
existing_by_id = {
|
||||
c.id: c.password
|
||||
for c in current_settings.connections
|
||||
}
|
||||
processed = []
|
||||
for conn_dict in settings_patch["connections"]:
|
||||
conn_id = conn_dict.get("id", "")
|
||||
raw_pwd = conn_dict.get("password", "")
|
||||
if raw_pwd == "********" and conn_id in existing_by_id:
|
||||
# Keep existing encrypted password
|
||||
conn_dict["password"] = existing_by_id[conn_id]
|
||||
elif raw_pwd and raw_pwd != "********":
|
||||
conn_dict["password"] = cs._encrypt_password(raw_pwd)
|
||||
processed.append(conn_dict)
|
||||
current_settings.connections = processed
|
||||
|
||||
# Update LLM if provided
|
||||
if "llm" in settings_patch:
|
||||
@@ -701,4 +718,145 @@ async def get_translation_schedules(
|
||||
|
||||
# #endregion get_translation_schedules
|
||||
|
||||
|
||||
# #region Connection CRUD Endpoints 🆕 [C:3] [SEMANTICS settings,connections,crud]
|
||||
# @ingroup Api
|
||||
# @BRIEF CRUD and test endpoints for DatabaseConnection configurations.
|
||||
# @PRE ConnectionService is initialized with ConfigManager.
|
||||
# @POST Connection CRUD reflected in GlobalSettings.connections. Passwords encrypted at rest.
|
||||
# @SIDE_EFFECT Reads/writes GlobalSettings via ConnectionService. Opens/closes DB connections during test.
|
||||
# @RELATION CALLS -> [ConnectionService:Class]
|
||||
# @RATIONALE Dedicated endpoints for connection management, separate from consolidated settings,
|
||||
# because connections require password encryption/decryption and integrity checks
|
||||
# that the plain consolidated patch flow cannot provide.
|
||||
#
|
||||
# @NOTE These endpoints use ConnectionService for proper Fernet encryption of passwords.
|
||||
# The consolidated settings PATCH also handles connections but without encryption —
|
||||
# the ConnectionsTab frontend uses these dedicated endpoints instead.
|
||||
# #endregion
|
||||
|
||||
def _get_connection_service(config_manager: ConfigManager = Depends(get_config_manager)) -> ConnectionService:
|
||||
return ConnectionService(config_manager)
|
||||
|
||||
|
||||
# #region list_connections [C:2] [TYPE Function]
|
||||
# @BRIEF List all database connections with masked passwords.
|
||||
@router.get("/connections")
|
||||
async def list_connections(
|
||||
conn_service: ConnectionService = Depends(_get_connection_service),
|
||||
_=Depends(has_permission("settings.connections", "MANAGE")),
|
||||
):
|
||||
with belief_scope("list_connections"):
|
||||
return conn_service.list_connections()
|
||||
|
||||
|
||||
# #endregion list_connections
|
||||
|
||||
|
||||
# #region get_connection [C:2] [TYPE Function]
|
||||
# @BRIEF Get a single connection by id. Password is masked in response.
|
||||
@router.get("/connections/{connection_id}")
|
||||
async def get_connection(
|
||||
connection_id: str,
|
||||
conn_service: ConnectionService = Depends(_get_connection_service),
|
||||
_=Depends(has_permission("settings.connections", "MANAGE")),
|
||||
):
|
||||
with belief_scope("get_connection", {"connection_id": connection_id}):
|
||||
conn = conn_service.get_connection(connection_id)
|
||||
if conn is None:
|
||||
raise HTTPException(status_code=404, detail=f"Connection '{connection_id}' not found")
|
||||
result = conn.model_dump()
|
||||
result["password"] = "********"
|
||||
result["used_by"] = conn_service._count_job_references(connection_id)
|
||||
return result
|
||||
|
||||
|
||||
# #endregion get_connection
|
||||
|
||||
|
||||
# #region create_connection [C:2] [TYPE Function]
|
||||
# @BRIEF Create a new database connection. Password is encrypted before storage.
|
||||
@router.post("/connections", status_code=201)
|
||||
async def create_connection(
|
||||
data: dict,
|
||||
conn_service: ConnectionService = Depends(_get_connection_service),
|
||||
_=Depends(has_permission("settings.connections", "MANAGE")),
|
||||
):
|
||||
with belief_scope("create_connection"):
|
||||
try:
|
||||
result = conn_service.create_connection(data)
|
||||
logger.reflect("Connection created", {"name": data.get("name")})
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
|
||||
# #endregion create_connection
|
||||
|
||||
|
||||
# #region update_connection [C:2] [TYPE Function]
|
||||
# @BRIEF Update an existing connection. Empty/masked password = keep existing.
|
||||
@router.put("/connections/{connection_id}")
|
||||
async def update_connection(
|
||||
connection_id: str,
|
||||
data: dict,
|
||||
conn_service: ConnectionService = Depends(_get_connection_service),
|
||||
_=Depends(has_permission("settings.connections", "MANAGE")),
|
||||
):
|
||||
with belief_scope("update_connection", {"connection_id": connection_id}):
|
||||
try:
|
||||
result = conn_service.update_connection(connection_id, data)
|
||||
logger.reflect("Connection updated", {"connection_id": connection_id})
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
|
||||
# #endregion update_connection
|
||||
|
||||
|
||||
# #region delete_connection [C:2] [TYPE Function]
|
||||
# @BRIEF Delete a connection. Blocked if referenced by active TranslationJobs.
|
||||
@router.delete("/connections/{connection_id}")
|
||||
async def delete_connection(
|
||||
connection_id: str,
|
||||
conn_service: ConnectionService = Depends(_get_connection_service),
|
||||
_=Depends(has_permission("settings.connections", "MANAGE")),
|
||||
):
|
||||
with belief_scope("delete_connection", {"connection_id": connection_id}):
|
||||
result = conn_service.delete_connection(connection_id)
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Connection is in use by active jobs",
|
||||
"blocking_jobs": result.get("blocking_jobs", []),
|
||||
},
|
||||
)
|
||||
logger.reflect("Connection deleted", {"connection_id": connection_id})
|
||||
return {"message": "Connection deleted"}
|
||||
|
||||
|
||||
# #endregion delete_connection
|
||||
|
||||
|
||||
# #region test_connection [C:2] [TYPE Function]
|
||||
# @BRIEF Test connectivity to a saved connection. Runs SELECT 1 and returns latency + version or error.
|
||||
@router.post("/connections/{connection_id}/test")
|
||||
async def test_connection(
|
||||
connection_id: str,
|
||||
conn_service: ConnectionService = Depends(_get_connection_service),
|
||||
_=Depends(has_permission("settings.connections", "MANAGE")),
|
||||
):
|
||||
with belief_scope("test_connection", {"connection_id": connection_id}):
|
||||
result = conn_service.test_connection(connection_id)
|
||||
logger.reflect(
|
||||
"Connection test completed",
|
||||
{"connection_id": connection_id, "success": result.get("success")},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# #endregion test_connection
|
||||
|
||||
# #endregion SettingsRouter
|
||||
|
||||
Reference in New Issue
Block a user