reports.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. import inspect
  2. import logging
  3. import pkgutil
  4. import traceback
  5. from datetime import timedelta
  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. job_result.start()
  64. report.run(job_result)
  65. except Exception:
  66. job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
  67. job_result.save()
  68. logging.error(f"Error during execution of report {job_result.name}")
  69. finally:
  70. # Schedule the next job if an interval has been set
  71. start_time = job_result.scheduled or job_result.started
  72. if start_time and job_result.interval:
  73. new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
  74. JobResult.enqueue_job(
  75. run_report,
  76. name=job_result.name,
  77. obj_type=job_result.obj_type,
  78. user=job_result.user,
  79. job_timeout=report.job_timeout,
  80. schedule_at=new_scheduled_time,
  81. interval=job_result.interval
  82. )
  83. class Report(object):
  84. """
  85. NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
  86. report must have one or more test methods named `test_*`.
  87. The `_results` attribute of a completed report will take the following form:
  88. {
  89. 'test_bar': {
  90. 'failures': 42,
  91. 'log': [
  92. (<datetime>, <level>, <object>, <message>),
  93. ...
  94. ]
  95. },
  96. 'test_foo': {
  97. 'failures': 0,
  98. 'log': [
  99. (<datetime>, <level>, <object>, <message>),
  100. ...
  101. ]
  102. }
  103. }
  104. """
  105. description = None
  106. job_timeout = None
  107. def __init__(self):
  108. self._results = {}
  109. self.active_test = None
  110. self.failed = False
  111. self.logger = logging.getLogger(f"netbox.reports.{self.full_name}")
  112. # Compile test methods and initialize results skeleton
  113. test_methods = []
  114. for method in dir(self):
  115. if method.startswith('test_') and callable(getattr(self, method)):
  116. test_methods.append(method)
  117. self._results[method] = {
  118. 'success': 0,
  119. 'info': 0,
  120. 'warning': 0,
  121. 'failure': 0,
  122. 'log': [],
  123. }
  124. if not test_methods:
  125. raise Exception("A report must contain at least one test method.")
  126. self.test_methods = test_methods
  127. @property
  128. def module(self):
  129. return self.__module__
  130. @property
  131. def class_name(self):
  132. return self.__class__.__name__
  133. @property
  134. def name(self):
  135. """
  136. Override this attribute to set a custom display name.
  137. """
  138. return self.class_name
  139. @property
  140. def full_name(self):
  141. return f'{self.module}.{self.class_name}'
  142. def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
  143. """
  144. Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
  145. """
  146. if level not in LogLevelChoices.values():
  147. raise Exception(f"Unknown logging level: {level}")
  148. self._results[self.active_test]['log'].append((
  149. timezone.now().isoformat(),
  150. level,
  151. str(obj) if obj else None,
  152. obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
  153. message,
  154. ))
  155. def log(self, message):
  156. """
  157. Log a message which is not associated with a particular object.
  158. """
  159. self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
  160. self.logger.info(message)
  161. def log_success(self, obj, message=None):
  162. """
  163. Record a successful test against an object. Logging a message is optional.
  164. """
  165. if message:
  166. self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
  167. self._results[self.active_test]['success'] += 1
  168. self.logger.info(f"Success | {obj}: {message}")
  169. def log_info(self, obj, message):
  170. """
  171. Log an informational message.
  172. """
  173. self._log(obj, message, level=LogLevelChoices.LOG_INFO)
  174. self._results[self.active_test]['info'] += 1
  175. self.logger.info(f"Info | {obj}: {message}")
  176. def log_warning(self, obj, message):
  177. """
  178. Log a warning.
  179. """
  180. self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
  181. self._results[self.active_test]['warning'] += 1
  182. self.logger.info(f"Warning | {obj}: {message}")
  183. def log_failure(self, obj, message):
  184. """
  185. Log a failure. Calling this method will automatically mark the report as failed.
  186. """
  187. self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
  188. self._results[self.active_test]['failure'] += 1
  189. self.logger.info(f"Failure | {obj}: {message}")
  190. self.failed = True
  191. def run(self, job_result):
  192. """
  193. Run the report and save its results. Each test method will be executed in order.
  194. """
  195. self.logger.info(f"Running report")
  196. job_result.status = JobResultStatusChoices.STATUS_RUNNING
  197. job_result.save()
  198. # Perform any post-run tasks
  199. self.pre_run()
  200. try:
  201. for method_name in self.test_methods:
  202. self.active_test = method_name
  203. test_method = getattr(self, method_name)
  204. test_method()
  205. if self.failed:
  206. self.logger.warning("Report failed")
  207. job_result.status = JobResultStatusChoices.STATUS_FAILED
  208. else:
  209. self.logger.info("Report completed successfully")
  210. job_result.status = JobResultStatusChoices.STATUS_COMPLETED
  211. except Exception as e:
  212. stacktrace = traceback.format_exc()
  213. self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
  214. logger.error(f"Exception raised during report execution: {e}")
  215. job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
  216. job_result.data = self._results
  217. job_result.completed = timezone.now()
  218. job_result.save()
  219. # Perform any post-run tasks
  220. self.post_run()
  221. def pre_run(self):
  222. """
  223. Extend this method to include any tasks which should execute *before* the report is run.
  224. """
  225. pass
  226. def post_run(self):
  227. """
  228. Extend this method to include any tasks which should execute *after* the report is run.
  229. """
  230. pass