scripts.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. import inspect
  2. import json
  3. import logging
  4. import os
  5. import yaml
  6. from django import forms
  7. from django.conf import settings
  8. from django.core.validators import RegexValidator
  9. from django.utils import timezone
  10. from django.utils.functional import classproperty
  11. from django.utils.translation import gettext as _
  12. from extras.choices import LogLevelChoices
  13. from extras.models import ScriptModule
  14. from ipam.formfields import IPAddressFormField, IPNetworkFormField
  15. from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
  16. from utilities.forms import add_blank_choice
  17. from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
  18. from utilities.forms.widgets import DatePicker, DateTimePicker
  19. from .forms import ScriptForm
  20. __all__ = (
  21. 'BaseScript',
  22. 'BooleanVar',
  23. 'ChoiceVar',
  24. 'DateVar',
  25. 'DateTimeVar',
  26. 'FileVar',
  27. 'IntegerVar',
  28. 'IPAddressVar',
  29. 'IPAddressWithMaskVar',
  30. 'IPNetworkVar',
  31. 'MultiChoiceVar',
  32. 'MultiObjectVar',
  33. 'ObjectVar',
  34. 'Script',
  35. 'StringVar',
  36. 'TextVar',
  37. 'get_module_and_script',
  38. )
  39. #
  40. # Script variables
  41. #
  42. class ScriptVariable:
  43. """
  44. Base model for script variables
  45. """
  46. form_field = forms.CharField
  47. def __init__(self, label='', description='', default=None, required=True, widget=None):
  48. # Initialize field attributes
  49. if not hasattr(self, 'field_attrs'):
  50. self.field_attrs = {}
  51. if label:
  52. self.field_attrs['label'] = label
  53. if description:
  54. self.field_attrs['help_text'] = description
  55. if default:
  56. self.field_attrs['initial'] = default
  57. if widget:
  58. self.field_attrs['widget'] = widget
  59. self.field_attrs['required'] = required
  60. def as_field(self):
  61. """
  62. Render the variable as a Django form field.
  63. """
  64. form_field = self.form_field(**self.field_attrs)
  65. if not isinstance(form_field.widget, forms.CheckboxInput):
  66. if form_field.widget.attrs and 'class' in form_field.widget.attrs.keys():
  67. form_field.widget.attrs['class'] += ' form-control'
  68. else:
  69. form_field.widget.attrs['class'] = 'form-control'
  70. return form_field
  71. class StringVar(ScriptVariable):
  72. """
  73. Character string representation. Can enforce minimum/maximum length and/or regex validation.
  74. """
  75. def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs):
  76. super().__init__(*args, **kwargs)
  77. # Optional minimum/maximum lengths
  78. if min_length:
  79. self.field_attrs['min_length'] = min_length
  80. if max_length:
  81. self.field_attrs['max_length'] = max_length
  82. # Optional regular expression validation
  83. if regex:
  84. self.field_attrs['validators'] = [
  85. RegexValidator(
  86. regex=regex,
  87. message='Invalid value. Must match regex: {}'.format(regex),
  88. code='invalid'
  89. )
  90. ]
  91. class TextVar(ScriptVariable):
  92. """
  93. Free-form text data. Renders as a <textarea>.
  94. """
  95. form_field = forms.CharField
  96. def __init__(self, *args, **kwargs):
  97. super().__init__(*args, **kwargs)
  98. self.field_attrs['widget'] = forms.Textarea
  99. class IntegerVar(ScriptVariable):
  100. """
  101. Integer representation. Can enforce minimum/maximum values.
  102. """
  103. form_field = forms.IntegerField
  104. def __init__(self, min_value=None, max_value=None, *args, **kwargs):
  105. super().__init__(*args, **kwargs)
  106. # Optional minimum/maximum values
  107. if min_value:
  108. self.field_attrs['min_value'] = min_value
  109. if max_value:
  110. self.field_attrs['max_value'] = max_value
  111. class BooleanVar(ScriptVariable):
  112. """
  113. Boolean representation (true/false). Renders as a checkbox.
  114. """
  115. form_field = forms.BooleanField
  116. def __init__(self, *args, **kwargs):
  117. super().__init__(*args, **kwargs)
  118. # Boolean fields cannot be required
  119. self.field_attrs['required'] = False
  120. class ChoiceVar(ScriptVariable):
  121. """
  122. Select one of several predefined static choices, passed as a list of two-tuples. Example:
  123. color = ChoiceVar(
  124. choices=(
  125. ('#ff0000', 'Red'),
  126. ('#00ff00', 'Green'),
  127. ('#0000ff', 'Blue')
  128. )
  129. )
  130. """
  131. form_field = forms.ChoiceField
  132. def __init__(self, choices, *args, **kwargs):
  133. super().__init__(*args, **kwargs)
  134. # Set field choices, adding a blank choice to avoid forced selections
  135. self.field_attrs['choices'] = add_blank_choice(choices)
  136. class DateVar(ScriptVariable):
  137. """
  138. A date.
  139. """
  140. form_field = forms.DateField
  141. def __init__(self, *args, **kwargs):
  142. super().__init__(*args, **kwargs)
  143. self.form_field.widget = DatePicker()
  144. class DateTimeVar(ScriptVariable):
  145. """
  146. A date and a time.
  147. """
  148. form_field = forms.DateTimeField
  149. def __init__(self, *args, **kwargs):
  150. super().__init__(*args, **kwargs)
  151. self.form_field.widget = DateTimePicker()
  152. class MultiChoiceVar(ScriptVariable):
  153. """
  154. Like ChoiceVar, but allows for the selection of multiple choices.
  155. """
  156. form_field = forms.MultipleChoiceField
  157. def __init__(self, choices, *args, **kwargs):
  158. super().__init__(*args, **kwargs)
  159. # Set field choices
  160. self.field_attrs['choices'] = choices
  161. class ObjectVar(ScriptVariable):
  162. """
  163. A single object within NetBox.
  164. :param model: The NetBox model being referenced
  165. :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
  166. :param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
  167. elements within the dropdown menu (optional)
  168. :param null_option: The label to use as a "null" selection option (optional)
  169. :param selector: Include an advanced object selection widget to assist the user in identifying the desired
  170. object (optional)
  171. """
  172. form_field = DynamicModelChoiceField
  173. def __init__(self, model, query_params=None, context=None, null_option=None, selector=False, *args, **kwargs):
  174. super().__init__(*args, **kwargs)
  175. self.field_attrs.update({
  176. 'queryset': model.objects.all(),
  177. 'query_params': query_params,
  178. 'context': context,
  179. 'null_option': null_option,
  180. 'selector': selector,
  181. })
  182. class MultiObjectVar(ObjectVar):
  183. """
  184. Like ObjectVar, but can represent one or more objects.
  185. """
  186. form_field = DynamicModelMultipleChoiceField
  187. class FileVar(ScriptVariable):
  188. """
  189. An uploaded file.
  190. """
  191. form_field = forms.FileField
  192. class IPAddressVar(ScriptVariable):
  193. """
  194. An IPv4 or IPv6 address without a mask.
  195. """
  196. form_field = IPAddressFormField
  197. class IPAddressWithMaskVar(ScriptVariable):
  198. """
  199. An IPv4 or IPv6 address with a mask.
  200. """
  201. form_field = IPNetworkFormField
  202. class IPNetworkVar(ScriptVariable):
  203. """
  204. An IPv4 or IPv6 prefix.
  205. """
  206. form_field = IPNetworkFormField
  207. def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
  208. super().__init__(*args, **kwargs)
  209. # Set prefix validator and optional minimum/maximum prefix lengths
  210. self.field_attrs['validators'] = [prefix_validator]
  211. if min_prefix_length is not None:
  212. self.field_attrs['validators'].append(
  213. MinPrefixLengthValidator(min_prefix_length)
  214. )
  215. if max_prefix_length is not None:
  216. self.field_attrs['validators'].append(
  217. MaxPrefixLengthValidator(max_prefix_length)
  218. )
  219. #
  220. # Scripts
  221. #
  222. class BaseScript:
  223. """
  224. Base model for custom scripts. User classes should inherit from this model if they want to extend Script
  225. functionality for use in other subclasses.
  226. """
  227. # Prevent django from instantiating the class on all accesses
  228. do_not_call_in_templates = True
  229. class Meta:
  230. pass
  231. def __init__(self):
  232. self.messages = [] # Primary script log
  233. self.tests = {} # Mapping of logs for test methods
  234. self.output = ''
  235. self.failed = False
  236. self._current_test = None # Tracks the current test method being run (if any)
  237. # Initiate the log
  238. self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}")
  239. # Declare the placeholder for the current request
  240. self.request = None
  241. # Compile test methods and initialize results skeleton
  242. for method in dir(self):
  243. if method.startswith('test_') and callable(getattr(self, method)):
  244. self.tests[method] = {
  245. LogLevelChoices.LOG_SUCCESS: 0,
  246. LogLevelChoices.LOG_INFO: 0,
  247. LogLevelChoices.LOG_WARNING: 0,
  248. LogLevelChoices.LOG_FAILURE: 0,
  249. 'log': [],
  250. }
  251. def __str__(self):
  252. return self.name
  253. @classproperty
  254. def module(self):
  255. return self.__module__
  256. @classproperty
  257. def class_name(self):
  258. return self.__name__
  259. @classproperty
  260. def full_name(self):
  261. return f'{self.module}.{self.class_name}'
  262. @classmethod
  263. def root_module(cls):
  264. return cls.__module__.split(".")[0]
  265. # Author-defined attributes
  266. @classproperty
  267. def name(self):
  268. return getattr(self.Meta, 'name', self.__name__)
  269. @classproperty
  270. def description(self):
  271. return getattr(self.Meta, 'description', '')
  272. @classproperty
  273. def field_order(self):
  274. return getattr(self.Meta, 'field_order', None)
  275. @classproperty
  276. def fieldsets(self):
  277. return getattr(self.Meta, 'fieldsets', None)
  278. @classproperty
  279. def commit_default(self):
  280. return getattr(self.Meta, 'commit_default', True)
  281. @classproperty
  282. def job_timeout(self):
  283. return getattr(self.Meta, 'job_timeout', None)
  284. @classproperty
  285. def scheduling_enabled(self):
  286. return getattr(self.Meta, 'scheduling_enabled', True)
  287. @property
  288. def filename(self):
  289. return inspect.getfile(self.__class__)
  290. @property
  291. def source(self):
  292. return inspect.getsource(self.__class__)
  293. @classmethod
  294. def _get_vars(cls):
  295. vars = {}
  296. # Iterate all base classes looking for ScriptVariables
  297. for base_class in inspect.getmro(cls):
  298. # When object is reached there's no reason to continue
  299. if base_class is object:
  300. break
  301. for name, attr in base_class.__dict__.items():
  302. if name not in vars and issubclass(attr.__class__, ScriptVariable):
  303. vars[name] = attr
  304. # Order variables according to field_order
  305. if not cls.field_order:
  306. return vars
  307. ordered_vars = {
  308. field: vars.pop(field) for field in cls.field_order if field in vars
  309. }
  310. ordered_vars.update(vars)
  311. return ordered_vars
  312. def run(self, data, commit):
  313. """
  314. Override this method with custom script logic.
  315. """
  316. # Backward compatibility for legacy Reports
  317. self.pre_run()
  318. self.run_tests()
  319. self.post_run()
  320. def get_job_data(self):
  321. """
  322. Return a dictionary of data to attach to the script's Job.
  323. """
  324. return {
  325. 'log': self.messages,
  326. 'output': self.output,
  327. 'tests': self.tests,
  328. }
  329. #
  330. # Form rendering
  331. #
  332. def get_fieldsets(self):
  333. fieldsets = []
  334. if self.fieldsets:
  335. fieldsets.extend(self.fieldsets)
  336. else:
  337. fields = list(name for name, _ in self._get_vars().items())
  338. fieldsets.append((_('Script Data'), fields))
  339. # Append the default fieldset if defined in the Meta class
  340. exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
  341. fieldsets.append((_('Script Execution Parameters'), exec_parameters))
  342. return fieldsets
  343. def as_form(self, data=None, files=None, initial=None):
  344. """
  345. Return a Django form suitable for populating the context data required to run this Script.
  346. """
  347. # Create a dynamic ScriptForm subclass from script variables
  348. fields = {
  349. name: var.as_field() for name, var in self._get_vars().items()
  350. }
  351. FormClass = type('ScriptForm', (ScriptForm,), fields)
  352. form = FormClass(data, files, initial=initial)
  353. # Set initial "commit" checkbox state based on the script's Meta parameter
  354. form.fields['_commit'].initial = self.commit_default
  355. # Hide fields if scheduling has been disabled
  356. if not self.scheduling_enabled:
  357. form.fields['_schedule_at'].widget = forms.HiddenInput()
  358. form.fields['_interval'].widget = forms.HiddenInput()
  359. return form
  360. #
  361. # Logging
  362. #
  363. def _log(self, message, obj=None, level=LogLevelChoices.LOG_INFO):
  364. """
  365. Log a message. Do not call this method directly; use one of the log_* wrappers below.
  366. """
  367. if level not in LogLevelChoices.values():
  368. raise ValueError(f"Invalid logging level: {level}")
  369. # A test method is currently active, so log the message using legacy Report logging
  370. if self._current_test:
  371. # Increment the event counter for this level
  372. if level in self.tests[self._current_test]:
  373. self.tests[self._current_test][level] += 1
  374. # Record message (if any) to the report log
  375. if message:
  376. # TODO: Use a dataclass for test method logs
  377. self.tests[self._current_test]['log'].append((
  378. timezone.now().isoformat(),
  379. level,
  380. str(obj) if obj else None,
  381. obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
  382. str(message),
  383. ))
  384. elif message:
  385. # Record to the script's log
  386. self.messages.append({
  387. 'time': timezone.now().isoformat(),
  388. 'status': level,
  389. 'message': str(message),
  390. 'obj': str(obj) if obj else None,
  391. 'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
  392. })
  393. # Record to the system log
  394. if obj:
  395. message = f"{obj}: {message}"
  396. self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
  397. def log_debug(self, message=None, obj=None):
  398. self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
  399. def log_success(self, message=None, obj=None):
  400. self._log(message, obj, level=LogLevelChoices.LOG_SUCCESS)
  401. def log_info(self, message=None, obj=None):
  402. self._log(message, obj, level=LogLevelChoices.LOG_INFO)
  403. def log_warning(self, message=None, obj=None):
  404. self._log(message, obj, level=LogLevelChoices.LOG_WARNING)
  405. def log_failure(self, message=None, obj=None):
  406. self._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
  407. self.failed = True
  408. #
  409. # Convenience functions
  410. #
  411. def load_yaml(self, filename):
  412. """
  413. Return data from a YAML file
  414. """
  415. try:
  416. from yaml import CLoader as Loader
  417. except ImportError:
  418. from yaml import Loader
  419. file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
  420. with open(file_path, 'r') as datafile:
  421. data = yaml.load(datafile, Loader=Loader)
  422. return data
  423. def load_json(self, filename):
  424. """
  425. Return data from a JSON file
  426. """
  427. file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
  428. with open(file_path, 'r') as datafile:
  429. data = json.load(datafile)
  430. return data
  431. #
  432. # Legacy Report functionality
  433. #
  434. def run_tests(self):
  435. """
  436. Run the report and save its results. Each test method will be executed in order.
  437. """
  438. self.logger.info("Running report")
  439. try:
  440. for test_name in self.tests:
  441. self._current_test = test_name
  442. test_method = getattr(self, test_name)
  443. test_method()
  444. self._current_test = None
  445. except Exception as e:
  446. self._current_test = None
  447. self.post_run()
  448. raise e
  449. def pre_run(self):
  450. """
  451. Legacy method for operations performed immediately prior to running a Report.
  452. """
  453. pass
  454. def post_run(self):
  455. """
  456. Legacy method for operations performed immediately after running a Report.
  457. """
  458. pass
  459. class Script(BaseScript):
  460. """
  461. Classes which inherit this model will appear in the list of available scripts.
  462. """
  463. pass
  464. #
  465. # Functions
  466. #
  467. def is_variable(obj):
  468. """
  469. Returns True if the object is a ScriptVariable.
  470. """
  471. return isinstance(obj, ScriptVariable)
  472. def get_module_and_script(module_name, script_name):
  473. module = ScriptModule.objects.get(file_path=f'{module_name}.py')
  474. script = module.scripts.get(name=script_name)
  475. return module, script