Coverage for .tox/coverage/lib/python3.13/site-packages/wuttjamaican/problems.py: 100%
168 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-02 20:08 -0500
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-02 20:08 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2026 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
31import re
33from wuttjamaican.app import GenericHandler
34from wuttjamaican.email import EmailSetting
36log = logging.getLogger(__name__)
39class ProblemCheck:
40 """
41 Base class for :term:`problem checks <problem check>`.
43 Each subclass must define logic for discovery of problems,
44 according to its purpose; see :meth:`find_problems()`.
46 If the check does find problems, and an email is to be sent, the
47 check instance is also able to affect that email somewhat, e.g. by
48 adding an attachment. See :meth:`get_email_context()` and
49 :meth:`make_email_attachments()`.
51 :param config: App :term:`config object`.
52 """
54 def __init__(self, config):
55 self.config = config
56 self.app = self.config.get_app()
58 @property
59 def system_key(self):
60 """
61 Key identifying which "system" the check pertains to.
63 Many apps may only have one "system" which corresponds to the
64 app itself. However some apps may integrate with other
65 systems and have ability/need to check for problems on those
66 systems as well.
68 See also :attr:`problem_key` and :attr:`title`.
69 """
70 raise AttributeError(f"system_key not defined for {self.__class__}")
72 @property
73 def problem_key(self):
74 """
75 Key identifying this problem check.
77 This key must be unique within the context of the "system" it
78 pertains to.
80 See also :attr:`system_key` and :attr:`title`.
81 """
82 raise AttributeError(f"problem_key not defined for {self.__class__}")
84 @property
85 def title(self):
86 """
87 Display title for the problem check.
89 See also :attr:`system_key` and :attr:`problem_key`.
90 """
91 raise AttributeError(f"title not defined for {self.__class__}")
93 def find_problems(self):
94 """
95 Find all problems relevant to this check.
97 This should always return a list, although no constraint is
98 made on what type of elements it contains.
100 :returns: List of problems found.
101 """
102 return []
104 def get_email_context(self, problems, **kwargs): # pylint: disable=unused-argument
105 """
106 This can be used to add extra context for a specific check's
107 report email template.
109 :param problems: List of problems found.
111 :returns: Context dict for email template.
112 """
113 return kwargs
115 def make_email_attachments(self, context):
116 """
117 Optionally generate some attachment(s) for the report email.
119 :param context: Context dict for the report email. In
120 particular see ``context['problems']`` for main data.
122 :returns: List of attachments, if applicable.
123 """
126class ProblemHandler(GenericHandler):
127 """
128 Base class and default implementation for the :term:`problem
129 handler`.
131 There is normally no need to instantiate this yourself; instead
132 call :meth:`~wuttjamaican.app.AppHandler.get_problem_handler()` on
133 the :term:`app handler`.
135 The problem handler can be used to discover and run :term:`problem
136 checks <problem check>`. In particular see:
138 * :meth:`get_all_problem_checks()`
139 * :meth:`filter_problem_checks()`
140 * :meth:`run_problem_checks()`
141 """
143 def get_all_problem_checks(self):
144 """
145 Return a list of all :term:`problem checks <problem check>`
146 which are "available" according to config.
148 See also :meth:`filter_problem_checks()`.
150 :returns: List of :class:`ProblemCheck` classes.
151 """
152 checks = []
153 modules = self.config.get_list(
154 f"{self.config.appname}.problems.modules", default=["wuttjamaican.problems"]
155 )
156 for module_path in modules:
157 module = importlib.import_module(module_path)
158 for name in dir(module):
159 obj = getattr(module, name)
160 if (
161 isinstance(obj, type)
162 and issubclass(obj, ProblemCheck)
163 and obj is not ProblemCheck
164 ):
165 checks.append(obj)
166 return checks
168 def get_problem_check(self, system_key, problem_key):
169 """
170 Retrieve a particular problem check. Note that this returns
171 the *class*, not the instance.
173 :param system_key: The :attr:`~ProblemCheck.system_key` for
174 the desired problem check.
176 :param problem_key: The :attr:`~ProblemCheck.problem_key` for
177 the desired problem check.
179 :returns: :class:`ProblemCheck` subclass, or ``None``
180 """
181 checks = self.filter_problem_checks(
182 systems=[system_key], problems=[problem_key]
183 )
184 if len(checks) > 1:
185 log.warning(
186 "found %s checks matching system/problem key: %s/%s",
187 len(checks),
188 system_key,
189 problem_key,
190 )
191 return checks[0] if checks else None
193 def filter_problem_checks(self, systems=None, problems=None):
194 """
195 Return a list of all :term:`problem checks <problem check>`
196 which match the given criteria.
198 This first calls :meth:`get_all_problem_checks()` and then
199 filters the result according to params.
201 :param systems: Optional list of "system keys" which a problem check
202 must match, in order to be included in return value.
204 :param problems: Optional list of "problem keys" which a problem check
205 must match, in order to be included in return value.
207 :returns: List of :class:`ProblemCheck` classes; may be an
208 empty list.
209 """
210 all_checks = self.get_all_problem_checks()
211 if not (systems or problems):
212 return all_checks
214 matches = []
215 for check in all_checks:
216 if not systems or check.system_key in systems:
217 if not problems or check.problem_key in problems:
218 matches.append(check)
219 return matches
221 def get_supported_systems(self, checks=None):
222 """
223 Returns list of keys for all systems which are supported by
224 any of the problem checks.
226 :param checks: Optional list of :class:`ProblemCheck` classes.
227 If not specified, calls :meth:`get_all_problem_checks()`.
229 :returns: List of system keys.
230 """
231 checks = self.get_all_problem_checks()
232 return sorted({check.system_key for check in checks})
234 def get_system_title(self, system_key):
235 """
236 Returns the display title for a given system.
238 The default logic returns the ``system_key`` as-is; subclass
239 may override as needed.
241 :param system_key: Key identifying a checked system.
243 :returns: Display title for the system.
244 """
245 return system_key
247 def is_enabled(self, check):
248 """
249 Returns boolean indicating if the given problem check is
250 enabled, per config.
252 :param check: :class:`ProblemCheck` class or instance.
254 :returns: ``True`` if enabled; ``False`` if not.
255 """
256 key = f"{check.system_key}.{check.problem_key}"
257 enabled = self.config.get_bool(f"{self.config.appname}.problems.{key}.enabled")
258 if enabled is not None:
259 return enabled
260 return True
262 def should_run_for_weekday(self, check, weekday):
263 """
264 Returns boolean indicating if the given problem check is
265 configured to run for the given weekday.
267 :param check: :class:`ProblemCheck` class or instance.
269 :param weekday: Integer corresponding to a particular weekday.
270 Uses the same conventions as Python itself, i.e. Monday is
271 represented as 0 and Sunday as 6.
273 :returns: ``True`` if check should run; ``False`` if not.
274 """
275 key = f"{check.system_key}.{check.problem_key}"
276 enabled = self.config.get_bool(
277 f"{self.config.appname}.problems.{key}.day{weekday}"
278 )
279 if enabled is not None:
280 return enabled
281 return True
283 def organize_problem_checks(self, checks):
284 """
285 Organize the problem checks by grouping them according to
286 their :attr:`~ProblemCheck.system_key`.
288 :param checks: List of :class:`ProblemCheck` classes.
290 :returns: Dict with "system" keys; each value is a list of
291 problem checks pertaining to that system.
292 """
293 organized = {}
295 for check in checks:
296 system = organized.setdefault(check.system_key, {})
297 system[check.problem_key] = check
299 return organized
301 def run_problem_checks(self, checks, force=False):
302 """
303 Run the given problem checks.
305 This calls :meth:`run_problem_check()` for each, so config is
306 consulted to determine if each check should actually run -
307 unless ``force=True``.
309 :param checks: List of :class:`ProblemCheck` classes.
311 :param force: If true, run the checks regardless of whether
312 each is configured to run.
313 """
314 organized = self.organize_problem_checks(checks)
315 for system_key in sorted(organized):
316 system = organized[system_key]
317 for problem_key in sorted(system):
318 check = system[problem_key]
319 self.run_problem_check(check, force=force)
321 def run_problem_check(self, check, force=False):
322 """
323 Run the given problem check, if it is enabled and configured
324 to run for the current weekday.
326 Running a check involves calling :meth:`find_problems()` and
327 possibly :meth:`send_problem_report()`. This method will also
328 update the "last run time" (saved setting) for the check.
330 See also :meth:`run_problem_checks()`.
332 :param check: :class:`ProblemCheck` class.
334 :param force: If true, run the check regardless of whether it
335 is configured to run.
337 :returns: List of problems as obtained from
338 :meth:`find_problems()`, or ``None`` if the check was not
339 configured to run.
340 """
341 key = f"{check.system_key}.{check.problem_key}"
342 log.info("running problem check: %s", key)
344 if not self.is_enabled(check):
345 log.debug("problem check is not enabled: %s", key)
346 if not force:
347 return None
349 weekday = datetime.date.today().weekday()
350 if not self.should_run_for_weekday(check, weekday):
351 log.debug(
352 "problem check is not scheduled for %s: %s",
353 calendar.day_name[weekday],
354 key,
355 )
356 if not force:
357 return None
359 check_instance = check(self.config)
360 problems = self.find_problems(check_instance)
361 log.info("found %s problems", len(problems))
362 if problems:
363 self.send_problem_report(check_instance, problems)
365 # save 'lastrun' setting if sqlalchemy is configured
366 try:
367 session = self.app.make_session()
368 except ModuleNotFoundError:
369 pass
370 else:
371 if session.bind:
372 now = self.app.make_utc().isoformat()
373 self.app.save_setting(
374 session, f"{self.config.appname}.problems.{key}.lastrun", now
375 )
376 session.commit()
377 session.close()
379 return problems
381 def find_problems(self, check):
382 """
383 Execute the given check to find relevant problems.
385 This mostly calls :meth:`ProblemCheck.find_problems()`
386 although subclass may override if needed.
388 This should always return a list, although no constraint is
389 made on what type of elements it contains.
391 :param check: :class:`ProblemCheck` instance.
393 :returns: List of problems found.
394 """
395 return check.find_problems() or []
397 def get_email_key(self, check):
398 """
399 Return the "email key" to be used when sending report email
400 resulting from the given problem check.
402 This follows a convention using the check's
403 :attr:`~ProblemCheck.system_key` and
404 :attr:`~ProblemCheck.problem_key`.
406 This is called by :meth:`send_problem_report()`.
408 :param check: :class:`ProblemCheck` class or instance.
410 :returns: Config key for problem report email message.
411 """
412 return f"{check.system_key}_problems_{check.problem_key}"
414 def send_problem_report(self, check, problems):
415 """
416 Send an email with details of the given problem check report.
418 This calls :meth:`get_email_key()` to determine which key to
419 use for sending email.
421 It also calls :meth:`get_full_email_context()` to build the
422 email template context.
424 And it calls :meth:`ProblemCheck.make_email_attachments()` to
425 allow the check to provide message attachments.
427 :param check: :class:`ProblemCheck` instance.
429 :param problems: List of problems found.
430 """
431 email_key = self.get_email_key(check)
432 context = self.get_full_email_context(check, problems)
433 attachments = check.make_email_attachments(context)
434 self.app.send_email(
435 email_key, context, default_subject=check.title, attachments=attachments
436 )
438 def get_full_email_context(self, check, problems):
439 """
440 This method collects *all* context for the problem report
441 email template. It will call:
443 * :meth:`get_global_email_context()`
444 * :meth:`get_check_email_context()`
446 :param check: :class:`ProblemCheck` instance.
448 :param problems: List of problems found.
450 :returns: Context dict for the email template.
451 """
452 context = self.get_global_email_context()
453 context = self.get_check_email_context(check, problems, **context)
454 context.update(
455 {
456 "config": self.config,
457 "app": self.app,
458 "check": check,
459 "problems": problems,
460 }
461 )
462 return context
464 def get_global_email_context(self, **kwargs):
465 """
466 This can be used to add extra context for all email report
467 templates, regardless of which problem check is involved.
469 :returns: Context dict for all email templates.
470 """
471 return kwargs
473 def get_check_email_context(self, check, problems, **kwargs):
474 """
475 This can be used to add extra context for a specific check's
476 report email template.
478 Note that this calls :meth:`ProblemCheck.get_email_context()`
479 and in many cases that is where customizations should live.
481 :param check: :class:`ProblemCheck` instance.
483 :param problems: List of problems found.
485 :returns: Context dict for email template.
486 """
487 kwargs["system_title"] = self.get_system_title(check.system_key)
488 kwargs = check.get_email_context(problems, **kwargs)
489 return kwargs
492class ProblemEmailSetting(EmailSetting):
493 """
494 Base class for :term:`problem check` report email settings. This
495 inherits from :class:`~wuttjamaican.email.EmailSetting`.
497 Each subclass should declare which problem check it pertains to,
498 in one of 3 ways:
500 * class is named like ``f"{system_key}_problems_{problem_key}"``
502 * :attr:`system_key` and :attr:`problem_key` attributes are set
504 * :attr:`problem_spec` attribute is set
506 See :meth:`get_problem_check()` for more info about that.
508 .. attribute:: system_key
510 This is optional, depending on how you declare the related
511 problem check. If set, it should match the desired
512 :attr:`ProblemCheck.system_key`.
514 .. attribute:: problem_key
516 This is optional, depending on how you declare the related
517 problem check. If set, it should match the desired
518 :attr:`ProblemCheck.problem_key`.
520 .. attribute:: problem_spec
522 This is optional, depending on how you declare the related
523 problem check. If set, it should be the full :term:`spec`
524 string for the desired problem check class.
525 """
527 system_key = None
528 problem_key = None
529 problem_spec = None
531 def get_problem_check(self):
532 """
533 Retrieve the problem check instance related to this email
534 setting, if possible.
536 This email setting's class name and/or attributes will
537 determine how the problem check class is located. (See also
538 notes in the :class:`ProblemEmailSetting` section above.)
540 If :attr:`problem_spec` is set, that will be used to load the
541 specified check class, which is then instantiated.
543 Or, if :attr:`system_key` and :attr:`problem_key` are set,
544 they will be used to load the check, via
545 :meth:`ProblemHandler.get_problem_check()`.
547 But the "default" behavior is to parse the system key and
548 problem key from this email setting's *class name*. For
549 instance::
551 from wuttjamaican.problems import ProblemEmailSetting
553 # email setting whose name conforms to expected pattern
554 # does not need to specify explicit problem check
555 class poser_problems_water_too_wet(ProblemEmailSetting):
556 pass
558 # but if email setting name does not conform, keys for
559 # problem check can be declared, so handler can do lookup
560 class water_too_wet(ProblemEmailSetting):
561 system_key = "poser"
562 problem_key = "water_too_wet"
564 # alternatively, declare full spec for the problem check
565 class water_too_wet_two(ProblemEmailSetting):
566 problem_spec = "poser.problems:WaterTooWet"
568 :returns: :class:`ProblemCheck` instance, or ``None``
569 """
571 # prefer explicit spec, if set
572 if self.problem_spec:
573 return self.app.load_object(self.problem_spec)(self.config)
575 handler = self.app.get_problem_handler()
577 # next try spec lookup, if keys set
578 if self.system_key and self.problem_key:
579 return handler.get_problem_check(self.system_key, self.problem_key)(
580 self.config
581 )
583 # or maybe try spec lookup based on setting class name
584 class_name = self.__class__.__name__
585 if match := re.match(
586 r"^(?P<system>\S+)_problems_(?P<problem>\S+)$", class_name
587 ):
588 if check := handler.get_problem_check(match["system"], match["problem"]):
589 return check(self.config)
591 return None
593 def get_description(self): # pylint: disable=empty-docstring
594 """ """
595 # prefer email setting docstring, if set
596 if docstring := self.__class__.__doc__:
597 if desc := docstring.strip():
598 return desc
600 # then try problem check docstring
601 if check := self.get_problem_check():
602 if docstring := check.__doc__:
603 if desc := docstring.strip():
604 return desc
606 # generic fallback
607 title = check.title if check else "??"
608 return f"Sent when problems are found for check: {title}"
610 def get_default_subject(self): # pylint: disable=empty-docstring
611 """ """
612 # prefer email setting's default subject, if set
613 if self.default_subject:
614 return self.default_subject
616 # otherwise use problem check title
617 if check := self.get_problem_check():
618 return check.title
620 return None
622 def get_sample_data(self): # pylint: disable=empty-docstring
623 """ """
624 if check := self.get_problem_check():
625 problems = self.get_sample_problems()
626 handler = self.app.get_problem_handler()
627 return handler.get_full_email_context(check, problems)
628 return {}
630 def get_sample_problems(self):
631 """
632 To improve the relevance of sample data for the email template
633 preview, subclass can override this method to declare
634 "problems found".
636 See also
637 :meth:`~wuttjamaican.email.EmailSetting.get_sample_data()`.
639 :returns: List of problems found.
640 """
641 return []