reports.py 8.0 KB

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