Coverage for src / local_deep_research / settings / manager.py: 96%
422 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-25 01:07 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-25 01:07 +0000
1import functools
2import json
3import os
4import threading
5from pathlib import Path
6from typing import Any, Dict, List, Optional, Type, Union
8from loguru import logger
9from sqlalchemy import func, or_
10from sqlalchemy.exc import SQLAlchemyError
11from sqlalchemy.orm import Session
13from .. import defaults
14from ..__version__ import __version__ as package_version
15from ..database.models import Setting, SettingType
16from ..web.models.settings import (
17 AppSetting,
18 BaseSetting,
19 LLMSetting,
20 ReportSetting,
21 SearchSetting,
22)
23from ..utilities.type_utils import to_bool
24from .base import ISettingsManager
25from .env_registry import registry as env_registry
28def parse_boolean(value: Any) -> bool:
29 """
30 Convert various representations to boolean using HTML checkbox semantics.
32 This function handles form values, JSON booleans, and environment variables,
33 ensuring consistent behavior across client and server.
35 **HTML Checkbox Semantics** (INTENTIONAL DESIGN):
36 - **Any value present (except explicit false) = checked = True**
37 - This matches standard HTML form behavior where checkbox presence indicates checked state
38 - In HTML forms, checkboxes send a value when checked, nothing when unchecked
40 **Examples**:
41 parse_boolean("on") # True - standard HTML checkbox value
42 parse_boolean("true") # True - explicit true
43 parse_boolean("1") # True - numeric true
44 parse_boolean("enabled") # True - any non-empty string
45 parse_boolean("disabled") # True - INTENTIONAL: any string = checkbox was checked!
46 parse_boolean("custom") # True - custom checkbox value
48 parse_boolean("false") # False - explicit false
49 parse_boolean("off") # False - explicit false
50 parse_boolean("0") # False - explicit false
51 parse_boolean("") # False - empty string = unchecked
52 parse_boolean(None) # False - missing = unchecked
54 **Why "disabled" returns True**:
55 This is NOT a bug! If a checkbox sends the value "disabled", it means the checkbox
56 was checked (present in form data). The actual string content doesn't matter for
57 HTML checkboxes - only presence vs absence matters.
59 Args:
60 value: Value to convert to boolean. Accepts strings, booleans, or None.
62 Returns:
63 bool: True for truthy values (any non-empty string except explicit false);
64 False for falsy values ('off', 'false', '0', '', 'no', False, None)
66 Note:
67 This function implements HTML form semantics, NOT generic boolean parsing.
68 See tests/settings/test_boolean_parsing.py for comprehensive test coverage.
69 """
70 # Constants for boolean value parsing
71 FALSY_VALUES = ("off", "false", "0", "", "no")
73 # Handle already-boolean values
74 if isinstance(value, bool):
75 return value
77 # Handle None (missing values)
78 if value is None:
79 return False
81 # Handle string values
82 if isinstance(value, str):
83 value_lower = value.lower().strip()
84 # Explicitly falsy values (empty string, false-like values)
85 if value_lower in FALSY_VALUES:
86 return False
87 # Any other non-empty string = True (HTML checkbox semantics)
88 return True
90 # For other types (numbers, lists, etc.), use Python's bool conversion
91 return bool(value)
94def _parse_number(x):
95 """Parse number, returning int if it's a whole number, otherwise float."""
96 f = float(x)
97 if f.is_integer():
98 return int(f)
99 return f
102def _parse_json_value(x):
103 """Parse JSON ui_element values.
105 DB values (via SQLAlchemy JSON column) arrive as Python objects already.
106 Form POST and env var overrides arrive as raw strings and need parsing.
107 For example, a textarea containing ``["general"]`` arrives as the string
108 ``'[\\r\\n "general"\\r\\n]'`` which must be decoded into a list.
109 """
110 if isinstance(x, str):
111 stripped = x.strip()
112 if stripped:
113 try:
114 return json.loads(stripped)
115 except (json.JSONDecodeError, ValueError, RecursionError):
116 return x
117 return x
120def _parse_multiselect(x):
121 """Parse multiselect value, handling both lists and strings.
123 DB values (via SQLAlchemy JSON column) arrive as Python lists already.
124 Env var overrides arrive as strings and need parsing — either as JSON
125 arrays (e.g. '["markdown","latex"]') or comma-separated values
126 (e.g. 'markdown,latex').
127 """
128 if isinstance(x, list):
129 return x
130 if isinstance(x, str):
131 stripped = x.strip()
132 if stripped.startswith("["):
133 try:
134 parsed = json.loads(stripped)
135 if isinstance(parsed, list): 135 ↛ 140line 135 didn't jump to line 140 because the condition on line 135 was always true
136 return parsed
137 except (json.JSONDecodeError, ValueError):
138 pass
139 # Comma-separated fallback
140 return [item.strip() for item in stripped.split(",") if item.strip()]
141 return x
144def _filter_setting_columns(data: dict) -> dict:
145 """Filter a dict to only keys that are valid Setting model columns.
147 Prevents crashes when default_settings.json contains keys not present
148 as columns on the Setting model (e.g. future flags).
149 """
150 valid_columns = {c.name for c in Setting.__table__.columns}
151 return {k: v for k, v in data.items() if k in valid_columns}
154UI_ELEMENT_TO_SETTING_TYPE = {
155 "text": str,
156 "json": _parse_json_value,
157 "password": str,
158 "select": str,
159 "number": _parse_number,
160 "range": _parse_number, # Same behavior as number for consistency
161 "checkbox": parse_boolean,
162 "textarea": str,
163 "multiselect": _parse_multiselect,
164}
167def get_typed_setting_value(
168 key: str,
169 value: Any,
170 ui_element: str,
171 default: Any = None,
172 check_env: bool = True,
173) -> Any:
174 """
175 Extracts the value for a particular setting, ensuring that it has the
176 correct type.
178 Args:
179 key: The setting key.
180 value: The setting value from the database.
181 ui_element: The setting UI element ID.
182 default: Default value to return if the value of the setting is
183 invalid.
184 check_env: If true, it will check the environment variable for
185 this setting before reading from the DB.
187 Returns:
188 The value of the setting.
190 """
191 setting_type = UI_ELEMENT_TO_SETTING_TYPE.get(ui_element, None)
192 if setting_type is None:
193 logger.warning(
194 "Got unknown type {} for setting {}, returning default value.",
195 ui_element,
196 key,
197 )
198 return default
200 # Check environment variable first (highest priority).
201 if check_env:
202 env_value = check_env_setting(key)
203 if env_value is not None:
204 try:
205 return setting_type(env_value)
206 except ValueError:
207 logger.warning(
208 "Setting {} has invalid value {}. Falling back to DB.",
209 key,
210 env_value,
211 )
213 # If value is None (not in database), return default.
214 if value is None:
215 return default
217 # Read from the database.
218 try:
219 return setting_type(value)
220 except (ValueError, TypeError):
221 logger.warning(
222 "Setting {} has invalid value {}. Returning default.",
223 key,
224 value,
225 )
226 return default
229def check_env_setting(key: str) -> str | None:
230 """
231 Checks environment variables for a particular setting.
233 Args:
234 key: The database key for the setting.
236 Returns:
237 The setting from the environment variables, or None if the variable
238 is not set.
240 """
241 env_variable_name = f"LDR_{'_'.join(key.split('.')).upper()}"
242 env_value = os.getenv(env_variable_name)
243 if env_value is not None:
244 logger.debug(f"Overriding {key} setting from environment variable.")
245 return env_value
248class SettingsManager(ISettingsManager):
249 """
250 Manager for handling application settings with database storage and file fallback.
251 Provides methods to get and set settings, with the ability to override settings in memory.
252 """
254 def __init__(self, db_session: Optional[Session] = None):
255 """
256 Initialize the settings manager
258 Args:
259 db_session: SQLAlchemy session for database operations
260 """
261 self.db_session = db_session
262 self.db_first = True # Always prioritize DB settings
264 # Store the thread ID this instance was created in
265 self._creation_thread_id = threading.get_ident()
267 # Initialize settings lock as None - will be checked lazily
268 self.__settings_locked = None
270 # Auto-initialize settings if database is empty
271 if self.db_session:
272 self._ensure_settings_initialized()
274 def _ensure_settings_initialized(self):
275 """Ensure settings are initialized in the database."""
276 # Check if we have any settings at all
277 from ..database.models import Setting
279 settings_count = self.db_session.query(Setting).count()
281 if settings_count == 0:
282 logger.info("No settings found in database, loading defaults")
283 self.load_from_defaults_file(commit=True)
284 logger.info("Default settings loaded successfully")
286 def _check_thread_safety(self):
287 """Check if this instance is being used in the same thread it was created in."""
288 current_thread_id = threading.get_ident()
289 if self.db_session and current_thread_id != self._creation_thread_id:
290 raise RuntimeError(
291 f"SettingsManager instance created in thread {self._creation_thread_id} "
292 f"is being used in thread {current_thread_id}. This is not thread-safe! "
293 f"Create a new SettingsManager instance within the current thread context."
294 )
296 @property
297 def settings_locked(self) -> bool:
298 """Check if settings are locked (lazy evaluation)."""
299 if self.__settings_locked is None:
300 try:
301 self.__settings_locked = self.get_setting(
302 "app.lock_settings", False
303 )
304 if self.settings_locked:
305 logger.info(
306 "Settings are locked. Disabling all settings changes."
307 )
308 except Exception:
309 # If we can't check, assume not locked
310 self.__settings_locked = False
311 return self.__settings_locked
313 @functools.cached_property
314 def default_settings(self) -> Dict[str, Any]:
315 """
316 Returns:
317 The default settings, loaded from JSON files and merged.
318 Automatically discovers and loads all .json files in the defaults
319 directory and its subdirectories.
320 Theme options are dynamically injected from the theme registry.
322 """
323 settings = {}
325 try:
326 # Get the defaults package path
327 defaults_path = Path(defaults.__file__).parent
329 # Find all JSON files recursively in the defaults directory
330 json_files = sorted(defaults_path.rglob("*.json"))
332 logger.debug(f"Found {len(json_files)} JSON settings files")
334 # Load and merge all JSON files
335 for json_file in json_files:
336 try:
337 with open(json_file, "r") as f:
338 file_settings = json.load(f)
340 # Get relative path for logging
341 relative_path = json_file.relative_to(defaults_path)
343 # Warn about key conflicts
344 conflicts = set(settings.keys()) & set(file_settings.keys())
345 if conflicts:
346 logger.warning(
347 f"Keys {conflicts} from {relative_path} "
348 f"override existing values"
349 )
351 settings.update(file_settings)
352 logger.debug(f"Loaded {relative_path}")
354 except json.JSONDecodeError:
355 logger.exception(f"Invalid JSON in {json_file}")
356 except Exception as e:
357 logger.warning(f"Could not load {json_file}: {e}")
359 except Exception as e:
360 logger.warning(f"Error loading settings files: {e}")
362 # Inject dynamic theme options from theme registry
363 if "app.theme" in settings:
364 try:
365 from local_deep_research.web.themes import theme_registry
367 settings["app.theme"]["options"] = (
368 theme_registry.get_settings_options()
369 )
370 except ImportError:
371 # Theme registry not available, use static options from JSON
372 pass
374 logger.debug(f"Loaded {len(settings)} total settings")
375 return settings
377 def __get_typed_setting_value(
378 self,
379 setting: Type[Setting],
380 default: Any = None,
381 check_env: bool = True,
382 ) -> Any:
383 """
384 Extracts the value for a particular setting, ensuring that it has the
385 correct type.
387 Args:
388 setting: The setting to get the value for.
389 default: Default value to return if the value of the setting is
390 invalid.
391 check_env: If true, it will check the environment variable for
392 this setting before reading from the DB.
394 Returns:
395 The value of the setting.
397 """
398 return get_typed_setting_value(
399 setting.key,
400 setting.value,
401 setting.ui_element,
402 default=default,
403 check_env=check_env,
404 )
406 def __query_settings(self, key: str | None = None) -> List[Type[Setting]]:
407 """
408 Abstraction for querying settings that also transparently handles
409 reading the default settings file if the DB is not enabled.
411 Args:
412 key: The key to read. If None, it will read everything.
414 Returns:
415 The settings it queried.
417 """
418 if self.db_session:
419 self._check_thread_safety()
420 query = self.db_session.query(Setting)
421 if key is not None:
422 # This will find exact matches and any subkeys.
423 query = query.filter(
424 or_(
425 Setting.key == key,
426 Setting.key.startswith(f"{key}."),
427 )
428 )
429 return query.all()
431 else:
432 logger.debug(
433 "DB is disabled, reading setting '{}' from defaults file.", key
434 )
436 settings = []
437 for candidate_key, setting in self.default_settings.items():
438 if key is None or (
439 candidate_key == key or candidate_key.startswith(f"{key}.")
440 ):
441 settings.append(
442 Setting(
443 key=candidate_key, # gitleaks:allow
444 **_filter_setting_columns(setting),
445 )
446 )
448 return settings
450 def get_setting(
451 self, key: str, default: Any = None, check_env: bool = True
452 ) -> Any:
453 """
454 Get a setting value
456 Args:
457 key: Setting key
458 default: Default value if setting is not found
459 check_env: If true, it will check the environment variable for
460 this setting before reading from the DB.
462 Returns:
463 Setting value or default if not found
464 """
466 # First check if this is an env-only setting
467 if env_registry.is_env_only(key):
468 return env_registry.get(key, default)
470 # If using database first approach and session available, check database
471 try:
472 settings = self.__query_settings(key)
473 if len(settings) == 1:
474 # This is a bottom-level key.
475 result = self.__get_typed_setting_value(
476 settings[0], default, check_env
477 )
478 # Cache the result
479 return result
480 elif len(settings) > 1:
481 # This is a higher-level key.
482 settings_map = {}
483 for setting in settings:
484 output_key = setting.key.removeprefix(f"{key}.")
485 settings_map[output_key] = self.__get_typed_setting_value(
486 setting, default, check_env
487 )
488 return settings_map
489 except SQLAlchemyError:
490 logger.exception(f"Error retrieving setting {key} from database")
492 # Check env var before returning default (setting not in DB)
493 if check_env:
494 env_value = check_env_setting(key)
495 if env_value is not None:
496 default_meta = self.default_settings.get(key)
497 if default_meta and isinstance(default_meta, dict):
498 ui_element = default_meta.get("ui_element", "text")
499 return get_typed_setting_value(
500 key,
501 None,
502 ui_element,
503 default=default,
504 check_env=True,
505 )
506 logger.warning(
507 "Setting '{}' has env var override but is not in "
508 "defaults — returning raw string without type "
509 "conversion. Add this setting to a defaults JSON "
510 "file with a ui_element type to enable proper "
511 "type conversion.",
512 key,
513 )
514 return env_value
516 # Return default if not found
517 return default
519 def get_bool_setting(
520 self, key: str, default: bool = False, check_env: bool = True
521 ) -> bool:
522 """
523 Get a setting value as a boolean, handling string conversion.
525 Args:
526 key: Setting key
527 default: Default boolean value if setting is not found
528 check_env: If true, it will check the environment variable for
529 this setting before reading from the DB.
531 Returns:
532 Boolean value of the setting
533 """
534 value = self.get_setting(key, default, check_env)
535 return to_bool(value, default)
537 def set_setting(self, key: str, value: Any, commit: bool = True) -> bool:
538 """
539 Set a setting value
541 Args:
542 key: Setting key
543 value: Setting value
544 commit: Whether to commit the change
546 Returns:
547 True if successful, False otherwise
548 """
549 if not self.db_session:
550 logger.error(
551 "Cannot edit setting {} because no DB was provided.", key
552 )
553 return False
554 if self.settings_locked:
555 logger.error("Cannot edit setting {} because they are locked.", key)
556 return False
558 # Always update database if available
559 try:
560 self._check_thread_safety()
561 setting = (
562 self.db_session.query(Setting)
563 .filter(Setting.key == key)
564 .first()
565 )
566 if setting:
567 if not setting.editable:
568 logger.error(
569 "Cannot change setting '{}' because it "
570 "is marked as non-editable.",
571 key,
572 )
573 return False
575 setting.value = value
576 setting.updated_at = (
577 func.now()
578 ) # Explicitly set the current timestamp
579 else:
580 # Determine setting type from key
581 setting_type = SettingType.APP
582 if key.startswith("llm."):
583 setting_type = SettingType.LLM
584 elif key.startswith("search."):
585 setting_type = SettingType.SEARCH
586 elif key.startswith("report."):
587 setting_type = SettingType.REPORT
588 elif key.startswith("database."): 588 ↛ 589line 588 didn't jump to line 589 because the condition on line 588 was never true
589 setting_type = SettingType.DATABASE
591 # Create a new setting
592 new_setting = Setting(
593 key=key,
594 value=value,
595 type=setting_type,
596 name=key.split(".")[-1].replace("_", " ").title(),
597 ui_element="text",
598 description=f"Setting for {key}",
599 )
600 self.db_session.add(new_setting)
602 if commit: 602 ↛ 607line 602 didn't jump to line 607 because the condition on line 602 was always true
603 self.db_session.commit()
604 # Emit WebSocket event for settings change
605 self._emit_settings_changed([key])
607 return True
608 except SQLAlchemyError:
609 logger.exception(f"Error setting value for key: {key}")
610 self.db_session.rollback()
611 return False
613 def clear_cache(self):
614 """Clear the settings cache."""
615 self.__dict__.pop("default_settings", None)
616 logger.debug("Settings cache cleared")
618 def get_all_settings(self, bypass_cache: bool = False) -> Dict[str, Any]:
619 """
620 Get all settings, merging defaults with database values.
622 This ensures that new settings added to defaults.json automatically
623 appear in the UI without requiring a database reset.
625 Args:
626 bypass_cache: If True, bypass the cache and read directly from database
628 Returns:
629 Dictionary of all settings
630 """
631 result = {}
633 # Start with defaults so new settings are always included
634 for key, default_setting in self.default_settings.items():
635 result[key] = dict(default_setting)
637 # Check env var override for defaults not yet in DB
638 env_value = check_env_setting(key)
639 if env_value is not None: 639 ↛ 640line 639 didn't jump to line 640 because the condition on line 639 was never true
640 ui_element = default_setting.get("ui_element", "text")
641 typed_value = get_typed_setting_value(
642 key,
643 None,
644 ui_element,
645 default=env_value,
646 check_env=True,
647 )
648 result[key]["value"] = typed_value
649 result[key]["editable"] = False
651 # Override with database settings
652 try:
653 db_settings = self.__query_settings()
654 except SQLAlchemyError:
655 logger.exception(
656 "Error querying settings from database in get_all_settings"
657 )
658 db_settings = []
660 for setting in db_settings:
661 # Handle type field - it might be a string or an enum
662 setting_type = setting.type
663 if hasattr(setting_type, "name"):
664 setting_type = setting_type.name
666 # Log if this is a custom setting not in defaults
667 if setting.key not in result:
668 logger.debug(
669 f"Database contains custom setting not in "
670 f"defaults: {setting.key} (type={setting_type}, "
671 f"category={setting.category})"
672 )
674 # Override default with database value
675 result[setting.key] = dict(
676 value=setting.value,
677 type=setting_type,
678 name=setting.name,
679 description=setting.description,
680 category=setting.category,
681 ui_element=setting.ui_element,
682 options=setting.options,
683 min_value=setting.min_value,
684 max_value=setting.max_value,
685 step=setting.step,
686 visible=setting.visible,
687 editable=False if self.settings_locked else setting.editable,
688 )
690 # Override from the environment variables if needed.
691 env_value = check_env_setting(setting.key)
692 if env_value is not None:
693 ui_element = result[setting.key].get(
694 "ui_element", setting.ui_element
695 )
696 typed_value = get_typed_setting_value(
697 setting.key,
698 None,
699 ui_element,
700 default=env_value,
701 check_env=True,
702 )
703 result[setting.key]["value"] = typed_value
704 # Mark it as non-editable, because changes to the DB
705 # value have no effect as long as the environment
706 # variable is set.
707 result[setting.key]["editable"] = False
709 return result
711 def get_settings_snapshot(self) -> Dict[str, Any]:
712 """
713 Get a simplified settings snapshot with just key-value pairs.
714 This is useful for passing settings to background threads or storing in metadata.
716 Returns:
717 Dictionary with setting keys mapped to their values
718 """
719 all_settings = self.get_all_settings()
720 settings_snapshot = {}
722 for key, setting in all_settings.items():
723 if isinstance(setting, dict) and "value" in setting: 723 ↛ 726line 723 didn't jump to line 726 because the condition on line 723 was always true
724 settings_snapshot[key] = setting["value"]
725 else:
726 settings_snapshot[key] = setting
728 return settings_snapshot
730 def create_or_update_setting(
731 self, setting: Union[BaseSetting, Dict[str, Any]], commit: bool = True
732 ) -> Optional[Setting]:
733 """
734 Create or update a setting
736 Args:
737 setting: Setting object or dictionary
738 commit: Whether to commit the change
740 Returns:
741 The created or updated Setting model, or None if failed
742 """
743 if not self.db_session:
744 logger.warning(
745 "No database session available, cannot create/update setting"
746 )
747 return None
748 if self.settings_locked:
749 logger.error("Cannot edit settings because they are locked.")
750 return None
752 # Convert dict to BaseSetting if needed
753 if isinstance(setting, dict): 753 ↛ 769line 753 didn't jump to line 769 because the condition on line 753 was always true
754 # Determine type from key if not specified
755 if "type" not in setting and "key" in setting:
756 key = setting["key"]
757 if key.startswith("llm."):
758 setting_obj = LLMSetting(**setting)
759 elif key.startswith("search."):
760 setting_obj = SearchSetting(**setting)
761 elif key.startswith("report."):
762 setting_obj = ReportSetting(**setting)
763 else:
764 setting_obj = AppSetting(**setting)
765 else:
766 # Use generic BaseSetting
767 setting_obj = BaseSetting(**setting)
768 else:
769 setting_obj = setting
771 try:
772 # Check if setting exists
773 db_setting = (
774 self.db_session.query(Setting)
775 .filter(Setting.key == setting_obj.key)
776 .first()
777 )
779 if db_setting:
780 # Update existing setting
781 if not db_setting.editable:
782 logger.error(
783 "Cannot change setting '{}' because it "
784 "is marked as non-editable.",
785 setting_obj.key,
786 )
787 return None
789 db_setting.value = setting_obj.value
790 db_setting.name = setting_obj.name
791 db_setting.description = setting_obj.description
792 db_setting.category = setting_obj.category
793 db_setting.ui_element = setting_obj.ui_element
794 db_setting.options = setting_obj.options
795 db_setting.min_value = setting_obj.min_value
796 db_setting.max_value = setting_obj.max_value
797 db_setting.step = setting_obj.step
798 db_setting.visible = setting_obj.visible
799 db_setting.editable = setting_obj.editable
800 db_setting.updated_at = (
801 func.now()
802 ) # Explicitly set the current timestamp
803 else:
804 # Create new setting
805 db_setting = Setting(
806 key=setting_obj.key,
807 value=setting_obj.value,
808 type=setting_obj.type,
809 name=setting_obj.name,
810 description=setting_obj.description,
811 category=setting_obj.category,
812 ui_element=setting_obj.ui_element,
813 options=setting_obj.options,
814 min_value=setting_obj.min_value,
815 max_value=setting_obj.max_value,
816 step=setting_obj.step,
817 visible=setting_obj.visible,
818 editable=setting_obj.editable,
819 )
820 self.db_session.add(db_setting)
822 if commit:
823 self.db_session.commit()
824 # Emit WebSocket event for settings change
825 self._emit_settings_changed([setting_obj.key])
827 return db_setting
829 except SQLAlchemyError:
830 logger.exception(
831 f"Error creating/updating setting {setting_obj.key}"
832 )
833 self.db_session.rollback()
834 return None
836 def delete_setting(self, key: str, commit: bool = True) -> bool:
837 """
838 Delete a setting
840 Args:
841 key: Setting key
842 commit: Whether to commit the change
844 Returns:
845 True if successful, False otherwise
846 """
847 if not self.db_session:
848 logger.warning(
849 "No database session available, cannot delete setting"
850 )
851 return False
853 try:
854 # Remove from database
855 result = (
856 self.db_session.query(Setting)
857 .filter(Setting.key == key)
858 .delete()
859 )
861 if commit:
862 self.db_session.commit()
864 return result > 0
865 except SQLAlchemyError:
866 logger.exception("Error deleting setting")
867 self.db_session.rollback()
868 return False
870 def load_from_defaults_file(
871 self, commit: bool = True, **kwargs: Any
872 ) -> None:
873 """
874 Import settings from the defaults settings file.
876 Args:
877 commit: Whether to commit changes to database
878 **kwargs: Will be passed to `import_settings`.
880 """
881 self.import_settings(self.default_settings, commit=commit, **kwargs)
883 def db_version_matches_package(self) -> bool:
884 """
885 Returns:
886 True if the version saved in the DB matches the package version.
888 """
889 db_version = self.get_setting("app.version")
890 logger.debug(
891 f"App version saved in DB is {db_version}, have package "
892 f"settings from version {package_version}."
893 )
895 return db_version == package_version
897 def update_db_version(self) -> None:
898 """
899 Updates the version saved in the DB based on the package version.
901 """
902 logger.debug(f"Updating saved DB version to {package_version}.")
904 self.delete_setting("app.version", commit=False)
905 version = Setting(
906 key="app.version",
907 value=package_version,
908 description="Version of the app this database is associated with.",
909 editable=False,
910 name="App Version",
911 type=SettingType.APP,
912 ui_element="text",
913 visible=False,
914 )
916 self.db_session.add(version)
917 self.db_session.commit()
919 def import_settings(
920 self,
921 settings_data: Dict[str, Any],
922 commit: bool = True,
923 overwrite: bool = True,
924 delete_extra: bool = False,
925 ) -> None:
926 """
927 Import settings directly from the export format. This can be used to
928 re-import settings that have been exported with `get_all_settings()`.
930 Args:
931 settings_data: The raw settings data to import.
932 commit: Whether to commit the DB after loading the settings.
933 overwrite: If true, it will overwrite the value of settings that
934 are already in the database.
935 delete_extra: If true, it will delete any settings that are in
936 the database but don't have a corresponding entry in
937 `settings_data`.
939 """
940 logger.debug(f"Importing {len(settings_data)} settings")
942 for key, setting_values in settings_data.items():
943 setting_values = dict(setting_values)
944 if not overwrite:
945 existing_value = self.get_setting(key)
946 if existing_value is not None:
947 # Preserve the value from this setting.
948 setting_values["value"] = existing_value
950 # Delete any existing setting so we can completely overwrite it.
951 self.delete_setting(key, commit=False)
953 # Convert type string to SettingType enum if needed
954 if "type" in setting_values and isinstance( 954 ↛ 959line 954 didn't jump to line 959 because the condition on line 954 was always true
955 setting_values["type"], str
956 ):
957 setting_values["type"] = SettingType[setting_values["type"]]
959 setting = Setting(
960 key=key, **_filter_setting_columns(setting_values)
961 )
962 self.db_session.add(setting)
964 if commit or delete_extra: 964 ↛ 970line 964 didn't jump to line 970 because the condition on line 964 was always true
965 self.db_session.commit()
966 logger.info(f"Successfully imported {len(settings_data)} settings")
967 # Emit WebSocket event for all imported settings
968 self._emit_settings_changed(list(settings_data.keys()))
970 if delete_extra:
971 all_settings = self.get_all_settings()
972 for key in all_settings:
973 if key not in settings_data:
974 logger.debug(f"Deleting extraneous setting: {key}")
975 self.delete_setting(key, commit=False)
977 def _create_setting(self, key, value, setting_type):
978 """Create a setting with appropriate metadata"""
980 # Determine appropriate category
981 category = None
982 ui_element = "text"
984 # Determine category based on key pattern
985 if key.startswith("app."):
986 category = "app_interface"
987 elif key.startswith("llm."):
988 if any(
989 param in key
990 for param in [
991 "temperature",
992 "max_tokens",
993 "n_batch",
994 "n_gpu_layers",
995 ]
996 ):
997 category = "llm_parameters"
998 else:
999 category = "llm_general"
1000 elif key.startswith("search."):
1001 if any(
1002 param in key
1003 for param in ["iterations", "questions", "results", "region"]
1004 ):
1005 category = "search_parameters"
1006 else:
1007 category = "search_general"
1008 elif key.startswith("report."):
1009 category = "report_parameters"
1011 # Determine UI element type based on value
1012 if isinstance(value, bool):
1013 ui_element = "checkbox"
1014 elif isinstance(value, (int, float)) and not isinstance(value, bool):
1015 ui_element = "number"
1016 elif isinstance(value, (dict, list)):
1017 ui_element = "textarea"
1019 # Build setting object
1020 setting_dict = {
1021 "key": key,
1022 "value": value,
1023 "type": setting_type.value.lower(),
1024 "name": key.split(".")[-1].replace("_", " ").title(),
1025 "description": f"Setting for {key}",
1026 "category": category,
1027 "ui_element": ui_element,
1028 }
1030 # Create the setting in the database
1031 self.create_or_update_setting(setting_dict, commit=False)
1033 def _emit_settings_changed(self, changed_keys: list = None):
1034 """
1035 Emit WebSocket event when settings change
1037 Args:
1038 changed_keys: List of setting keys that changed
1039 """
1040 try:
1041 # Import here to avoid circular imports
1042 from ..web.services.socket_service import SocketIOService
1044 try:
1045 socket_service = SocketIOService()
1046 except ValueError:
1047 logger.debug(
1048 "Not emitting socket event because server is not initialized."
1049 )
1050 return
1052 # Get the changed settings
1053 settings_data = {}
1054 if changed_keys: 1054 ↛ 1061line 1054 didn't jump to line 1061 because the condition on line 1054 was always true
1055 for key in changed_keys:
1056 setting_value = self.get_setting(key)
1057 if setting_value is not None:
1058 settings_data[key] = {"value": setting_value}
1060 # Emit the settings change event
1061 from datetime import datetime, UTC
1063 socket_service.emit_socket_event(
1064 "settings_changed",
1065 {
1066 "changed_keys": changed_keys or [],
1067 "settings": settings_data,
1068 "timestamp": datetime.now(UTC).isoformat(),
1069 },
1070 )
1072 logger.debug(
1073 f"Emitted settings_changed event for keys: {changed_keys}"
1074 )
1076 except Exception:
1077 logger.exception("Failed to emit settings change event")
1078 # Don't let WebSocket emission failures break settings saving
1080 @staticmethod
1081 def get_bootstrap_env_vars() -> Dict[str, str]:
1082 """
1083 Get environment variables that must be available before database access.
1084 These are critical for system initialization.
1086 Returns:
1087 Dict mapping env var names to their descriptions
1088 """
1089 # Get bootstrap vars from env registry
1090 return env_registry.get_bootstrap_vars()
1092 @staticmethod
1093 def is_bootstrap_env_var(env_var: str) -> bool:
1094 """
1095 Check if an environment variable is a bootstrap variable (needed before DB access).
1097 Args:
1098 env_var: Environment variable name
1100 Returns:
1101 True if this is a bootstrap variable
1102 """
1103 bootstrap_vars = SettingsManager.get_bootstrap_env_vars()
1104 return env_var in bootstrap_vars
1106 @staticmethod
1107 def is_env_only_setting(key: str) -> bool:
1108 """
1109 Check if a setting key is environment-only.
1111 Args:
1112 key: Setting key to check
1114 Returns:
1115 True if it's an env-only setting, False otherwise
1116 """
1117 return env_registry.is_env_only(key)
1119 @staticmethod
1120 def get_env_var_for_setting(setting_key: str) -> str:
1121 """
1122 Get the environment variable name for a given setting key.
1124 Args:
1125 setting_key: Setting key (e.g., "app.host")
1127 Returns:
1128 Environment variable name (e.g., "LDR_APP_HOST")
1129 """
1130 # Use the same logic as check_env_setting for consistency
1131 return f"LDR_{'_'.join(setting_key.split('.')).upper()}"
1133 @staticmethod
1134 def get_setting_key_for_env_var(env_var: str) -> Optional[str]:
1135 """
1136 Get the setting key for a given environment variable.
1138 Args:
1139 env_var: Environment variable name (e.g., "LDR_APP_HOST")
1141 Returns:
1142 Setting key (e.g., "app.host") or None if not a valid LDR env var
1143 """
1144 if not env_var.startswith("LDR_"):
1145 return None
1147 # Remove LDR_ prefix and convert to lowercase
1148 without_prefix = env_var[4:]
1149 parts = without_prefix.split("_")
1151 return ".".join(part.lower() for part in parts)