scripts.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. import inspect
  2. import json
  3. import os
  4. import pkgutil
  5. import time
  6. import traceback
  7. from collections import OrderedDict
  8. import yaml
  9. from django import forms
  10. from django.conf import settings
  11. from django.core.validators import RegexValidator
  12. from django.db import transaction
  13. from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
  14. from mptt.models import MPTTModel
  15. from ipam.formfields import IPFormField
  16. from utilities.exceptions import AbortTransaction
  17. from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator
  18. from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
  19. from .forms import ScriptForm
  20. from .signals import purge_changelog
  21. __all__ = [
  22. 'BaseScript',
  23. 'BooleanVar',
  24. 'ChoiceVar',
  25. 'FileVar',
  26. 'IntegerVar',
  27. 'IPNetworkVar',
  28. 'MultiObjectVar',
  29. 'ObjectVar',
  30. 'Script',
  31. 'StringVar',
  32. 'TextVar',
  33. ]
  34. #
  35. # Script variables
  36. #
  37. class ScriptVariable:
  38. """
  39. Base model for script variables
  40. """
  41. form_field = forms.CharField
  42. def __init__(self, label='', description='', default=None, required=True):
  43. # Default field attributes
  44. self.field_attrs = {
  45. 'help_text': description,
  46. 'required': required
  47. }
  48. if label:
  49. self.field_attrs['label'] = label
  50. if default:
  51. self.field_attrs['initial'] = default
  52. def as_field(self):
  53. """
  54. Render the variable as a Django form field.
  55. """
  56. form_field = self.form_field(**self.field_attrs)
  57. if not isinstance(form_field.widget, forms.CheckboxInput):
  58. form_field.widget.attrs['class'] = 'form-control'
  59. return form_field
  60. class StringVar(ScriptVariable):
  61. """
  62. Character string representation. Can enforce minimum/maximum length and/or regex validation.
  63. """
  64. def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs):
  65. super().__init__(*args, **kwargs)
  66. # Optional minimum/maximum lengths
  67. if min_length:
  68. self.field_attrs['min_length'] = min_length
  69. if max_length:
  70. self.field_attrs['max_length'] = max_length
  71. # Optional regular expression validation
  72. if regex:
  73. self.field_attrs['validators'] = [
  74. RegexValidator(
  75. regex=regex,
  76. message='Invalid value. Must match regex: {}'.format(regex),
  77. code='invalid'
  78. )
  79. ]
  80. class TextVar(ScriptVariable):
  81. """
  82. Free-form text data. Renders as a <textarea>.
  83. """
  84. form_field = forms.CharField
  85. def __init__(self, *args, **kwargs):
  86. super().__init__(*args, **kwargs)
  87. self.field_attrs['widget'] = forms.Textarea
  88. class IntegerVar(ScriptVariable):
  89. """
  90. Integer representation. Can enforce minimum/maximum values.
  91. """
  92. form_field = forms.IntegerField
  93. def __init__(self, min_value=None, max_value=None, *args, **kwargs):
  94. super().__init__(*args, **kwargs)
  95. # Optional minimum/maximum values
  96. if min_value:
  97. self.field_attrs['min_value'] = min_value
  98. if max_value:
  99. self.field_attrs['max_value'] = max_value
  100. class BooleanVar(ScriptVariable):
  101. """
  102. Boolean representation (true/false). Renders as a checkbox.
  103. """
  104. form_field = forms.BooleanField
  105. def __init__(self, *args, **kwargs):
  106. super().__init__(*args, **kwargs)
  107. # Boolean fields cannot be required
  108. self.field_attrs['required'] = False
  109. class ChoiceVar(ScriptVariable):
  110. """
  111. Select one of several predefined static choices, passed as a list of two-tuples. Example:
  112. color = ChoiceVar(
  113. choices=(
  114. ('#ff0000', 'Red'),
  115. ('#00ff00', 'Green'),
  116. ('#0000ff', 'Blue')
  117. )
  118. )
  119. """
  120. form_field = forms.ChoiceField
  121. def __init__(self, choices, *args, **kwargs):
  122. super().__init__(*args, **kwargs)
  123. # Set field choices
  124. self.field_attrs['choices'] = choices
  125. class ObjectVar(ScriptVariable):
  126. """
  127. NetBox object representation. The provided QuerySet will determine the choices available.
  128. """
  129. form_field = forms.ModelChoiceField
  130. def __init__(self, queryset, *args, **kwargs):
  131. super().__init__(*args, **kwargs)
  132. # Queryset for field choices
  133. self.field_attrs['queryset'] = queryset
  134. # Update form field for MPTT (nested) objects
  135. if issubclass(queryset.model, MPTTModel):
  136. self.form_field = TreeNodeChoiceField
  137. class MultiObjectVar(ScriptVariable):
  138. """
  139. Like ObjectVar, but can represent one or more objects.
  140. """
  141. form_field = forms.ModelMultipleChoiceField
  142. def __init__(self, queryset, *args, **kwargs):
  143. super().__init__(*args, **kwargs)
  144. # Queryset for field choices
  145. self.field_attrs['queryset'] = queryset
  146. # Update form field for MPTT (nested) objects
  147. if issubclass(queryset.model, MPTTModel):
  148. self.form_field = TreeNodeMultipleChoiceField
  149. class FileVar(ScriptVariable):
  150. """
  151. An uploaded file.
  152. """
  153. form_field = forms.FileField
  154. class IPNetworkVar(ScriptVariable):
  155. """
  156. An IPv4 or IPv6 prefix.
  157. """
  158. form_field = IPFormField
  159. def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
  160. super().__init__(*args, **kwargs)
  161. self.field_attrs['validators'] = list()
  162. # Optional minimum/maximum prefix lengths
  163. if min_prefix_length is not None:
  164. self.field_attrs['validators'].append(
  165. MinPrefixLengthValidator(min_prefix_length)
  166. )
  167. if max_prefix_length is not None:
  168. self.field_attrs['validators'].append(
  169. MaxPrefixLengthValidator(max_prefix_length)
  170. )
  171. #
  172. # Scripts
  173. #
  174. class BaseScript:
  175. """
  176. Base model for custom scripts. User classes should inherit from this model if they want to extend Script
  177. functionality for use in other subclasses.
  178. """
  179. class Meta:
  180. pass
  181. def __init__(self):
  182. # Initiate the log
  183. self.log = []
  184. # Grab some info about the script
  185. self.filename = inspect.getfile(self.__class__)
  186. self.source = inspect.getsource(self.__class__)
  187. def __str__(self):
  188. return getattr(self.Meta, 'name', self.__class__.__name__)
  189. def _get_vars(self):
  190. vars = OrderedDict()
  191. # Infer order from Meta.field_order (Python 3.5 and lower)
  192. field_order = getattr(self.Meta, 'field_order', [])
  193. for name in field_order:
  194. vars[name] = getattr(self, name)
  195. # Default to order of declaration on class
  196. for name, attr in self.__class__.__dict__.items():
  197. if name not in vars and issubclass(attr.__class__, ScriptVariable):
  198. vars[name] = attr
  199. return vars
  200. def run(self, data):
  201. raise NotImplementedError("The script must define a run() method.")
  202. def as_form(self, data=None, files=None):
  203. """
  204. Return a Django form suitable for populating the context data required to run this Script.
  205. """
  206. vars = self._get_vars()
  207. form = ScriptForm(vars, data, files, commit_default=getattr(self.Meta, 'commit_default', True))
  208. return form
  209. # Logging
  210. def log_debug(self, message):
  211. self.log.append((LOG_DEFAULT, message))
  212. def log_success(self, message):
  213. self.log.append((LOG_SUCCESS, message))
  214. def log_info(self, message):
  215. self.log.append((LOG_INFO, message))
  216. def log_warning(self, message):
  217. self.log.append((LOG_WARNING, message))
  218. def log_failure(self, message):
  219. self.log.append((LOG_FAILURE, message))
  220. # Convenience functions
  221. def load_yaml(self, filename):
  222. """
  223. Return data from a YAML file
  224. """
  225. file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
  226. with open(file_path, 'r') as datafile:
  227. data = yaml.load(datafile)
  228. return data
  229. def load_json(self, filename):
  230. """
  231. Return data from a JSON file
  232. """
  233. file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
  234. with open(file_path, 'r') as datafile:
  235. data = json.load(datafile)
  236. return data
  237. class Script(BaseScript):
  238. """
  239. Classes which inherit this model will appear in the list of available scripts.
  240. """
  241. pass
  242. #
  243. # Functions
  244. #
  245. def is_script(obj):
  246. """
  247. Returns True if the object is a Script.
  248. """
  249. try:
  250. return issubclass(obj, Script) and obj != Script
  251. except TypeError:
  252. return False
  253. def is_variable(obj):
  254. """
  255. Returns True if the object is a ScriptVariable.
  256. """
  257. return isinstance(obj, ScriptVariable)
  258. def run_script(script, data, files, commit=True):
  259. """
  260. A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
  261. exists outside of the Script class to ensure it cannot be overridden by a script author.
  262. """
  263. output = None
  264. start_time = None
  265. end_time = None
  266. # Add files to form data
  267. for field_name, fileobj in files.items():
  268. data[field_name] = fileobj
  269. try:
  270. with transaction.atomic():
  271. start_time = time.time()
  272. output = script.run(data)
  273. end_time = time.time()
  274. if not commit:
  275. raise AbortTransaction()
  276. except AbortTransaction:
  277. pass
  278. except Exception as e:
  279. stacktrace = traceback.format_exc()
  280. script.log_failure(
  281. "An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
  282. )
  283. commit = False
  284. finally:
  285. if not commit:
  286. # Delete all pending changelog entries
  287. purge_changelog.send(Script)
  288. script.log_info(
  289. "Database changes have been reverted automatically."
  290. )
  291. # Calculate execution time
  292. if end_time is not None:
  293. execution_time = end_time - start_time
  294. else:
  295. execution_time = None
  296. return output, execution_time
  297. def get_scripts():
  298. scripts = OrderedDict()
  299. # Iterate through all modules within the reports path. These are the user-created files in which reports are
  300. # defined.
  301. for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
  302. module = importer.find_module(module_name).load_module(module_name)
  303. if hasattr(module, 'name'):
  304. module_name = module.name
  305. module_scripts = OrderedDict()
  306. for name, cls in inspect.getmembers(module, is_script):
  307. module_scripts[name] = cls
  308. scripts[module_name] = module_scripts
  309. return scripts