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
« 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"""
27import calendar
28import datetime
29import importlib
30import logging
32from wuttjamaican.app import GenericHandler
35log = logging.getLogger(__name__)
38class ProblemCheck:
39 """
40 Base class for :term:`problem checks <problem check>`.
42 Each subclass must define logic for discovery of problems,
43 according to its purpose; see :meth:`find_problems()`.
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()`.
50 :param config: App :term:`config object`.
51 """
53 def __init__(self, config):
54 self.config = config
55 self.app = self.config.get_app()
57 @property
58 def system_key(self):
59 """
60 Key identifying which "system" the check pertains to.
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.
67 See also :attr:`problem_key` and :attr:`title`.
68 """
69 raise AttributeError(f"system_key not defined for {self.__class__}")
71 @property
72 def problem_key(self):
73 """
74 Key identifying this problem check.
76 This key must be unique within the context of the "system" it
77 pertains to.
79 See also :attr:`system_key` and :attr:`title`.
80 """
81 raise AttributeError(f"problem_key not defined for {self.__class__}")
83 @property
84 def title(self):
85 """
86 Display title for the problem check.
88 See also :attr:`system_key` and :attr:`problem_key`.
89 """
90 raise AttributeError(f"title not defined for {self.__class__}")
92 def find_problems(self):
93 """
94 Find all problems relevant to this check.
96 This should always return a list, although no constraint is
97 made on what type of elements it contains.
99 :returns: List of problems found.
100 """
101 return []
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.
108 :param problems: List of problems found.
110 :returns: Context dict for email template.
111 """
112 return kwargs
114 def make_email_attachments(self, context):
115 """
116 Optionally generate some attachment(s) for the report email.
118 :param context: Context dict for the report email. In
119 particular see ``context['problems']`` for main data.
121 :returns: List of attachments, if applicable.
122 """
125class ProblemHandler(GenericHandler):
126 """
127 Base class and default implementation for the :term:`problem
128 handler`.
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`.
134 The problem handler can be used to discover and run :term:`problem
135 checks <problem check>`. In particular see:
137 * :meth:`get_all_problem_checks()`
138 * :meth:`filter_problem_checks()`
139 * :meth:`run_problem_checks()`
140 """
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.
147 See also :meth:`filter_problem_checks()`.
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
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.
172 This first calls :meth:`get_all_problem_checks()` and then
173 filters the result according to params.
175 :param systems: Optional list of "system keys" which a problem check
176 must match, in order to be included in return value.
178 :param problems: Optional list of "problem keys" which a problem check
179 must match, in order to be included in return value.
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
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
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.
200 :param checks: Optional list of :class:`ProblemCheck` classes.
201 If not specified, calls :meth:`get_all_problem_checks()`.
203 :returns: List of system keys.
204 """
205 checks = self.get_all_problem_checks()
206 return sorted({check.system_key for check in checks})
208 def get_system_title(self, system_key):
209 """
210 Returns the display title for a given system.
212 The default logic returns the ``system_key`` as-is; subclass
213 may override as needed.
215 :param system_key: Key identifying a checked system.
217 :returns: Display title for the system.
218 """
219 return system_key
221 def is_enabled(self, check):
222 """
223 Returns boolean indicating if the given problem check is
224 enabled, per config.
226 :param check: :class:`ProblemCheck` class or instance.
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
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.
241 :param check: :class:`ProblemCheck` class or instance.
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.
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
257 def organize_problem_checks(self, checks):
258 """
259 Organize the problem checks by grouping them according to
260 their :attr:`~ProblemCheck.system_key`.
262 :param checks: List of :class:`ProblemCheck` classes.
264 :returns: Dict with "system" keys; each value is a list of
265 problem checks pertaining to that system.
266 """
267 organized = {}
269 for check in checks:
270 system = organized.setdefault(check.system_key, {})
271 system[check.problem_key] = check
273 return organized
275 def run_problem_checks(self, checks, force=False):
276 """
277 Run the given problem checks.
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``.
283 :param checks: List of :class:`ProblemCheck` classes.
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)
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.
300 Running a check involves calling :meth:`find_problems()` and
301 possibly :meth:`send_problem_report()`.
303 See also :meth:`run_problem_checks()`.
305 :param check: :class:`ProblemCheck` class.
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)
313 if not self.is_enabled(check):
314 log.debug("problem check is not enabled: %s", key)
315 if not force:
316 return None
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
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
335 def find_problems(self, check):
336 """
337 Execute the given check to find relevant problems.
339 This mostly calls :meth:`ProblemCheck.find_problems()`
340 although subclass may override if needed.
342 This should always return a list, although no constraint is
343 made on what type of elements it contains.
345 :param check: :class:`ProblemCheck` instance.
347 :returns: List of problems found.
348 """
349 return check.find_problems() or []
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.
356 This follows a convention using the check's
357 :attr:`~ProblemCheck.system_key` and
358 :attr:`~ProblemCheck.problem_key`.
360 This is called by :meth:`send_problem_report()`.
362 :param check: :class:`ProblemCheck` class or instance.
364 :returns: Config key for problem report email message.
365 """
366 return f"{check.system_key}_problems_{check.problem_key}"
368 def send_problem_report(self, check, problems):
369 """
370 Send an email with details of the given problem check report.
372 This calls :meth:`get_email_key()` to determine which key to
373 use for sending email.
375 It also calls :meth:`get_global_email_context()` and
376 :meth:`get_check_email_context()` to build the email template
377 context.
379 And it calls :meth:`ProblemCheck.make_email_attachments()` to
380 allow the check to provide message attachments.
382 :param check: :class:`ProblemCheck` instance.
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 )
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 )
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.
408 :returns: Context dict for all email templates.
409 """
410 return kwargs
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.
417 Note that this calls :meth:`ProblemCheck.get_email_context()`
418 and in many cases that is where customizations should live.
420 :param check: :class:`ProblemCheck` instance.
422 :param problems: List of problems found.
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