reports.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import importlib
  2. import inspect
  3. import logging
  4. import pkgutil
  5. import traceback
  6. from django.conf import settings
  7. from django.utils import timezone
  8. from django_rq import job
  9. from .choices import JobResultStatusChoices, LogLevelChoices
  10. from .models import JobResult
  11. logger = logging.getLogger(__name__)
  12. def is_report(obj):
  13. """
  14. Returns True if the given object is a Report.
  15. """
  16. return obj in Report.__subclasses__()
  17. def get_report(module_name, report_name):
  18. """
  19. Return a specific report from within a module.
  20. """
  21. reports = get_reports()
  22. module = reports.get(module_name)
  23. if module is None:
  24. return None
  25. report = module.get(report_name)
  26. if report is None:
  27. return None
  28. return report
  29. def get_reports():
  30. """
  31. Compile a list of all reports available across all modules in the reports path. Returns a list of tuples:
  32. [
  33. (module_name, (report, report, report, ...)),
  34. (module_name, (report, report, report, ...)),
  35. ...
  36. ]
  37. """
  38. module_list = {}
  39. # Iterate through all modules within the reports path. These are the user-created files in which reports are
  40. # defined.
  41. for importer, module_name, _ in pkgutil.iter_modules([settings.REPORTS_ROOT]):
  42. module = importer.find_module(module_name).load_module(module_name)
  43. report_order = getattr(module, "report_order", ())
  44. ordered_reports = [cls() for cls in report_order if is_report(cls)]
  45. unordered_reports = [cls() for _, cls in inspect.getmembers(module, is_report) if cls not in report_order]
  46. module_reports = {}
  47. for cls in [*ordered_reports, *unordered_reports]:
  48. # For reports in submodules use the full import path w/o the root module as the name
  49. report_name = cls.full_name.split(".", maxsplit=1)[1]
  50. module_reports[report_name] = cls
  51. if module_reports:
  52. module_list[module_name] = module_reports
  53. return module_list
  54. @job('default')
  55. def run_report(job_result, *args, **kwargs):
  56. """
  57. Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance
  58. method for queueing into the background processor.
  59. """
  60. module_name, report_name = job_result.name.split('.', 1)
  61. report = get_report(module_name, report_name)
  62. try:
  63. report.run(job_result)
  64. except Exception as e:
  65. job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
  66. job_result.save()
  67. logging.error(f"Error during execution of report {job_result.name}")
  68. class Report(object):
  69. """
  70. NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
  71. report must have one or more test methods named `test_*`.
  72. The `_results` attribute of a completed report will take the following form:
  73. {
  74. 'test_bar': {
  75. 'failures': 42,
  76. 'log': [
  77. (<datetime>, <level>, <object>, <message>),
  78. ...
  79. ]
  80. },
  81. 'test_foo': {
  82. 'failures': 0,
  83. 'log': [
  84. (<datetime>, <level>, <object>, <message>),
  85. ...
  86. ]
  87. }
  88. }
  89. """
  90. description = None
  91. job_timeout = None
  92. def __init__(self):
  93. self._results = {}
  94. self.active_test = None
  95. self.failed = False
  96. self.logger = logging.getLogger(f"netbox.reports.{self.full_name}")
  97. # Compile test methods and initialize results skeleton
  98. test_methods = []
  99. for method in dir(self):
  100. if method.startswith('test_') and callable(getattr(self, method)):
  101. test_methods.append(method)
  102. self._results[method] = {
  103. 'success': 0,
  104. 'info': 0,
  105. 'warning': 0,
  106. 'failure': 0,
  107. 'log': [],
  108. }
  109. if not test_methods:
  110. raise Exception("A report must contain at least one test method.")
  111. self.test_methods = test_methods
  112. @property
  113. def module(self):
  114. return self.__module__
  115. @property
  116. def class_name(self):
  117. return self.__class__.__name__
  118. @property
  119. def name(self):
  120. """
  121. Override this attribute to set a custom display name.
  122. """
  123. return self.class_name
  124. @property
  125. def full_name(self):
  126. return f'{self.module}.{self.class_name}'
  127. def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
  128. """
  129. Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
  130. """
  131. if level not in LogLevelChoices.values():
  132. raise Exception(f"Unknown logging level: {level}")
  133. self._results[self.active_test]['log'].append((
  134. timezone.now().isoformat(),
  135. level,
  136. str(obj) if obj else None,
  137. obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
  138. message,
  139. ))
  140. def log(self, message):
  141. """
  142. Log a message which is not associated with a particular object.
  143. """
  144. self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
  145. self.logger.info(message)
  146. def log_success(self, obj, message=None):
  147. """
  148. Record a successful test against an object. Logging a message is optional.
  149. """
  150. if message:
  151. self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
  152. self._results[self.active_test]['success'] += 1
  153. self.logger.info(f"Success | {obj}: {message}")
  154. def log_info(self, obj, message):
  155. """
  156. Log an informational message.
  157. """
  158. self._log(obj, message, level=LogLevelChoices.LOG_INFO)
  159. self._results[self.active_test]['info'] += 1
  160. self.logger.info(f"Info | {obj}: {message}")
  161. def log_warning(self, obj, message):
  162. """
  163. Log a warning.
  164. """
  165. self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
  166. self._results[self.active_test]['warning'] += 1
  167. self.logger.info(f"Warning | {obj}: {message}")
  168. def log_failure(self, obj, message):
  169. """
  170. Log a failure. Calling this method will automatically mark the report as failed.
  171. """
  172. self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
  173. self._results[self.active_test]['failure'] += 1
  174. self.logger.info(f"Failure | {obj}: {message}")
  175. self.failed = True
  176. def run(self, job_result):
  177. """
  178. Run the report and save its results. Each test method will be executed in order.
  179. """
  180. self.logger.info(f"Running report")
  181. job_result.status = JobResultStatusChoices.STATUS_RUNNING
  182. job_result.save()
  183. # Perform any post-run tasks
  184. self.pre_run()
  185. try:
  186. for method_name in self.test_methods:
  187. self.active_test = method_name
  188. test_method = getattr(self, method_name)
  189. test_method()
  190. if self.failed:
  191. self.logger.warning("Report failed")
  192. job_result.status = JobResultStatusChoices.STATUS_FAILED
  193. else:
  194. self.logger.info("Report completed successfully")
  195. job_result.status = JobResultStatusChoices.STATUS_COMPLETED
  196. except Exception as e:
  197. stacktrace = traceback.format_exc()
  198. self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
  199. logger.error(f"Exception raised during report execution: {e}")
  200. job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
  201. job_result.data = self._results
  202. job_result.completed = timezone.now()
  203. job_result.save()
  204. # Perform any post-run tasks
  205. self.post_run()
  206. def pre_run(self):
  207. """
  208. Extend this method to include any tasks which should execute *before* the report is run.
  209. """
  210. pass
  211. def post_run(self):
  212. """
  213. Extend this method to include any tasks which should execute *after* the report is run.
  214. """
  215. pass