Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/problems.py: 100%

110 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-19 13:14 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2025 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Problem Checks + Handler 

25""" 

26 

27import calendar 

28import datetime 

29import importlib 

30import logging 

31 

32from wuttjamaican.app import GenericHandler 

33 

34 

35log = logging.getLogger(__name__) 

36 

37 

38class ProblemCheck: 

39 """ 

40 Base class for :term:`problem checks <problem check>`. 

41 

42 Each subclass must define logic for discovery of problems, 

43 according to its purpose; see :meth:`find_problems()`. 

44 

45 If the check does find problems, and an email is to be sent, the 

46 check instance is also able to affect that email somewhat, e.g. by 

47 adding an attachment. See :meth:`get_email_context()` and 

48 :meth:`make_email_attachments()`. 

49 

50 :param config: App :term:`config object`. 

51 """ 

52 

53 def __init__(self, config): 

54 self.config = config 

55 self.app = self.config.get_app() 

56 

57 @property 

58 def system_key(self): 

59 """ 

60 Key identifying which "system" the check pertains to. 

61 

62 Many apps may only have one "system" which corresponds to the 

63 app itself. However some apps may integrate with other 

64 systems and have ability/need to check for problems on those 

65 systems as well. 

66 

67 See also :attr:`problem_key` and :attr:`title`. 

68 """ 

69 raise AttributeError(f"system_key not defined for {self.__class__}") 

70 

71 @property 

72 def problem_key(self): 

73 """ 

74 Key identifying this problem check. 

75 

76 This key must be unique within the context of the "system" it 

77 pertains to. 

78 

79 See also :attr:`system_key` and :attr:`title`. 

80 """ 

81 raise AttributeError(f"problem_key not defined for {self.__class__}") 

82 

83 @property 

84 def title(self): 

85 """ 

86 Display title for the problem check. 

87 

88 See also :attr:`system_key` and :attr:`problem_key`. 

89 """ 

90 raise AttributeError(f"title not defined for {self.__class__}") 

91 

92 def find_problems(self): 

93 """ 

94 Find all problems relevant to this check. 

95 

96 This should always return a list, although no constraint is 

97 made on what type of elements it contains. 

98 

99 :returns: List of problems found. 

100 """ 

101 return [] 

102 

103 def get_email_context(self, problems, **kwargs): # pylint: disable=unused-argument 

104 """ 

105 This can be used to add extra context for a specific check's 

106 report email template. 

107 

108 :param problems: List of problems found. 

109 

110 :returns: Context dict for email template. 

111 """ 

112 return kwargs 

113 

114 def make_email_attachments(self, context): 

115 """ 

116 Optionally generate some attachment(s) for the report email. 

117 

118 :param context: Context dict for the report email. In 

119 particular see ``context['problems']`` for main data. 

120 

121 :returns: List of attachments, if applicable. 

122 """ 

123 

124 

125class ProblemHandler(GenericHandler): 

126 """ 

127 Base class and default implementation for the :term:`problem 

128 handler`. 

129 

130 There is normally no need to instantiate this yourself; instead 

131 call :meth:`~wuttjamaican.app.AppHandler.get_problem_handler()` on 

132 the :term:`app handler`. 

133 

134 The problem handler can be used to discover and run :term:`problem 

135 checks <problem check>`. In particular see: 

136 

137 * :meth:`get_all_problem_checks()` 

138 * :meth:`filter_problem_checks()` 

139 * :meth:`run_problem_checks()` 

140 """ 

141 

142 def get_all_problem_checks(self): 

143 """ 

144 Return a list of all :term:`problem checks <problem check>` 

145 which are "available" according to config. 

146 

147 See also :meth:`filter_problem_checks()`. 

148 

149 :returns: List of :class:`ProblemCheck` classes. 

150 """ 

151 checks = [] 

152 modules = self.config.get_list( 

153 f"{self.config.appname}.problems.modules", default=["wuttjamaican.problems"] 

154 ) 

155 for module_path in modules: 

156 module = importlib.import_module(module_path) 

157 for name in dir(module): 

158 obj = getattr(module, name) 

159 if ( 

160 isinstance(obj, type) 

161 and issubclass(obj, ProblemCheck) 

162 and obj is not ProblemCheck 

163 ): 

164 checks.append(obj) 

165 return checks 

166 

167 def filter_problem_checks(self, systems=None, problems=None): 

168 """ 

169 Return a list of all :term:`problem checks <problem check>` 

170 which match the given criteria. 

171 

172 This first calls :meth:`get_all_problem_checks()` and then 

173 filters the result according to params. 

174 

175 :param systems: Optional list of "system keys" which a problem check 

176 must match, in order to be included in return value. 

177 

178 :param problems: Optional list of "problem keys" which a problem check 

179 must match, in order to be included in return value. 

180 

181 :returns: List of :class:`ProblemCheck` classes; may be an 

182 empty list. 

183 """ 

184 all_checks = self.get_all_problem_checks() 

185 if not (systems or problems): 

186 return all_checks 

187 

188 matches = [] 

189 for check in all_checks: 

190 if not systems or check.system_key in systems: 

191 if not problems or check.problem_key in problems: 

192 matches.append(check) 

193 return matches 

194 

195 def get_supported_systems(self, checks=None): 

196 """ 

197 Returns list of keys for all systems which are supported by 

198 any of the problem checks. 

199 

200 :param checks: Optional list of :class:`ProblemCheck` classes. 

201 If not specified, calls :meth:`get_all_problem_checks()`. 

202 

203 :returns: List of system keys. 

204 """ 

205 checks = self.get_all_problem_checks() 

206 return sorted({check.system_key for check in checks}) 

207 

208 def get_system_title(self, system_key): 

209 """ 

210 Returns the display title for a given system. 

211 

212 The default logic returns the ``system_key`` as-is; subclass 

213 may override as needed. 

214 

215 :param system_key: Key identifying a checked system. 

216 

217 :returns: Display title for the system. 

218 """ 

219 return system_key 

220 

221 def is_enabled(self, check): 

222 """ 

223 Returns boolean indicating if the given problem check is 

224 enabled, per config. 

225 

226 :param check: :class:`ProblemCheck` class or instance. 

227 

228 :returns: ``True`` if enabled; ``False`` if not. 

229 """ 

230 key = f"{check.system_key}.{check.problem_key}" 

231 enabled = self.config.get_bool(f"{self.config.appname}.problems.{key}.enabled") 

232 if enabled is not None: 

233 return enabled 

234 return True 

235 

236 def should_run_for_weekday(self, check, weekday): 

237 """ 

238 Returns boolean indicating if the given problem check is 

239 configured to run for the given weekday. 

240 

241 :param check: :class:`ProblemCheck` class or instance. 

242 

243 :param weekday: Integer corresponding to a particular weekday. 

244 Uses the same conventions as Python itself, i.e. Monday is 

245 represented as 0 and Sunday as 6. 

246 

247 :returns: ``True`` if check should run; ``False`` if not. 

248 """ 

249 key = f"{check.system_key}.{check.problem_key}" 

250 enabled = self.config.get_bool( 

251 f"{self.config.appname}.problems.{key}.day{weekday}" 

252 ) 

253 if enabled is not None: 

254 return enabled 

255 return True 

256 

257 def organize_problem_checks(self, checks): 

258 """ 

259 Organize the problem checks by grouping them according to 

260 their :attr:`~ProblemCheck.system_key`. 

261 

262 :param checks: List of :class:`ProblemCheck` classes. 

263 

264 :returns: Dict with "system" keys; each value is a list of 

265 problem checks pertaining to that system. 

266 """ 

267 organized = {} 

268 

269 for check in checks: 

270 system = organized.setdefault(check.system_key, {}) 

271 system[check.problem_key] = check 

272 

273 return organized 

274 

275 def run_problem_checks(self, checks, force=False): 

276 """ 

277 Run the given problem checks. 

278 

279 This calls :meth:`run_problem_check()` for each, so config is 

280 consulted to determine if each check should actually run - 

281 unless ``force=True``. 

282 

283 :param checks: List of :class:`ProblemCheck` classes. 

284 

285 :param force: If true, run the checks regardless of whether 

286 each is configured to run. 

287 """ 

288 organized = self.organize_problem_checks(checks) 

289 for system_key in sorted(organized): 

290 system = organized[system_key] 

291 for problem_key in sorted(system): 

292 check = system[problem_key] 

293 self.run_problem_check(check, force=force) 

294 

295 def run_problem_check(self, check, force=False): 

296 """ 

297 Run the given problem check, if it is enabled and configured 

298 to run for the current weekday. 

299 

300 Running a check involves calling :meth:`find_problems()` and 

301 possibly :meth:`send_problem_report()`. 

302 

303 See also :meth:`run_problem_checks()`. 

304 

305 :param check: :class:`ProblemCheck` class. 

306 

307 :param force: If true, run the check regardless of whether it 

308 is configured to run. 

309 """ 

310 key = f"{check.system_key}.{check.problem_key}" 

311 log.info("running problem check: %s", key) 

312 

313 if not self.is_enabled(check): 

314 log.debug("problem check is not enabled: %s", key) 

315 if not force: 

316 return None 

317 

318 weekday = datetime.date.today().weekday() 

319 if not self.should_run_for_weekday(check, weekday): 

320 log.debug( 

321 "problem check is not scheduled for %s: %s", 

322 calendar.day_name[weekday], 

323 key, 

324 ) 

325 if not force: 

326 return None 

327 

328 check_instance = check(self.config) 

329 problems = self.find_problems(check_instance) 

330 log.info("found %s problems", len(problems)) 

331 if problems: 

332 self.send_problem_report(check_instance, problems) 

333 return problems 

334 

335 def find_problems(self, check): 

336 """ 

337 Execute the given check to find relevant problems. 

338 

339 This mostly calls :meth:`ProblemCheck.find_problems()` 

340 although subclass may override if needed. 

341 

342 This should always return a list, although no constraint is 

343 made on what type of elements it contains. 

344 

345 :param check: :class:`ProblemCheck` instance. 

346 

347 :returns: List of problems found. 

348 """ 

349 return check.find_problems() or [] 

350 

351 def get_email_key(self, check): 

352 """ 

353 Return the "email key" to be used when sending report email 

354 resulting from the given problem check. 

355 

356 This follows a convention using the check's 

357 :attr:`~ProblemCheck.system_key` and 

358 :attr:`~ProblemCheck.problem_key`. 

359 

360 This is called by :meth:`send_problem_report()`. 

361 

362 :param check: :class:`ProblemCheck` class or instance. 

363 

364 :returns: Config key for problem report email message. 

365 """ 

366 return f"{check.system_key}_problems_{check.problem_key}" 

367 

368 def send_problem_report(self, check, problems): 

369 """ 

370 Send an email with details of the given problem check report. 

371 

372 This calls :meth:`get_email_key()` to determine which key to 

373 use for sending email. 

374 

375 It also calls :meth:`get_global_email_context()` and 

376 :meth:`get_check_email_context()` to build the email template 

377 context. 

378 

379 And it calls :meth:`ProblemCheck.make_email_attachments()` to 

380 allow the check to provide message attachments. 

381 

382 :param check: :class:`ProblemCheck` instance. 

383 

384 :param problems: List of problems found. 

385 """ 

386 context = self.get_global_email_context() 

387 context = self.get_check_email_context(check, problems, **context) 

388 context.update( 

389 { 

390 "config": self.config, 

391 "app": self.app, 

392 "check": check, 

393 "problems": problems, 

394 } 

395 ) 

396 

397 email_key = self.get_email_key(check) 

398 attachments = check.make_email_attachments(context) 

399 self.app.send_email( 

400 email_key, context, default_subject=check.title, attachments=attachments 

401 ) 

402 

403 def get_global_email_context(self, **kwargs): 

404 """ 

405 This can be used to add extra context for all email report 

406 templates, regardless of which problem check is involved. 

407 

408 :returns: Context dict for all email templates. 

409 """ 

410 return kwargs 

411 

412 def get_check_email_context(self, check, problems, **kwargs): 

413 """ 

414 This can be used to add extra context for a specific check's 

415 report email template. 

416 

417 Note that this calls :meth:`ProblemCheck.get_email_context()` 

418 and in many cases that is where customizations should live. 

419 

420 :param check: :class:`ProblemCheck` instance. 

421 

422 :param problems: List of problems found. 

423 

424 :returns: Context dict for email template. 

425 """ 

426 kwargs["system_title"] = self.get_system_title(check.system_key) 

427 kwargs = check.get_email_context(problems, **kwargs) 

428 return kwargs