- 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
863 lines
31 KiB
Python
Executable File
863 lines
31 KiB
Python
Executable File
# #region SettingsRouter [C:5] [TYPE Module] [SEMANTICS fastapi, api, superset, logging-config-response]
|
|
# @defgroup Api Module group.
|
|
#
|
|
# @BRIEF Provides API endpoints for managing application settings and Superset environments.
|
|
# @LAYER API
|
|
# @RELATION DEPENDS_ON -> [ConfigManager]
|
|
# @RELATION DEPENDS_ON -> [get_config_manager]
|
|
# @RELATION DEPENDS_ON -> [has_permission]
|
|
#
|
|
# @PRE ConfigManager is initialized and accessible.
|
|
# @POST Settings are read or written via ConfigManager.
|
|
# @SIDE_EFFECT Persists config changes to disk via ConfigManager.
|
|
# @DATA_CONTRACT Input -> ConfigUpdateRequest, Output -> AppConfig, LoggingConfigResponse
|
|
# @INVARIANT All settings changes must be persisted via ConfigManager.
|
|
# @PUBLIC_API router
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ...core.config_manager import ConfigManager
|
|
from ...core.config_models import AppConfig, Environment, GlobalSettings, LoggingConfig
|
|
from ...core.database import get_db
|
|
from ...core.logger import belief_scope, logger
|
|
from ...core.async_superset_client import AsyncSupersetClient
|
|
from ...dependencies import get_config_manager, has_permission
|
|
from ...models.config import AppConfigRecord
|
|
from ...models.llm import ValidationPolicy
|
|
from ...models.storage import StorageConfig
|
|
from ...models.translate import TranslationJob, TranslationSchedule
|
|
from ...schemas.settings import (
|
|
TranslationScheduleItem,
|
|
ValidationPolicyCreate,
|
|
ValidationPolicyResponse,
|
|
ValidationPolicyUpdate,
|
|
)
|
|
from ...core.connection_service import ConnectionService
|
|
from ...services.llm_prompt_templates import normalize_llm_settings
|
|
|
|
|
|
# #region LoggingConfigResponse [C:1] [TYPE Class] [SEMANTICS logging, config, response]
|
|
# @BRIEF Response model for logging configuration with current task log level.
|
|
class LoggingConfigResponse(BaseModel):
|
|
level: str
|
|
task_log_level: str
|
|
enable_belief_state: bool
|
|
|
|
|
|
# #endregion LoggingConfigResponse
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# #region _normalize_superset_env_url [C:1] [TYPE Function]
|
|
# @BRIEF Canonicalize Superset environment URL to base host/path without trailing /api/v1.
|
|
# Auto-prepends https:// if no scheme is present.
|
|
# @PRE raw_url can be empty.
|
|
# @POST Returns normalized base URL with scheme.
|
|
def _normalize_superset_env_url(raw_url: str) -> str:
|
|
normalized = str(raw_url or "").strip().rstrip("/")
|
|
if normalized.lower().endswith("/api/v1"):
|
|
normalized = normalized[: -len("/api/v1")]
|
|
normalized = normalized.rstrip("/")
|
|
# Auto-prepend https:// if no scheme is present
|
|
if normalized and not normalized.startswith("http://") and not normalized.startswith("https://"):
|
|
normalized = f"https://{normalized}"
|
|
return normalized
|
|
|
|
|
|
# #endregion _normalize_superset_env_url
|
|
|
|
|
|
# #region _validate_superset_connection_fast [C:2] [TYPE Function]
|
|
# @BRIEF Run lightweight Superset connectivity validation without full pagination scan.
|
|
# @PRE env contains valid URL and credentials.
|
|
# @POST Raises on auth/API failures; returns None on success.
|
|
async def _validate_superset_connection_fast(env: Environment) -> None:
|
|
client = AsyncSupersetClient(env)
|
|
# 1) Explicit auth check
|
|
await client.authenticate()
|
|
# 2) Single lightweight API call to ensure read access
|
|
_, _ = await client.get_dashboards_page(
|
|
query={
|
|
"page": 0,
|
|
"page_size": 1,
|
|
"columns": ["id"],
|
|
}
|
|
)
|
|
|
|
|
|
# #endregion _validate_superset_connection_fast
|
|
|
|
|
|
# #region get_settings [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Retrieves all application settings.
|
|
# @PRE Config manager is available.
|
|
# @POST Returns masked AppConfig.
|
|
@router.get("", response_model=AppConfig)
|
|
async def get_settings(
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
_=Depends(has_permission("admin:settings", "READ")),
|
|
):
|
|
with belief_scope("get_settings"):
|
|
logger.reason("Fetching all settings", extra={"src": "get_settings"})
|
|
config = config_manager.get_config().copy(deep=True)
|
|
config.settings.llm = normalize_llm_settings(config.settings.llm)
|
|
# Mask passwords
|
|
for env in config.environments:
|
|
if env.password:
|
|
env.password = "********"
|
|
return config
|
|
|
|
|
|
# #endregion get_settings
|
|
|
|
|
|
# #region get_features [C:1] [TYPE Function]
|
|
# @BRIEF Public endpoint returning feature flags for frontend sidebar filtering.
|
|
# @RATIONALE Unauthenticated because sidebar filtering must work for all users, not just admins.
|
|
# @PRE Config manager is available.
|
|
# @POST Returns dict with dataset_review and health_monitor booleans.
|
|
@router.get("/features")
|
|
async def get_features(
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
):
|
|
return config_manager.get_config().settings.features.model_dump()
|
|
|
|
|
|
# #endregion get_features
|
|
|
|
|
|
# #region get_allowed_languages [C:1] [TYPE Function]
|
|
# @BRIEF Public endpoint returning allowed translation languages for language dropdowns.
|
|
# @RATIONALE No auth required — needed by dictionary page, job config, etc. everywhere
|
|
# language selects appear. Non-admin users still need the list.
|
|
# @PRE Config manager is available.
|
|
# @POST Returns list of BCP-47 language codes.
|
|
@router.get("/allowed-languages")
|
|
async def get_allowed_languages(
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
):
|
|
return config_manager.get_config().settings.allowed_languages
|
|
|
|
|
|
# #endregion get_allowed_languages
|
|
|
|
|
|
# #region update_global_settings [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Updates global application settings.
|
|
# @PRE New settings are provided.
|
|
# @POST Global settings are updated.
|
|
@router.patch("/global", response_model=GlobalSettings)
|
|
async def update_global_settings(
|
|
settings: GlobalSettings,
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
_=Depends(has_permission("admin:settings", "WRITE")),
|
|
):
|
|
with belief_scope("update_global_settings"):
|
|
logger.reason("Updating global settings", extra={"src": "update_global_settings"})
|
|
|
|
config_manager.update_global_settings(settings)
|
|
return settings
|
|
|
|
|
|
# #endregion update_global_settings
|
|
|
|
|
|
# #region get_storage_settings [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Retrieves storage-specific settings.
|
|
@router.get("/storage", response_model=StorageConfig)
|
|
async def get_storage_settings(
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
_=Depends(has_permission("admin:settings", "READ")),
|
|
):
|
|
with belief_scope("get_storage_settings"):
|
|
return config_manager.get_config().settings.storage
|
|
|
|
|
|
# #endregion get_storage_settings
|
|
|
|
|
|
# #region update_storage_settings [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Updates storage-specific settings.
|
|
# @POST Storage settings are updated and saved.
|
|
@router.put("/storage", response_model=StorageConfig)
|
|
async def update_storage_settings(
|
|
storage: StorageConfig,
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
_=Depends(has_permission("admin:settings", "WRITE")),
|
|
):
|
|
with belief_scope("update_storage_settings"):
|
|
is_valid, message = config_manager.validate_path(storage.root_path)
|
|
if not is_valid:
|
|
raise HTTPException(status_code=400, detail=message)
|
|
|
|
settings = config_manager.get_config().settings
|
|
settings.storage = storage
|
|
config_manager.update_global_settings(settings)
|
|
return config_manager.get_config().settings.storage
|
|
|
|
|
|
# #endregion update_storage_settings
|
|
|
|
|
|
# #region get_environments [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Lists all configured Superset environments.
|
|
# @PRE Config manager is available.
|
|
# @POST Returns list of environments.
|
|
@router.get("/environments", response_model=list[Environment])
|
|
async def get_environments(
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
_=Depends(has_permission("admin:settings", "READ")),
|
|
):
|
|
with belief_scope("get_environments"):
|
|
logger.reason("Fetching environments", extra={"src": "get_environments"})
|
|
environments = config_manager.get_environments()
|
|
return [
|
|
env.copy(update={"url": _normalize_superset_env_url(env.url)})
|
|
for env in environments
|
|
]
|
|
|
|
|
|
# #endregion get_environments
|
|
|
|
|
|
# #region add_environment [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Adds a new Superset environment.
|
|
# @PRE Environment data is valid and reachable.
|
|
# @POST Environment is added to config.
|
|
@router.post("/environments", response_model=Environment)
|
|
async def add_environment(
|
|
env: Environment,
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
_=Depends(has_permission("admin:settings", "WRITE")),
|
|
):
|
|
with belief_scope("add_environment"):
|
|
logger.reason(f"Adding environment {env.id}", extra={"src": "add_environment"})
|
|
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
|
|
|
|
# Validate connection before adding (fast path)
|
|
try:
|
|
await _validate_superset_connection_fast(env)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"[add_environment][Coherence:Failed] Connection validation failed: {e}"
|
|
)
|
|
raise HTTPException(
|
|
status_code=400, detail=f"Connection validation failed: {e}"
|
|
)
|
|
|
|
config_manager.add_environment(env)
|
|
return env
|
|
|
|
|
|
# #endregion add_environment
|
|
|
|
|
|
# #region update_environment [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Updates an existing Superset environment.
|
|
# @PRE ID and valid environment data are provided.
|
|
# @POST Environment is updated in config.
|
|
@router.put("/environments/{id}", response_model=Environment)
|
|
async def update_environment(
|
|
id: str,
|
|
env: Environment,
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
):
|
|
with belief_scope("update_environment"):
|
|
logger.reason(f"Updating environment {id}", extra={"src": "update_environment"})
|
|
|
|
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
|
|
|
|
# If password is masked, we need the real one for validation
|
|
env_to_validate = env.copy(deep=True)
|
|
if env_to_validate.password == "********":
|
|
old_env = next(
|
|
(e for e in config_manager.get_environments() if e.id == id), None
|
|
)
|
|
if old_env:
|
|
env_to_validate.password = old_env.password
|
|
|
|
# Validate connection before updating (fast path)
|
|
try:
|
|
await _validate_superset_connection_fast(env_to_validate)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"[update_environment][Coherence:Failed] Connection validation failed: {e}"
|
|
)
|
|
raise HTTPException(
|
|
status_code=400, detail=f"Connection validation failed: {e}"
|
|
)
|
|
|
|
if config_manager.update_environment(id, env):
|
|
return env
|
|
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
|
|
|
|
|
|
# #endregion update_environment
|
|
|
|
|
|
# #region delete_environment [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Deletes a Superset environment.
|
|
# @PRE ID is provided.
|
|
# @POST Environment is removed from config.
|
|
@router.delete("/environments/{id}")
|
|
async def delete_environment(
|
|
id: str, config_manager: ConfigManager = Depends(get_config_manager)
|
|
):
|
|
with belief_scope("delete_environment"):
|
|
logger.reason(f"Deleting environment {id}", extra={"src": "delete_environment"})
|
|
config_manager.delete_environment(id)
|
|
return {"message": f"Environment {id} deleted"}
|
|
|
|
|
|
# #endregion delete_environment
|
|
|
|
|
|
# #region test_environment_connection [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Tests the connection to a Superset environment.
|
|
# @PRE ID is provided.
|
|
# @POST Returns success or error status.
|
|
@router.post("/environments/{id}/test")
|
|
async def test_environment_connection(
|
|
id: str, config_manager: ConfigManager = Depends(get_config_manager)
|
|
):
|
|
with belief_scope("test_environment_connection"):
|
|
logger.reason(f"Testing environment {id}", extra={"src": "test_environment_connection"})
|
|
|
|
# Find environment
|
|
env = next((e for e in config_manager.get_environments() if e.id == id), None)
|
|
if not env:
|
|
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
|
|
|
|
try:
|
|
await _validate_superset_connection_fast(env)
|
|
|
|
logger.info(
|
|
f"[test_environment_connection][Coherence:OK] Connection successful for {id}"
|
|
)
|
|
return {"status": "success", "message": "Connection successful"}
|
|
except Exception as e:
|
|
logger.error(
|
|
f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}"
|
|
)
|
|
return {"status": "error", "message": str(e)}
|
|
|
|
|
|
# #endregion test_environment_connection
|
|
|
|
|
|
# #region get_logging_config [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Retrieves current logging configuration.
|
|
# @PRE Config manager is available.
|
|
# @POST Returns logging configuration.
|
|
@router.get("/logging", response_model=LoggingConfigResponse)
|
|
async def get_logging_config(
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
_=Depends(has_permission("admin:settings", "READ")),
|
|
):
|
|
with belief_scope("get_logging_config"):
|
|
logging_config = config_manager.get_config().settings.logging
|
|
return LoggingConfigResponse(
|
|
level=logging_config.level,
|
|
task_log_level=logging_config.task_log_level,
|
|
enable_belief_state=logging_config.enable_belief_state,
|
|
)
|
|
|
|
|
|
# #endregion get_logging_config
|
|
|
|
|
|
# #region update_logging_config [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Updates logging configuration.
|
|
# @PRE New logging config is provided.
|
|
# @POST Logging configuration is updated and saved.
|
|
@router.patch("/logging", response_model=LoggingConfigResponse)
|
|
async def update_logging_config(
|
|
config: LoggingConfig,
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
_=Depends(has_permission("admin:settings", "WRITE")),
|
|
):
|
|
with belief_scope("update_logging_config"):
|
|
logger.reason(
|
|
f"Updating logging config: level={config.level}, task_log_level={config.task_log_level}", extra={"src": "update_logging_config"}
|
|
)
|
|
|
|
# Get current settings and update logging config
|
|
settings = config_manager.get_config().settings
|
|
settings.logging = config
|
|
config_manager.update_global_settings(settings)
|
|
|
|
return LoggingConfigResponse(
|
|
level=config.level,
|
|
task_log_level=config.task_log_level,
|
|
enable_belief_state=config.enable_belief_state,
|
|
)
|
|
|
|
|
|
# #endregion update_logging_config
|
|
|
|
|
|
# #region ConsolidatedSettingsResponse [C:1] [TYPE Class]
|
|
# @BRIEF Response model for consolidated application settings.
|
|
class ConsolidatedSettingsResponse(BaseModel):
|
|
environments: list[dict]
|
|
connections: list[dict]
|
|
llm: dict
|
|
llm_providers: list[dict]
|
|
logging: dict
|
|
storage: dict
|
|
notifications: dict = {}
|
|
features: dict = {}
|
|
app_timezone: str = "Europe/Moscow"
|
|
allowed_languages: list[str] = []
|
|
|
|
|
|
# #endregion ConsolidatedSettingsResponse
|
|
|
|
|
|
# #region get_consolidated_settings [C:4] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Retrieves all settings categories in a single call.
|
|
# @PRE Config manager is available and the caller holds admin settings read permission.
|
|
# @POST Returns consolidated settings, provider metadata, and persisted notification payload in one stable response.
|
|
# @SIDE_EFFECT Opens one database session to read LLM providers and config-backed notification payload, then closes it.
|
|
# @DATA_CONTRACT Input[ConfigManager] -> Output[ConsolidatedSettingsResponse]
|
|
# @RELATION DEPENDS_ON -> [ConfigManager]
|
|
# @RELATION DEPENDS_ON -> [LLMProviderService]
|
|
# @RELATION DEPENDS_ON -> [AppConfigRecord]
|
|
# @RELATION DEPENDS_ON -> [SessionLocal]
|
|
# @RELATION DEPENDS_ON -> [has_permission]
|
|
# @RELATION DEPENDS_ON -> [normalize_llm_settings]
|
|
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
|
|
async def get_consolidated_settings(
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
_=Depends(has_permission("admin:settings", "READ")),
|
|
):
|
|
with belief_scope("get_consolidated_settings"):
|
|
logger.reason("Fetching consolidated settings payload")
|
|
|
|
config = config_manager.get_config()
|
|
|
|
from ...core.database import SessionLocal
|
|
from ...services.llm_provider import LLMProviderService
|
|
|
|
db = SessionLocal()
|
|
notifications_payload = {}
|
|
try:
|
|
llm_service = LLMProviderService(db)
|
|
providers = llm_service.get_all_providers()
|
|
llm_providers_list = [
|
|
{
|
|
"id": p.id,
|
|
"provider_type": p.provider_type,
|
|
"name": p.name,
|
|
"base_url": p.base_url,
|
|
"api_key": "********",
|
|
"default_model": p.default_model,
|
|
"is_active": p.is_active,
|
|
"is_multimodal": bool(p.is_multimodal) if p.is_multimodal is not None else False,
|
|
"context_window": p.context_window,
|
|
"max_output_tokens": p.max_output_tokens,
|
|
}
|
|
for p in providers
|
|
]
|
|
|
|
config_record = (
|
|
db.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
|
|
)
|
|
if config_record and isinstance(config_record.payload, dict):
|
|
notifications_payload = (
|
|
config_record.payload.get("notifications", {}) or {}
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
normalized_llm = normalize_llm_settings(config.settings.llm)
|
|
|
|
response_payload = ConsolidatedSettingsResponse(
|
|
environments=[env.model_dump() for env in config.environments],
|
|
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(),
|
|
storage=config.settings.storage.model_dump(),
|
|
notifications=notifications_payload,
|
|
features=config.settings.features.model_dump(),
|
|
app_timezone=config.settings.app_timezone,
|
|
allowed_languages=config.settings.allowed_languages,
|
|
)
|
|
logger.reflect(
|
|
"Consolidated settings payload assembled",
|
|
extra={
|
|
"environment_count": len(response_payload.environments),
|
|
"provider_count": len(response_payload.llm_providers),
|
|
},
|
|
)
|
|
return response_payload
|
|
|
|
|
|
# #endregion get_consolidated_settings
|
|
|
|
|
|
# #region update_consolidated_settings [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Bulk update application settings from the consolidated view.
|
|
# @PRE User has admin permissions, config is valid.
|
|
# @POST Settings are updated and saved via ConfigManager.
|
|
@router.patch("/consolidated")
|
|
async def update_consolidated_settings(
|
|
settings_patch: dict,
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
_=Depends(has_permission("admin:settings", "WRITE")),
|
|
):
|
|
with belief_scope("update_consolidated_settings"):
|
|
logger.reason(
|
|
"Applying consolidated settings patch", extra={"src": "update_consolidated_settings"}
|
|
)
|
|
|
|
current_config = config_manager.get_config()
|
|
current_settings = current_config.settings
|
|
|
|
# Update connections if provided
|
|
if "connections" in settings_patch:
|
|
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:
|
|
current_settings.llm = normalize_llm_settings(settings_patch["llm"])
|
|
|
|
# Update Logging if provided
|
|
if "logging" in settings_patch:
|
|
current_settings.logging = LoggingConfig(**settings_patch["logging"])
|
|
|
|
# Update Storage if provided
|
|
if "storage" in settings_patch:
|
|
new_storage = StorageConfig(**settings_patch["storage"])
|
|
is_valid, message = config_manager.validate_path(new_storage.root_path)
|
|
if not is_valid:
|
|
raise HTTPException(status_code=400, detail=message)
|
|
current_settings.storage = new_storage
|
|
|
|
# Update Features if provided
|
|
if "features" in settings_patch:
|
|
from ...core.config_models import FeaturesConfig
|
|
|
|
current_settings.features = FeaturesConfig(**settings_patch["features"])
|
|
|
|
if "notifications" in settings_patch:
|
|
payload = config_manager.get_payload()
|
|
payload["notifications"] = settings_patch["notifications"]
|
|
config_manager.save_config(payload)
|
|
|
|
# Update app_timezone if provided
|
|
if "app_timezone" in settings_patch:
|
|
new_tz = settings_patch["app_timezone"]
|
|
from ...core.timezone import invalidate_timezone_cache, validate_timezone
|
|
if not validate_timezone(new_tz):
|
|
raise HTTPException(status_code=422, detail=f"Invalid IANA timezone: {new_tz}")
|
|
current_settings.app_timezone = new_tz
|
|
invalidate_timezone_cache()
|
|
|
|
# Update allowed_languages if provided
|
|
if "allowed_languages" in settings_patch:
|
|
current_settings.allowed_languages = settings_patch["allowed_languages"]
|
|
|
|
config_manager.update_global_settings(current_settings)
|
|
return {"status": "success", "message": "Settings updated"}
|
|
|
|
|
|
# #endregion update_consolidated_settings
|
|
|
|
|
|
# #region get_validation_policies [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Lists all validation policies.
|
|
@router.get("/automation/policies", response_model=list[ValidationPolicyResponse])
|
|
async def get_validation_policies(
|
|
db: Session = Depends(get_db), _=Depends(has_permission("admin:settings", "READ"))
|
|
):
|
|
with belief_scope("get_validation_policies"):
|
|
return db.query(ValidationPolicy).all()
|
|
|
|
|
|
# #endregion get_validation_policies
|
|
|
|
|
|
# #region create_validation_policy [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Creates a new validation policy.
|
|
@router.post("/automation/policies", response_model=ValidationPolicyResponse)
|
|
async def create_validation_policy(
|
|
policy: ValidationPolicyCreate,
|
|
db: Session = Depends(get_db),
|
|
_=Depends(has_permission("admin:settings", "WRITE")),
|
|
):
|
|
with belief_scope("create_validation_policy"):
|
|
db_policy = ValidationPolicy(**policy.model_dump())
|
|
db.add(db_policy)
|
|
db.commit()
|
|
db.refresh(db_policy)
|
|
return db_policy
|
|
|
|
|
|
# #endregion create_validation_policy
|
|
|
|
|
|
# #region update_validation_policy [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Updates an existing validation policy.
|
|
@router.patch("/automation/policies/{id}", response_model=ValidationPolicyResponse)
|
|
async def update_validation_policy(
|
|
id: str,
|
|
policy: ValidationPolicyUpdate,
|
|
db: Session = Depends(get_db),
|
|
_=Depends(has_permission("admin:settings", "WRITE")),
|
|
):
|
|
with belief_scope("update_validation_policy"):
|
|
db_policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == id).first()
|
|
if not db_policy:
|
|
raise HTTPException(status_code=404, detail="Policy not found")
|
|
|
|
update_data = policy.dict(exclude_unset=True)
|
|
for key, value in update_data.items():
|
|
setattr(db_policy, key, value)
|
|
|
|
db.commit()
|
|
db.refresh(db_policy)
|
|
return db_policy
|
|
|
|
|
|
# #endregion update_validation_policy
|
|
|
|
|
|
# #region delete_validation_policy [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Deletes a validation policy.
|
|
@router.delete("/automation/policies/{id}")
|
|
async def delete_validation_policy(
|
|
id: str,
|
|
db: Session = Depends(get_db),
|
|
_=Depends(has_permission("admin:settings", "WRITE")),
|
|
):
|
|
with belief_scope("delete_validation_policy"):
|
|
db_policy = db.query(ValidationPolicy).filter(ValidationPolicy.id == id).first()
|
|
if not db_policy:
|
|
raise HTTPException(status_code=404, detail="Policy not found")
|
|
|
|
db.delete(db_policy)
|
|
db.commit()
|
|
return {"message": "Policy deleted"}
|
|
|
|
|
|
# #endregion delete_validation_policy
|
|
|
|
|
|
# #region get_translation_schedules [C:2] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Lists all translation schedules joined with translation job names.
|
|
@router.get("/automation/translation-schedules", response_model=list[TranslationScheduleItem])
|
|
async def get_translation_schedules(
|
|
db: Session = Depends(get_db),
|
|
_=Depends(has_permission("admin:settings", "READ")),
|
|
):
|
|
with belief_scope("get_translation_schedules"):
|
|
results = (
|
|
db.query(
|
|
TranslationSchedule,
|
|
TranslationJob.name.label("job_name"),
|
|
)
|
|
.outerjoin(TranslationJob, TranslationSchedule.job_id == TranslationJob.id)
|
|
.all()
|
|
)
|
|
return [
|
|
TranslationScheduleItem(
|
|
schedule_id=ts.id,
|
|
job_id=ts.job_id,
|
|
job_name=job_name,
|
|
cron_expression=ts.cron_expression,
|
|
timezone=ts.timezone,
|
|
is_active=ts.is_active,
|
|
last_run_at=ts.last_run_at,
|
|
next_run_at=ts.next_run_at,
|
|
created_by=ts.created_by,
|
|
created_at=ts.created_at,
|
|
)
|
|
for ts, job_name in results
|
|
]
|
|
|
|
|
|
# #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
|