Coverage for src / lexigram / admin / settings / service.py: 0%

227 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 02:25 +0800

1from __future__ import annotations 

2 

3from dataclasses import dataclass, field 

4from datetime import UTC, datetime 

5from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable 

6 

7from lexigram import serialization as json 

8from lexigram.admin.models.setting import SystemSetting 

9from lexigram.admin.settings.panel.utils import map_config_node_type 

10from lexigram.contracts.infra.cache import CacheBackendProtocol 

11from lexigram.domain import DomainModel 

12from lexigram.logging import get_logger 

13 

14logger = get_logger(__name__) 

15_registry_warned = [False] 

16 

17 

18def _warn_registry_unavailable() -> None: 

19 """Emit a one-off warning when the admin panel registry is unavailable.""" 

20 

21 if _registry_warned[0]: 

22 return 

23 _registry_warned[0] = True 

24 logger.warning( 

25 "config_registry_unavailable", 

26 hint="pip install lexigram-admin", 

27 detail="falling back to legacy settings resolution", 

28 ) 

29 

30 

31@runtime_checkable 

32class _SettingsRepository(Protocol): 

33 """Narrow structural protocol for the settings storage backend. 

34 

35 Avoids a direct import of ``GenericRepository`` from ``lexigram-sql``. 

36 """ 

37 

38 async def find_one(self, **filters: Any) -> Any: ... 

39 

40 async def update(self, entity: Any) -> Any: ... 

41 

42 async def create(self, data: Any) -> Any: ... 

43 

44 

45# Events integration 

46from lexigram.contracts.domain import DomainEvent 

47from lexigram.di.decorators import inject 

48 

49if TYPE_CHECKING: 

50 from lexigram.contracts.core.di import ContainerResolverProtocol 

51 

52 

53class SettingUpdated(DomainEvent): 

54 """Fired when a setting is changed.""" 

55 

56 key: str 

57 value: str 

58 scope: str 

59 scope_id: str 

60 

61 

62@dataclass(init=False) 

63class SettingDefinition(DomainModel): 

64 """Defines a comprehensive schema for a configuration setting.""" 

65 

66 key: str 

67 scope: list[str] = field( 

68 default_factory=lambda: ["global"] 

69 ) # ["global", "tenant", "user"] 

70 type: str = "string" # string, int, bool, json, secret, enum 

71 default: Any = None 

72 options: list[str] | dict[str, str] | None = None # For enums 

73 label: str = "" 

74 description: str | None = None 

75 category: str = "General" 

76 is_public: bool = False 

77 validation_rules: str | None = None # e.g. "min:1|max:100" 

78 

79 

80class SettingsRegistry: 

81 """Registry for setting definitions.""" 

82 

83 _definitions: dict[str, SettingDefinition] = {} 

84 

85 @classmethod 

86 def register(cls, definition: SettingDefinition) -> Any: 

87 cls._definitions[definition.key] = definition 

88 

89 @classmethod 

90 def get(cls, key: str) -> SettingDefinition | None: 

91 return cls._definitions.get(key) 

92 

93 @classmethod 

94 def get_all(cls) -> list[SettingDefinition]: 

95 return list(cls._definitions.values()) 

96 

97 @classmethod 

98 def get_for_scope(cls, scope: str) -> list[SettingDefinition]: 

99 return list(filter(lambda d: scope in d.scope, cls._definitions.values())) 

100 

101 

102@inject 

103class SettingsService: 

104 """Service for managing implementation and resolution of settings.""" 

105 

106 def __init__( 

107 self, 

108 repository: _SettingsRepository, 

109 event_bus: Any = None, 

110 cache: CacheBackendProtocol | None = None, 

111 ): 

112 self.repo = repository 

113 self.registry = SettingsRegistry() 

114 self.event_bus = event_bus 

115 self.cache = cache 

116 

117 async def get(self, key: str, context: Any | None = None) -> Any: 

118 """ 

119 Resolve a setting value with cascading logic and caching. 

120 """ 

121 definition = self.registry.get(key) 

122 

123 # 1. Try Cache if available 

124 cache_key = self._get_cache_key(key, context) 

125 if self.cache: 

126 try: 

127 res = await self.cache.get(cache_key) 

128 if res.is_ok(): 

129 cached = res.unwrap() 

130 if cached is not None: 

131 return cached 

132 except (RuntimeError, ValueError, OSError) as e: 

133 logger.warning("Cache lookup failed for %s: %s", key, e) 

134 

135 # 2. Cascading Strategy: 

136 resolved_val = await self._resolve_cascaded(key, context, definition) 

137 

138 # 3. Cache and return 

139 if self.cache: 

140 try: 

141 await self.cache.set(cache_key, resolved_val, ttl=3600) 

142 except (RuntimeError, ValueError, OSError): 

143 logger.exception("Cache set failed") 

144 

145 return resolved_val 

146 

147 async def _resolve_cascaded( 

148 self, 

149 key: str, 

150 context: Any | None, 

151 definition: SettingDefinition | None, 

152 ) -> Any: 

153 # 1. Env Override (Highest Priority) - LEX_KEY_STYLE 

154 env_val = self._get_from_env(key) 

155 if env_val is not None: 

156 return self._cast_value(env_val, definition) 

157 

158 # 2. Database: User Scope 

159 if context: 

160 user = getattr(context, "user", None) or getattr( 

161 getattr(context, "state", None), 

162 "user", 

163 None, 

164 ) 

165 # Prefer canonical `user_id` attribute on user objects 

166 if user and hasattr(user, "user_id"): 

167 user_val = await self._get_from_repo(key, "user", str(user.user_id)) 

168 if user_val is not None: 

169 return self._cast_value(user_val, definition) 

170 

171 # 3. Database: Global Scope 

172 global_val = await self._get_from_repo(key, "global", "system") 

173 if global_val is not None: 

174 return self._cast_value(global_val, definition) 

175 

176 # 4. YAML Config (LexigramConfig) 

177 yaml_val = self._get_from_yaml_config(key) 

178 if yaml_val is not None: 

179 return yaml_val 

180 

181 # 5. Default from Registry Definition 

182 if definition: 

183 return definition.default 

184 

185 # 6. Fallback to ConfigRegistry for new ConfigSpecs 

186 try: 

187 from lexigram.admin.lib.di import get_admin_resolver 

188 from lexigram.admin.settings.panel.registry import ConfigRegistry 

189 

190 resolver = get_admin_resolver(context) 

191 registry = await resolver.resolve(ConfigRegistry) 

192 node = registry.get_node(key) 

193 if node: 

194 return node.default 

195 except ImportError: 

196 _warn_registry_unavailable() 

197 

198 return None 

199 

200 def _get_from_env(self, key: str) -> str | None: 

201 """Get value from environment variable (LEX_ADMIN__KEY__STYLE).""" 

202 import os 

203 

204 env_key = f"LEX_ADMIN__{key.upper().replace('.', '__')}" 

205 return os.environ.get(env_key) 

206 

207 def _get_from_yaml_config(self, key: str) -> Any: 

208 """Get value from boot-time config via LexigramConfig. 

209 

210 Traverses the boot config dict using dot-separated keys. 

211 """ 

212 try: 

213 from lexigram.config import LexigramConfig 

214 

215 config = LexigramConfig.boot_config() # type: ignore[attr-defined] 

216 config_dict = config.model_dump() 

217 parts = key.split(".") 

218 curr = config_dict 

219 for p in parts: 

220 if isinstance(curr, dict) and p in curr: 

221 curr = curr[p] 

222 else: 

223 return None 

224 return curr 

225 except (ImportError, RuntimeError): 

226 return None 

227 

228 def _get_cache_key(self, key: str, context: Any | None = None) -> str: 

229 """Generate a unique cache key based on key and context.""" 

230 scope_suffix = "global" 

231 if context: 

232 user = getattr(context, "user", None) or getattr( 

233 getattr(context, "state", None), 

234 "user", 

235 None, 

236 ) 

237 # Use canonical `user_id` when present 

238 if user and hasattr(user, "user_id"): 

239 scope_suffix = f"user:{user.user_id}" 

240 return f"settings:resolved:{key}:{scope_suffix}" 

241 

242 async def _get_from_repo( 

243 self, 

244 key: str, 

245 scope: str, 

246 scope_id: str, 

247 ) -> str | None: 

248 """Fetch raw value from repository using simple filters.""" 

249 try: 

250 # Using simple filter-based find_one 

251 setting = await self.repo.find_one(scope=scope, scope_id=scope_id, key=key) 

252 return setting.value if setting else None 

253 except BaseException: 

254 logger.exception("Failed to fetch setting %s from repo", key) 

255 return None 

256 

257 def _cast_value(self, value: str, definition: SettingDefinition | None) -> Any: 

258 # ... (rest of the method unchanged) 

259 if not definition: 

260 return value 

261 

262 if definition.type == "bool": 

263 return value.lower() == "true" 

264 if definition.type == "int": 

265 try: 

266 return int(value) 

267 except ValueError: 

268 return 0 

269 elif definition.type == "json": 

270 from lexigram.serialization import loads_str 

271 

272 try: 

273 return loads_str(value) 

274 except (json.JSONDecodeError, ValueError, TypeError) as e: 

275 logger.warning( 

276 "Failed to parse JSON setting for %s: %s", 

277 definition.key, 

278 e, 

279 ) 

280 return {} 

281 

282 return value 

283 

284 async def set( 

285 self, 

286 key: str, 

287 value: Any, 

288 scope: str = "global", 

289 scope_id: str = "system", 

290 context: Any | None = None, 

291 ) -> Any: 

292 """Persist a setting value with audit tracking and cache invalidation.""" 

293 

294 # 1. Resolve Auditor 

295 updated_by = None 

296 if context: 

297 user = getattr(context, "user", None) or getattr( 

298 getattr(context, "state", None), 

299 "user", 

300 None, 

301 ) 

302 if user: 

303 # Prefer username, then canonical user_id, then fallback to str(user) 

304 updated_by = getattr( 

305 user, 

306 "username", 

307 getattr(user, "user_id", str(user)), 

308 ) 

309 

310 # 2. Type serialization 

311 str_value = str(value) 

312 if isinstance(value, bool): 

313 str_value = "true" if value else "false" 

314 elif isinstance(value, (dict, list)): 

315 from lexigram.serialization import dumps_str 

316 

317 str_value = dumps_str(value) 

318 

319 definition = self.registry.get(key) 

320 setting_type = "string" 

321 is_sensitive = False 

322 

323 if definition: 

324 setting_type = definition.type 

325 is_sensitive = definition.type == "secret" 

326 else: 

327 # Fallback to ConfigRegistry 

328 try: 

329 from lexigram.admin.lib.di import get_admin_resolver 

330 from lexigram.admin.settings.panel.registry import ConfigRegistry 

331 

332 resolver = get_admin_resolver(context) 

333 config_registry = await resolver.resolve( 

334 ConfigRegistry, 

335 ) 

336 node = config_registry.get_node(key) 

337 if node: 

338 setting_type = map_config_node_type(node) 

339 is_sensitive = setting_type == "secret" 

340 except ImportError: 

341 _warn_registry_unavailable() 

342 

343 try: 

344 existing = await self.repo.find_one(scope=scope, scope_id=scope_id, key=key) 

345 

346 if existing: 

347 existing.value = str_value 

348 existing.updated_at = datetime.now(UTC) 

349 existing.updated_by = updated_by 

350 await self.repo.update(existing) 

351 else: 

352 new_setting = SystemSetting( 

353 scope=scope, 

354 scope_id=scope_id, 

355 key=key, 

356 value=str_value, 

357 type=setting_type, 

358 is_sensitive=is_sensitive, 

359 updated_by=updated_by, 

360 ) 

361 await self.repo.create(new_setting) 

362 

363 # 3. Invalidate Cache 

364 if self.cache: 

365 await self.cache.delete(f"settings:resolved:{key}:global") 

366 if scope == "user": 

367 await self.cache.delete(f"settings:resolved:{key}:user:{scope_id}") 

368 

369 # Fire Event 

370 if self.event_bus: 

371 try: 

372 await self.event_bus.publish( 

373 SettingUpdated( 

374 key=key, 

375 value=str_value, 

376 scope=scope, 

377 scope_id=scope_id, 

378 ), 

379 ) 

380 except BaseException: 

381 logger.exception("Failed to publish SettingUpdated event") 

382 

383 except BaseException: 

384 logger.exception("Failed to save setting %s", key) 

385 raise 

386 

387 async def get_all_resolved(self, context: Any | None = None) -> dict[str, Any]: 

388 """Return a dictionary of all resolved settings for the current context.""" 

389 settings = {} 

390 for definition in self.registry.get_all(): 

391 settings[definition.key] = await self.get(definition.key, context) 

392 return settings 

393 

394 async def boot(self, container: ContainerResolverProtocol | None = None) -> None: 

395 """Initialize settings resources and register as Config Store.""" 

396 # Register as a store for ConfigRegistry 

397 from lexigram.admin.lib.di import get_admin_resolver 

398 from lexigram.admin.settings.panel.registry import ConfigRegistry, StoreBase 

399 

400 resolver = get_admin_resolver(container) 

401 registry = await resolver.resolve(ConfigRegistry) 

402 

403 class SettingsServiceStore(StoreBase): 

404 def __init__(self, service: SettingsService): 

405 self.service = service 

406 

407 async def get(self, key: str, default: Any = None) -> Any: 

408 val = await self.service.get(key) 

409 return val if val is not None else default 

410 

411 async def set(self, key: str, value: Any) -> None: 

412 await self.service.set(key, value) 

413 

414 # Register store named 'app' (matches ConfigSpec category 'app' if we map it) 

415 # But ConfigRegistry uses store_name="default" by default. 

416 # We should register it as 'db' or override 'default'? 

417 # For now, let's register as 'default' to take over persistence globally 

418 # (since MemoryStore is useless in production) 

419 registry.register_store("default", SettingsServiceStore(self)) 

420 

421 logger.info("✓ SettingsService registered as default ConfigRegistry store")