Coverage for src/local_deep_research/web/services/settings_service.py: 92%
66 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1from typing import Any, Dict, Optional, Union
3from loguru import logger
4from sqlalchemy.orm import Session
6from ...database.models import Setting
7from ...settings.manager import get_typed_setting_value
8from ...utilities.db_utils import get_settings_manager
10# Settings with dynamically populated options (excluded from validation)
11DYNAMIC_SETTINGS = ["llm.provider", "llm.model", "search.tool"]
14def set_setting(
15 key: str,
16 value: Any,
17 commit: bool = True,
18 db_session: Optional[Session] = None,
19) -> bool:
20 """
21 Set a setting value
23 Args:
24 key: Setting key
25 value: Setting value
26 commit: Whether to commit the change
27 db_session: Optional database session
29 Returns:
30 bool: True if successful
31 """
32 manager = get_settings_manager(db_session)
33 return bool(manager.set_setting(key, value, commit))
36def get_all_settings() -> Dict[str, Any]:
37 """
38 Get all settings, optionally filtered by type
40 Returns:
41 Dict[str, Any]: Dictionary of settings
43 """
44 manager = get_settings_manager()
45 return manager.get_all_settings() # type: ignore[no-any-return]
48def create_or_update_setting(
49 setting: Union[Dict[str, Any], Setting],
50 commit: bool = True,
51 db_session: Optional[Session] = None,
52) -> Optional[Setting]:
53 """
54 Create or update a setting
56 Args:
57 setting: Setting dictionary or object
58 commit: Whether to commit the change
59 db_session: Optional database session
61 Returns:
62 Optional[Setting]: The setting object if successful
63 """
64 manager = get_settings_manager(db_session)
65 return manager.create_or_update_setting(setting, commit) # type: ignore[no-any-return]
68def invalidate_settings_caches(username=None):
69 """Invalidate all settings-related caches after a settings mutation.
71 Call this after any route that creates, updates, or deletes settings
72 so background services pick up changes immediately.
74 Safe to call from anywhere — silently skips caches that aren't initialized.
76 Args:
77 username: If provided, invalidates only that user's scheduler cache.
78 If None, invalidates all users' scheduler caches.
79 """
80 # News scheduler per-user settings cache (TTLCache, 5-min TTL).
81 # NOTE: the LLM provider dropdown is served by the auto-discovery path
82 # (llm/providers/auto_discovery.get_discovered_provider_options), which
83 # rebuilds per call and has no cache to invalidate here.
84 try:
85 from ...scheduler.background import get_background_job_scheduler
87 scheduler = get_background_job_scheduler()
88 if username is not None: 88 ↛ 91line 88 didn't jump to line 91 because the condition on line 88 was always true
89 scheduler.invalidate_user_settings_cache(username)
90 else:
91 scheduler.invalidate_all_settings_cache()
92 except Exception:
93 logger.debug("Could not invalidate scheduler cache", exc_info=True)
96def reschedule_document_jobs_if_needed(username, changed_keys):
97 """Reschedule a user's document-scheduler jobs after a settings change.
99 Cache invalidation alone (``invalidate_settings_caches``) only clears the
100 scheduler's cached settings — it does not (re)create or tear down the
101 per-user interval jobs. So toggling ``document_scheduler.*`` settings (e.g.
102 ``sweep_library_collections`` or the legacy ``generate_rag``) would not take
103 effect until the user logged out and back in. Call this AFTER
104 ``invalidate_settings_caches`` so the scheduler re-reads fresh settings and
105 the change applies on the next tick.
107 Only reschedules when a ``document_scheduler.*`` key actually changed, so an
108 unrelated settings save never churns (and resets the timer of) the document
109 jobs. Best-effort and silent when the scheduler isn't running (e.g. tests,
110 CLI), mirroring ``invalidate_settings_caches``.
112 Args:
113 username: The user whose jobs should be re-evaluated.
114 changed_keys: Iterable of setting keys written by the request.
115 """
116 if not username or not changed_keys:
117 return
118 if not any(
119 str(key).startswith("document_scheduler.") for key in changed_keys
120 ):
121 return
122 try:
123 from ...scheduler.background import get_background_job_scheduler
125 scheduler = get_background_job_scheduler()
126 scheduler.reschedule_document_jobs(username)
127 except Exception:
128 logger.debug(
129 "Could not reschedule document jobs after settings change",
130 exc_info=True,
131 )
134def reschedule_zotero_jobs_if_needed(username, changed_keys):
135 """Reschedule a user's Zotero auto-sync job after a settings change.
137 Mirrors :func:`reschedule_document_jobs_if_needed` for ``zotero.*`` keys.
138 Without it, toggling ``zotero.auto_sync_enabled`` (or changing
139 ``zotero.sync_interval_minutes``) would not create/refresh the interval job
140 until the user logged out and back in — the schedule is otherwise only built
141 on the login path. Call AFTER ``invalidate_settings_caches`` so the
142 scheduler re-reads fresh settings. Only reschedules when a ``zotero.`` key
143 actually changed. Best-effort and silent when the scheduler isn't running
144 (e.g. tests, CLI).
146 Args:
147 username: The user whose Zotero job should be re-evaluated.
148 changed_keys: Iterable of setting keys written by the request.
149 """
150 if not username or not changed_keys:
151 return
152 if not any(str(key).startswith("zotero.") for key in changed_keys):
153 return
154 try:
155 from ...scheduler.background import get_background_job_scheduler
157 scheduler = get_background_job_scheduler()
158 scheduler.reschedule_zotero_jobs(username)
159 except Exception:
160 logger.debug(
161 "Could not reschedule Zotero jobs after settings change",
162 exc_info=True,
163 )
166def validate_setting(
167 setting: Setting, value: Any
168) -> tuple[bool, Optional[str]]:
169 """
170 Validate a setting value based on its type and constraints.
172 Args:
173 setting: The Setting object to validate against
174 value: The value to validate
176 Returns:
177 tuple[bool, Optional[str]]: (is_valid, error_message)
178 """
179 # Convert value to appropriate type first using SettingsManager's logic
180 value = get_typed_setting_value(
181 key=str(setting.key),
182 value=value,
183 ui_element=str(setting.ui_element),
184 default=None,
185 check_env=False,
186 )
188 # Validate based on UI element type
189 if setting.ui_element == "checkbox":
190 # After conversion, should be boolean
191 if not isinstance(value, bool):
192 return False, "Value must be a boolean"
194 elif setting.ui_element in ("number", "slider", "range"):
195 # After conversion, should be numeric
196 if not isinstance(value, (int, float)):
197 return False, "Value must be a number"
199 # Check min/max constraints if defined
200 if setting.min_value is not None and value < setting.min_value:
201 return False, f"Value must be at least {setting.min_value}"
202 if setting.max_value is not None and value > setting.max_value:
203 return False, f"Value must be at most {setting.max_value}"
205 elif setting.ui_element == "select":
206 # Check if value is in the allowed options
207 if setting.options:
208 # Skip options validation for dynamically populated dropdowns
209 if setting.key not in DYNAMIC_SETTINGS:
210 allowed_values = [
211 opt.get("value") if isinstance(opt, dict) else opt
212 for opt in list(setting.options)
213 ]
214 if value not in allowed_values:
215 return (
216 False,
217 f"Value must be one of: {', '.join(str(v) for v in allowed_values)}",
218 )
220 # All checks passed
221 return True, None