settings_service.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. from __future__ import annotations
  2. from pathlib import Path
  3. from typing import Any
  4. from fastapi import HTTPException
  5. from .database import DATA_DIR, get_db
  6. from .scanner import now_iso
  7. from .schemas import SystemSettingsUpdate
  8. SETTING_KEYS = {
  9. "default_ai_provider_id",
  10. "default_ai_model_id",
  11. "default_ai_temperature",
  12. "automation_file_root",
  13. "automation_screen_path",
  14. "automation_error_path",
  15. "automation_runtime_path",
  16. "automation_auto_screenshot_enabled",
  17. "automation_auto_screenshot_interval",
  18. "automation_remote_token",
  19. }
  20. def list_settings() -> dict[str, Any]:
  21. with get_db() as conn:
  22. rows = conn.execute("SELECT * FROM app_settings ORDER BY key ASC").fetchall()
  23. values = {row["key"]: row["value"] for row in rows}
  24. return {"settings": normalize_settings(values), "items": rows}
  25. def update_settings(payload: SystemSettingsUpdate) -> dict[str, Any]:
  26. values = payload.model_dump(exclude_unset=True)
  27. now = now_iso()
  28. with get_db() as conn:
  29. for key, value in values.items():
  30. if key not in SETTING_KEYS:
  31. continue
  32. if key.endswith("_path") or key == "automation_file_root":
  33. value = normalize_relative_path(value)
  34. conn.execute(
  35. """
  36. INSERT INTO app_settings (key, value, description, updated_at)
  37. VALUES (?, ?, COALESCE((SELECT description FROM app_settings WHERE key = ?), ''), ?)
  38. ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
  39. """,
  40. (key, serialize_value(value), key, now),
  41. )
  42. return list_settings()
  43. def normalize_settings(values: dict[str, str | None]) -> dict[str, Any]:
  44. return {
  45. "default_ai_provider_id": optional_int(values.get("default_ai_provider_id")),
  46. "default_ai_model_id": optional_int(values.get("default_ai_model_id")),
  47. "default_ai_temperature": optional_float(values.get("default_ai_temperature"), 0.1),
  48. "automation_file_root": values.get("automation_file_root") or "automation",
  49. "automation_screen_path": values.get("automation_screen_path") or "automation/screens",
  50. "automation_error_path": values.get("automation_error_path") or "automation/errors",
  51. "automation_runtime_path": values.get("automation_runtime_path") or "automation/runtime",
  52. "automation_auto_screenshot_enabled": parse_bool(values.get("automation_auto_screenshot_enabled")),
  53. "automation_auto_screenshot_interval": optional_int(values.get("automation_auto_screenshot_interval"), 30),
  54. "automation_remote_token": values.get("automation_remote_token") or "",
  55. }
  56. def automation_remote_token() -> str:
  57. return str(get_settings_dict().get("automation_remote_token") or "").strip()
  58. def get_settings_dict() -> dict[str, Any]:
  59. return list_settings()["settings"]
  60. def default_ai_params() -> dict[str, Any]:
  61. settings = get_settings_dict()
  62. return {
  63. "provider_id": settings.get("default_ai_provider_id"),
  64. "model_id": settings.get("default_ai_model_id"),
  65. "temperature": settings.get("default_ai_temperature", 0.1),
  66. }
  67. def resolve_data_path(setting_key: str, fallback: str) -> Path:
  68. settings = get_settings_dict()
  69. relative = settings.get(setting_key) or fallback
  70. normalized = normalize_relative_path(relative)
  71. path = (DATA_DIR / normalized).resolve()
  72. data_root = DATA_DIR.resolve()
  73. if data_root != path and data_root not in path.parents:
  74. raise HTTPException(status_code=400, detail="Configured path escapes data directory")
  75. path.mkdir(parents=True, exist_ok=True)
  76. return path
  77. def normalize_relative_path(value: str | None) -> str:
  78. raw = (value or "").strip().replace("\\", "/")
  79. if not raw:
  80. return ""
  81. path = Path(raw)
  82. if path.is_absolute() or ".." in path.parts:
  83. raise HTTPException(status_code=400, detail="Path must be relative and must not contain ..")
  84. return "/".join(part for part in path.parts if part not in {"", "."})
  85. def optional_int(value: Any, default: int | None = None) -> int | None:
  86. if value in (None, ""):
  87. return default
  88. try:
  89. return int(value)
  90. except (TypeError, ValueError):
  91. return default
  92. def optional_float(value: Any, default: float | None = None) -> float | None:
  93. if value in (None, ""):
  94. return default
  95. try:
  96. return float(value)
  97. except (TypeError, ValueError):
  98. return default
  99. def parse_bool(value: Any) -> bool:
  100. return str(value).lower() in {"1", "true", "yes", "on"}
  101. def serialize_value(value: Any) -> str:
  102. if value is None:
  103. return ""
  104. if isinstance(value, bool):
  105. return "1" if value else "0"
  106. return str(value)