scripts.py 20 KB

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