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:
2026-06-11 19:12:15 +03:00
parent 46d89c9006
commit 1f9040d53e
6 changed files with 1034 additions and 3 deletions

View File

@@ -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