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

@@ -15,6 +15,9 @@
# @BRIEF Contract for database/environment connection configuration models.
# #endregion ConnectionContracts
import uuid
from datetime import datetime, timezone
from pydantic import BaseModel, Field, field_validator
from ..models.storage import StorageConfig
@@ -173,6 +176,53 @@ class FeaturesConfig(BaseModel):
# #endregion FeaturesConfig
# #region DatabaseConnection [C:1] [TYPE DataClass] [SEMANTICS settings,connections,database]
# @ingroup Core
# @BRIEF A reusable database connection configuration stored in GlobalSettings.connections.
# @RATIONALE Typed model prevents config corruption from ad-hoc dicts. Replaces the existing
# `connections: list[dict]` stub. connection_id references the UUID in this list — not a DB FK.
# @REJECTED Keeping `list[dict]` was rejected — would require per-field validation in every consumer.
# A separate DB table was rejected — connections are configuration, not domain data.
class DatabaseConnection(BaseModel):
id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
name: str
host: str
port: int = 5432
database: str
username: str
password: str # Fernet-encrypted at rest; masked as "********" in API responses
dialect: str = "postgresql"
extra_params: dict = Field(default_factory=dict)
pool_size: int = Field(default=5, ge=1, le=100)
created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
updated_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
@field_validator("dialect")
@classmethod
def validate_dialect(cls, v: str) -> str:
allowed = {"postgresql", "clickhouse", "mysql"}
if v.lower() not in allowed:
raise ValueError(f"Unsupported dialect: {v}. Allowed: {', '.join(sorted(allowed))}")
return v.lower()
@field_validator("port")
@classmethod
def validate_port(cls, v: int) -> int:
if v < 1 or v > 65535:
raise ValueError(f"Port must be between 1 and 65535, got {v}")
return v
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("Connection name must not be empty")
return v.strip()
# #endregion DatabaseConnection
# #region GlobalSettings [TYPE DataClass]
# @ingroup Core
# @BRIEF Represents global application settings.
@@ -182,7 +232,7 @@ class GlobalSettings(BaseModel):
default_environment_id: str | None = None
logging: LoggingConfig = Field(default_factory=LoggingConfig)
features: FeaturesConfig = Field(default_factory=FeaturesConfig)
connections: list[dict] = []
connections: list[DatabaseConnection] = []
llm: dict = Field(
default_factory=lambda: {
"providers": [],