reports.py 8.1 KB

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