Coverage for src/local_deep_research/web/services/settings_service.py: 94%

84 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1import math 

2from typing import Any, Dict, Optional, Union 

3 

4from loguru import logger 

5from sqlalchemy.orm import Session 

6 

7from ...database.models import Setting 

8from ...settings.exceptions import SettingsValueError 

9from ...settings.manager import DYNAMIC_SETTINGS, get_typed_setting_value 

10from ...utilities.db_utils import get_settings_manager 

11from ...utilities.type_utils import to_positive_int 

12 

13# Settings with dynamically populated options (excluded from validation). 

14# Single source of truth lives in `settings/manager.py` (it is also used by 

15# `import_settings` there); re-exported here for the web save path. The 

16# re-export must stay identity-equal to the manager's list — pinned by 

17# tests/settings/test_import_settings_validation.py. 

18 

19 

20def set_setting( 

21 key: str, 

22 value: Any, 

23 commit: bool = True, 

24 db_session: Optional[Session] = None, 

25) -> bool: 

26 """ 

27 Set a setting value 

28 

29 Args: 

30 key: Setting key 

31 value: Setting value 

32 commit: Whether to commit the change 

33 db_session: Optional database session 

34 

35 Returns: 

36 bool: True if successful 

37 """ 

38 manager = get_settings_manager(db_session) 

39 return bool(manager.set_setting(key, value, commit)) 

40 

41 

42def get_all_settings() -> Dict[str, Any]: 

43 """ 

44 Get all settings, optionally filtered by type 

45 

46 Returns: 

47 Dict[str, Any]: Dictionary of settings 

48 

49 """ 

50 manager = get_settings_manager() 

51 return manager.get_all_settings() # type: ignore[no-any-return] 

52 

53 

54def create_or_update_setting( 

55 setting: Union[Dict[str, Any], Setting], 

56 commit: bool = True, 

57 db_session: Optional[Session] = None, 

58) -> Optional[Setting]: 

59 """ 

60 Create or update a setting 

61 

62 Args: 

63 setting: Setting dictionary or object 

64 commit: Whether to commit the change 

65 db_session: Optional database session 

66 

67 Returns: 

68 Optional[Setting]: The setting object if successful 

69 """ 

70 manager = get_settings_manager(db_session) 

71 return manager.create_or_update_setting(setting, commit) # type: ignore[no-any-return] 

72 

73 

74def invalidate_settings_caches(username=None): 

75 """Invalidate all settings-related caches after a settings mutation. 

76 

77 Call this after any route that creates, updates, or deletes settings 

78 so background services pick up changes immediately. 

79 

80 Safe to call from anywhere — silently skips caches that aren't initialized. 

81 

82 Args: 

83 username: If provided, invalidates only that user's scheduler cache. 

84 If None, invalidates all users' scheduler caches. 

85 """ 

86 # News scheduler per-user settings cache (TTLCache, 5-min TTL). 

87 # NOTE: the LLM provider dropdown is served by the auto-discovery path 

88 # (llm/providers/auto_discovery.get_discovered_provider_options), which 

89 # rebuilds per call and has no cache to invalidate here. 

90 try: 

91 from ...scheduler.background import get_background_job_scheduler 

92 

93 scheduler = get_background_job_scheduler() 

94 if username is not None: 

95 scheduler.invalidate_user_settings_cache(username) 

96 else: 

97 scheduler.invalidate_all_settings_cache() 

98 except Exception: 

99 logger.debug("Could not invalidate scheduler cache", exc_info=True) 

100 

101 

102def reschedule_document_jobs_if_needed(username, changed_keys): 

103 """Reschedule a user's document-scheduler jobs after a settings change. 

104 

105 Cache invalidation alone (``invalidate_settings_caches``) only clears the 

106 scheduler's cached settings — it does not (re)create or tear down the 

107 per-user interval jobs. So toggling ``document_scheduler.*`` settings (e.g. 

108 ``sweep_library_collections`` or the legacy ``generate_rag``) would not take 

109 effect until the user logged out and back in. Call this AFTER 

110 ``invalidate_settings_caches`` so the scheduler re-reads fresh settings and 

111 the change applies on the next tick. 

112 

113 Only reschedules when a ``document_scheduler.*`` key actually changed, so an 

114 unrelated settings save never churns (and resets the timer of) the document 

115 jobs. Best-effort and silent when the scheduler isn't running (e.g. tests, 

116 CLI), mirroring ``invalidate_settings_caches``. 

117 

118 Args: 

119 username: The user whose jobs should be re-evaluated. 

120 changed_keys: Iterable of setting keys written by the request. 

121 """ 

122 if not username or not changed_keys: 

123 return 

124 if not any( 

125 str(key).startswith("document_scheduler.") for key in changed_keys 

126 ): 

127 return 

128 try: 

129 from ...scheduler.background import get_background_job_scheduler 

130 

131 scheduler = get_background_job_scheduler() 

132 scheduler.reschedule_document_jobs(username) 

133 except Exception: 

134 logger.debug( 

135 "Could not reschedule document jobs after settings change", 

136 exc_info=True, 

137 ) 

138 

139 

140def reschedule_zotero_jobs_if_needed(username, changed_keys): 

141 """Reschedule a user's Zotero auto-sync job after a settings change. 

142 

143 Mirrors :func:`reschedule_document_jobs_if_needed` for ``zotero.*`` keys. 

144 Without it, toggling ``zotero.auto_sync_enabled`` (or changing 

145 ``zotero.sync_interval_minutes``) would not create/refresh the interval job 

146 until the user logged out and back in — the schedule is otherwise only built 

147 on the login path. Call AFTER ``invalidate_settings_caches`` so the 

148 scheduler re-reads fresh settings. Only reschedules when a ``zotero.`` key 

149 actually changed. Best-effort and silent when the scheduler isn't running 

150 (e.g. tests, CLI). 

151 

152 Args: 

153 username: The user whose Zotero job should be re-evaluated. 

154 changed_keys: Iterable of setting keys written by the request. 

155 """ 

156 if not username or not changed_keys: 

157 return 

158 if not any(str(key).startswith("zotero.") for key in changed_keys): 

159 return 

160 try: 

161 from ...scheduler.background import get_background_job_scheduler 

162 

163 scheduler = get_background_job_scheduler() 

164 scheduler.reschedule_zotero_jobs(username) 

165 except Exception: 

166 logger.debug( 

167 "Could not reschedule Zotero jobs after settings change", 

168 exc_info=True, 

169 ) 

170 

171 

172def validate_setting( 

173 setting: Setting, value: Any 

174) -> tuple[bool, Optional[str]]: 

175 """ 

176 Validate a setting value based on its type and constraints. 

177 

178 Args: 

179 setting: The Setting object to validate against 

180 value: The value to validate 

181 

182 Returns: 

183 tuple[bool, Optional[str]]: (is_valid, error_message) 

184 """ 

185 if setting.key == "search.fetch.summary_input_max_length": 

186 try: 

187 to_positive_int(value) 

188 except ValueError: 

189 return False, "Value must be a positive integer" 

190 

191 # Validate the exact summary integer before generic number conversion 

192 # or the blank-input handling for other optional numeric settings. 

193 raw_value = value 

194 numeric_ui = setting.ui_element in ("number", "slider", "range") 

195 # Blank form inputs explicitly clear optional numeric settings; they are 

196 # normalized to None on write, not used as a fallback for bad numbers. 

197 if numeric_ui and ( 

198 raw_value is None 

199 or (isinstance(raw_value, str) and not raw_value.strip()) 

200 ): 

201 return True, None 

202 try: 

203 value = get_typed_setting_value( 

204 key=str(setting.key), 

205 value=value, 

206 ui_element=str(setting.ui_element), 

207 default=None, 

208 check_env=False, 

209 ) 

210 except SettingsValueError as error: 

211 if numeric_ui: 211 ↛ 213line 211 didn't jump to line 213 because the condition on line 211 was always true

212 return False, "Value must be a number" 

213 if setting.ui_element == "checkbox": 

214 return False, "Value must be a boolean" 

215 return False, error.reason 

216 

217 # Validate based on UI element type 

218 if setting.ui_element == "checkbox": 

219 # After conversion, should be boolean 

220 if not isinstance(value, bool): 

221 return False, "Value must be a boolean" 

222 

223 elif setting.ui_element in ("number", "slider", "range"): 

224 # After conversion, should be numeric 

225 if ( 

226 isinstance(raw_value, bool) 

227 or isinstance(value, bool) 

228 or not isinstance(value, (int, float)) 

229 or (isinstance(value, float) and not math.isfinite(value)) 

230 ): 

231 return False, "Value must be a number" 

232 

233 # Check min/max constraints if defined 

234 if setting.min_value is not None and value < setting.min_value: 

235 return False, f"Value must be at least {setting.min_value}" 

236 if setting.max_value is not None and value > setting.max_value: 

237 return False, f"Value must be at most {setting.max_value}" 

238 

239 elif setting.ui_element == "select": 

240 # Check if value is in the allowed options 

241 if setting.options: 241 ↛ 255line 241 didn't jump to line 255 because the condition on line 241 was always true

242 # Skip options validation for dynamically populated dropdowns 

243 if setting.key not in DYNAMIC_SETTINGS: 

244 allowed_values = [ 

245 opt.get("value") if isinstance(opt, dict) else opt 

246 for opt in list(setting.options) 

247 ] 

248 if value not in allowed_values: 

249 return ( 

250 False, 

251 f"Value must be one of: {', '.join(str(v) for v in allowed_values)}", 

252 ) 

253 

254 # All checks passed 

255 return True, None