Coverage for extras/scripts.py: 29%
333 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import inspect
2import logging
3import os
4import re
6from django import forms
7from django.core.exceptions import ValidationError
8from django.core.files.storage import storages
9from django.core.validators import RegexValidator
10from django.utils import timezone
11from django.utils.functional import classproperty
12from django.utils.translation import gettext as _
13from rq.exceptions import TimeoutFormatError
14from rq.utils import parse_timeout
16from core.choices import JobNotificationChoices
17from extras.choices import LogLevelChoices
18from extras.constants import SCRIPT_MODULE_NAME_PREFIX
19from extras.models import ScriptModule
20from ipam.formfields import IPAddressFormField, IPNetworkFormField
21from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
22from utilities.forms import add_blank_choice
23from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
24from utilities.forms.widgets import DatePicker, DateTimePicker
26from .forms import ScriptForm
28__all__ = (
29 'BaseScript',
30 'BooleanVar',
31 'ChoiceVar',
32 'DateTimeVar',
33 'DateVar',
34 'DecimalVar',
35 'FileVar',
36 'IPAddressVar',
37 'IPAddressWithMaskVar',
38 'IPNetworkVar',
39 'IntegerVar',
40 'MultiChoiceVar',
41 'MultiObjectVar',
42 'ObjectVar',
43 'Script',
44 'StringVar',
45 'TextVar',
46 'get_module_and_script',
47)
49# Internal ScriptForm fields used to carry execution parameters (see ScriptForm in
50# extras/forms/scripts.py). These are validated/sourced separately from the script's own
51# declared variables and must never be treated as script data or surfaced as script errors.
52EXEC_PARAM_FIELDS = ('_commit', '_schedule_at', '_interval', '_notifications')
54# Sentinel distinguishing "argument not supplied" from an explicit None in validate_meta().
55_UNSET = object()
58#
59# Script variables
60#
62class ScriptVariable:
63 """
64 Base model for script variables
65 """
66 form_field = forms.CharField
68 def __init__(self, label='', description='', default=None, required=True, widget=None):
70 # Initialize field attributes
71 if not hasattr(self, 'field_attrs'):
72 self.field_attrs = {}
73 if label:
74 self.field_attrs['label'] = label
75 if description:
76 self.field_attrs['help_text'] = description
77 if default is not None:
78 self.field_attrs['initial'] = default
79 if widget:
80 self.field_attrs['widget'] = widget
81 self.field_attrs['required'] = required
83 def as_field(self):
84 """
85 Render the variable as a Django form field.
86 """
87 form_field = self.form_field(**self.field_attrs)
88 if not isinstance(form_field.widget, forms.CheckboxInput):
89 if form_field.widget.attrs and 'class' in form_field.widget.attrs.keys():
90 form_field.widget.attrs['class'] += ' form-control'
91 else:
92 form_field.widget.attrs['class'] = 'form-control'
94 return form_field
97class StringVar(ScriptVariable):
98 """
99 Character string representation. Can enforce minimum/maximum length and/or regex validation.
100 """
101 def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs):
102 super().__init__(*args, **kwargs)
104 # Optional minimum/maximum lengths
105 if min_length:
106 self.field_attrs['min_length'] = min_length
107 if max_length:
108 self.field_attrs['max_length'] = max_length
110 # Optional regular expression validation
111 if regex:
112 self.field_attrs['validators'] = [
113 RegexValidator(
114 regex=regex,
115 message='Invalid value. Must match regex: {}'.format(regex),
116 code='invalid'
117 )
118 ]
121class TextVar(ScriptVariable):
122 """
123 Free-form text data. Renders as a <textarea>.
124 """
125 form_field = forms.CharField
127 def __init__(self, *args, **kwargs):
128 super().__init__(*args, **kwargs)
130 self.field_attrs['widget'] = forms.Textarea
133class IntegerVar(ScriptVariable):
134 """
135 Integer representation. Can enforce minimum/maximum values.
136 """
137 form_field = forms.IntegerField
139 def __init__(self, min_value=None, max_value=None, *args, **kwargs):
140 super().__init__(*args, **kwargs)
142 # Optional minimum/maximum values
143 if min_value:
144 self.field_attrs['min_value'] = min_value
145 if max_value:
146 self.field_attrs['max_value'] = max_value
149class DecimalVar(ScriptVariable):
150 """
151 Decimal representation. Can enforce minimum/maximum values, maximum digits and decimal places.
152 """
153 form_field = forms.DecimalField
155 def __init__(self, min_value=None, max_value=None, max_digits=None, decimal_places=None, *args, **kwargs,):
156 super().__init__(*args, **kwargs)
158 # Optional constraints
159 if min_value:
160 self.field_attrs["min_value"] = min_value
161 if max_value:
162 self.field_attrs["max_value"] = max_value
163 if max_digits:
164 self.field_attrs["max_digits"] = max_digits
165 if decimal_places:
166 self.field_attrs["decimal_places"] = decimal_places
169class BooleanVar(ScriptVariable):
170 """
171 Boolean representation (true/false). Renders as a checkbox.
172 """
173 form_field = forms.BooleanField
175 def __init__(self, *args, **kwargs):
176 super().__init__(*args, **kwargs)
178 # Boolean fields cannot be required
179 self.field_attrs['required'] = False
182class ChoiceVar(ScriptVariable):
183 """
184 Select one of several predefined static choices, passed as a list of two-tuples. Example:
186 color = ChoiceVar(
187 choices=(
188 ('#ff0000', 'Red'),
189 ('#00ff00', 'Green'),
190 ('#0000ff', 'Blue')
191 )
192 )
193 """
194 form_field = forms.ChoiceField
196 def __init__(self, choices, *args, **kwargs):
197 super().__init__(*args, **kwargs)
199 # Set field choices, adding a blank choice to avoid forced selections
200 self.field_attrs['choices'] = add_blank_choice(choices)
203class DateVar(ScriptVariable):
204 """
205 A date.
206 """
207 form_field = forms.DateField
209 def __init__(self, *args, **kwargs):
210 super().__init__(*args, **kwargs)
211 self.form_field.widget = DatePicker()
214class DateTimeVar(ScriptVariable):
215 """
216 A date and a time.
217 """
218 form_field = forms.DateTimeField
220 def __init__(self, *args, **kwargs):
221 super().__init__(*args, **kwargs)
222 self.form_field.widget = DateTimePicker()
225class MultiChoiceVar(ScriptVariable):
226 """
227 Like ChoiceVar, but allows for the selection of multiple choices.
228 """
229 form_field = forms.MultipleChoiceField
231 def __init__(self, choices, *args, **kwargs):
232 super().__init__(*args, **kwargs)
234 # Set field choices
235 self.field_attrs['choices'] = choices
238class ObjectVar(ScriptVariable):
239 """
240 A single object within NetBox.
242 :param model: The NetBox model being referenced
243 :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional)
244 :param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
245 elements within the dropdown menu (optional)
246 :param null_option: The label to use as a "null" selection option (optional)
247 :param selector: Include an advanced object selection widget to assist the user in identifying the desired
248 object (optional)
249 :param quick_add: Include a widget to quickly create a new related object for assignment. (optional)
250 """
251 form_field = DynamicModelChoiceField
253 def __init__(self, model, query_params=None, context=None, null_option=None, selector=False, quick_add=False,
254 *args, **kwargs):
255 super().__init__(*args, **kwargs)
257 self.field_attrs.update({
258 'queryset': model.objects.all(),
259 'query_params': query_params,
260 'context': context,
261 'null_option': null_option,
262 'selector': selector,
263 'quick_add': quick_add,
264 })
267class MultiObjectVar(ObjectVar):
268 """
269 Like ObjectVar, but can represent one or more objects.
270 """
271 form_field = DynamicModelMultipleChoiceField
274class FileVar(ScriptVariable):
275 """
276 An uploaded file.
277 """
278 form_field = forms.FileField
281class IPAddressVar(ScriptVariable):
282 """
283 An IPv4 or IPv6 address without a mask.
284 """
285 form_field = IPAddressFormField
288class IPAddressWithMaskVar(ScriptVariable):
289 """
290 An IPv4 or IPv6 address with a mask.
291 """
292 form_field = IPNetworkFormField
295class IPNetworkVar(ScriptVariable):
296 """
297 An IPv4 or IPv6 prefix.
298 """
299 form_field = IPNetworkFormField
301 def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
302 super().__init__(*args, **kwargs)
304 # Set prefix validator and optional minimum/maximum prefix lengths
305 self.field_attrs['validators'] = [prefix_validator]
306 if min_prefix_length is not None:
307 self.field_attrs['validators'].append(
308 MinPrefixLengthValidator(min_prefix_length)
309 )
310 if max_prefix_length is not None:
311 self.field_attrs['validators'].append(
312 MaxPrefixLengthValidator(max_prefix_length)
313 )
316#
317# Scripts
318#
320class BaseScript:
321 """
322 Base model for custom scripts. User classes should inherit from this model if they want to extend Script
323 functionality for use in other subclasses.
324 """
326 # Prevent django from instantiating the class on all accesses
327 do_not_call_in_templates = True
329 class Meta:
330 pass
332 def __init__(self):
333 self.messages = [] # Primary script log
334 self.tests = {} # Mapping of logs for test methods
335 self.output = ''
336 self.failed = False
337 self._current_test = None # Tracks the current test method being run (if any)
339 # Initiate the log
340 self.logger = logging.getLogger(f"netbox.scripts.{self.full_name}")
342 # Declare the placeholder for the current request
343 self.request = None
345 # Initiate the storage backend (local, S3, etc) as a class attr
346 self.storage = storages.create_storage(storages.backends["scripts"])
348 # Compile test methods and initialize results skeleton
349 for method in dir(self):
350 if method.startswith('test_') and callable(getattr(self, method)):
351 self.tests[method] = {
352 LogLevelChoices.LOG_SUCCESS: 0,
353 LogLevelChoices.LOG_INFO: 0,
354 LogLevelChoices.LOG_WARNING: 0,
355 LogLevelChoices.LOG_FAILURE: 0,
356 'log': [],
357 }
359 def __str__(self):
360 return self.name
362 @classproperty
363 def module(self):
364 # Strip the internal prefix applied when the module is loaded (see #22566) so that
365 # user-facing names (full_name, logger namespaces) reflect the original script filename.
366 name = self.__module__
367 if name.startswith(SCRIPT_MODULE_NAME_PREFIX):
368 name = name[len(SCRIPT_MODULE_NAME_PREFIX):]
369 return name
371 @classproperty
372 def class_name(self):
373 return self.__name__
375 @classproperty
376 def full_name(self):
377 return f'{self.module}.{self.class_name}'
379 @classmethod
380 def root_module(cls):
381 return cls.module.split(".")[0]
383 # Author-defined attributes
385 @classproperty
386 def name(self):
387 return getattr(self.Meta, 'name', self.__name__)
389 @classproperty
390 def description(self):
391 return getattr(self.Meta, 'description', '')
393 @classproperty
394 def field_order(self):
395 return getattr(self.Meta, 'field_order', None)
397 @classproperty
398 def fieldsets(self):
399 return getattr(self.Meta, 'fieldsets', None)
401 @classproperty
402 def commit_default(self):
403 return getattr(self.Meta, 'commit_default', True)
405 @classproperty
406 def job_timeout(self):
407 return getattr(self.Meta, 'job_timeout', None)
409 @classproperty
410 def scheduling_enabled(self):
411 return getattr(self.Meta, 'scheduling_enabled', True)
413 @classproperty
414 def notifications_default(self):
415 return getattr(self.Meta, 'notifications_default', JobNotificationChoices.NOTIFICATION_ALWAYS)
417 @classmethod
418 def validate_meta(cls, job_timeout=_UNSET, notifications=_UNSET):
419 """
420 Validate the execution parameters used to run this script. Raises a ValidationError if any value is invalid,
421 so that a misconfigured script surfaces an actionable error rather than an unhandled exception when the job is
422 enqueued (see #22872).
424 The values actually enqueued are validated, not the raw Meta values: a caller may supply an explicit
425 `job_timeout` or `notifications` (e.g. via the REST API), in which case that value is checked. When a caller
426 omits a value, the corresponding Meta default is validated instead. Unset values fall back to valid defaults
427 and are not rejected.
428 """
429 errors = {}
431 job_timeout = cls.job_timeout if job_timeout is _UNSET else job_timeout
432 if job_timeout is not None:
433 # parse_timeout() is what RQ applies to the timeout downstream. It raises TimeoutFormatError for
434 # malformed duration strings, but a job_timeout of an unexpected type (e.g. a list) instead raises
435 # TypeError/ValueError/AssertionError from its internal int()/assert. Catch them all so any invalid value
436 # surfaces as an actionable error rather than an unhandled 500.
437 try:
438 parsed_timeout = parse_timeout(job_timeout)
439 except (TimeoutFormatError, TypeError, ValueError, AssertionError):
440 parsed_timeout = None
441 errors['job_timeout'] = _(
442 "Invalid job_timeout value '{value}': must be an integer (seconds) or a duration string such as "
443 "'1h' or '30m'."
444 ).format(value=job_timeout)
445 if parsed_timeout is not None and parsed_timeout <= 0:
446 errors['job_timeout'] = _(
447 "Invalid job_timeout value '{value}': must be a positive duration."
448 ).format(value=job_timeout)
450 # A caller may pass notifications=None to mean "use the script's default"; treat that as unset.
451 if notifications is _UNSET or notifications is None:
452 notifications = cls.notifications_default
453 if notifications not in JobNotificationChoices.values():
454 valid = ', '.join(JobNotificationChoices.values())
455 errors['notifications_default'] = _(
456 "Invalid notifications value '{value}': must be one of {valid}."
457 ).format(value=notifications, valid=valid)
459 if errors:
460 raise ValidationError(errors)
462 @property
463 def filename(self):
464 return inspect.getfile(self.__class__)
466 def findsource(self, object):
467 with self.storage.open(os.path.basename(self.filename), 'r') as f:
468 data = f.read()
470 # Break the source code into lines
471 lines = [line + '\n' for line in data.splitlines()]
473 # Find the class definition
474 name = object.__name__
475 pat = re.compile(r'^(\s*)class\s*' + name + r'\b')
476 # use the class definition with the least indentation
477 candidates = []
478 for i in range(len(lines)):
479 match = pat.match(lines[i])
480 if match:
481 if lines[i][0] == 'c':
482 return lines, i
484 candidates.append((match.group(1), i))
485 if not candidates:
486 raise OSError('could not find class definition')
488 # Sort the candidates by whitespace, and by line number
489 candidates.sort()
490 return lines, candidates[0][1]
492 @property
493 def source(self):
494 # Can't use inspect.getsource() as it uses os to get the file
495 # inspect uses ast, but that is overkill for this as we only do
496 # classes.
497 object = self.__class__
499 try:
500 lines, lnum = self.findsource(object)
501 lines = inspect.getblock(lines[lnum:])
502 return ''.join(lines)
503 except OSError:
504 return ''
506 @classmethod
507 def _get_vars(cls):
508 vars = {}
510 # Iterate all base classes looking for ScriptVariables
511 for base_class in inspect.getmro(cls):
512 # When object is reached there's no reason to continue
513 if base_class is object:
514 break
516 for name, attr in base_class.__dict__.items():
517 if name not in vars and issubclass(attr.__class__, ScriptVariable):
518 vars[name] = attr
520 # Order variables according to field_order
521 if not cls.field_order:
522 return vars
523 ordered_vars = {
524 field: vars.pop(field) for field in cls.field_order if field in vars
525 }
526 ordered_vars.update(vars)
528 return ordered_vars
530 def run(self, data, commit):
531 """
532 Override this method with custom script logic.
533 """
535 # Backward compatibility for legacy Reports
536 self.pre_run()
537 self.run_tests()
538 self.post_run()
540 def get_job_data(self):
541 """
542 Return a dictionary of data to attach to the script's Job.
543 """
544 return {
545 'log': self.messages,
546 'output': self.output,
547 'tests': self.tests,
548 }
550 #
551 # Form rendering
552 #
554 def get_fieldsets(self):
555 fieldsets = []
557 if self.fieldsets:
558 fieldsets.extend(self.fieldsets)
559 else:
560 fields = list(name for name, __ in self._get_vars().items())
561 fieldsets.append((_('Script Data'), fields))
563 # Append the default fieldset if defined in the Meta class
564 if self.scheduling_enabled:
565 exec_parameters = ('_schedule_at', '_interval', '_commit', '_notifications')
566 else:
567 exec_parameters = ('_commit', '_notifications')
568 fieldsets.append((_('Script Execution Parameters'), exec_parameters))
570 return fieldsets
572 def as_form(self, data=None, files=None, initial=None):
573 """
574 Return a Django form suitable for populating the context data required to run this Script.
575 """
576 # Create a dynamic ScriptForm subclass from script variables
577 fields = {
578 name: var.as_field() for name, var in self._get_vars().items()
579 }
580 FormClass = type('ScriptForm', (ScriptForm,), fields)
582 form = FormClass(data, files, initial=initial)
584 # Set initial "commit" checkbox state based on the script's Meta parameter
585 form.fields['_commit'].initial = self.commit_default
587 # Set initial "notifications" selection based on the script's Meta parameter
588 form.fields['_notifications'].initial = self.notifications_default
590 # Hide fields if scheduling has been disabled
591 if not self.scheduling_enabled:
592 form.fields['_schedule_at'].widget = forms.HiddenInput()
593 form.fields['_interval'].widget = forms.HiddenInput()
595 return form
597 #
598 # Logging
599 #
601 def _log(self, message, obj=None, level=LogLevelChoices.LOG_INFO):
602 """
603 Log a message. Do not call this method directly; use one of the log_* wrappers below.
604 """
605 if level not in LogLevelChoices.values():
606 raise ValueError(f"Invalid logging level: {level}")
608 # A test method is currently active, so log the message using legacy Report logging
609 if self._current_test:
611 # Increment the event counter for this level
612 if level in self.tests[self._current_test]:
613 self.tests[self._current_test][level] += 1
615 # Record message (if any) to the report log
616 if message:
617 # TODO: Use a dataclass for test method logs
618 self.tests[self._current_test]['log'].append((
619 timezone.now().isoformat(),
620 level,
621 str(obj) if obj else None,
622 obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
623 str(message),
624 ))
626 elif message:
628 # Record to the script's log
629 self.messages.append({
630 'time': timezone.now().isoformat(),
631 'status': level,
632 'message': str(message),
633 'obj': str(obj) if obj else None,
634 'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
635 })
637 # Record to the system log
638 if obj:
639 message = f"{obj}: {message}"
640 self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
642 def log_debug(self, message=None, obj=None):
643 self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
645 def log_success(self, message=None, obj=None):
646 self._log(message, obj, level=LogLevelChoices.LOG_SUCCESS)
648 def log_info(self, message=None, obj=None):
649 self._log(message, obj, level=LogLevelChoices.LOG_INFO)
651 def log_warning(self, message=None, obj=None):
652 self._log(message, obj, level=LogLevelChoices.LOG_WARNING)
654 def log_failure(self, message=None, obj=None):
655 self._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
656 self.failed = True
658 #
659 # Legacy Report functionality
660 #
662 def run_tests(self):
663 """
664 Run the report and save its results. Each test method will be executed in order.
665 """
666 self.logger.info("Running report")
667 try:
668 for test_name in self.tests:
669 self._current_test = test_name
670 test_method = getattr(self, test_name)
671 test_method()
672 self._current_test = None
673 except Exception as e:
674 self._current_test = None
675 self.post_run()
676 raise e
678 def pre_run(self):
679 """
680 Legacy method for operations performed immediately prior to running a Report.
681 """
682 pass
684 def post_run(self):
685 """
686 Legacy method for operations performed immediately after running a Report.
687 """
688 pass
691class Script(BaseScript):
692 """
693 Classes which inherit this model will appear in the list of available scripts.
694 """
695 pass
698#
699# Functions
700#
703def is_variable(obj):
704 """
705 Returns True if the object is a ScriptVariable.
706 """
707 return isinstance(obj, ScriptVariable)
710def get_module_and_script(module_name, script_name):
711 module = ScriptModule.objects.get(file_path=f'{module_name}.py')
712 script = module.scripts.get(name=script_name)
713 return module, script
716def prepare_script_form(script_instance, data, files=None):
717 """
718 Return a bound ScriptForm for the given Script instance, back-filling the declared
719 `default` of any variable omitted from `data`.
721 `data` is copied rather than coerced to a plain dict, so a QueryDict retains the
722 multi-value semantics a MultiObjectVar's multi-select field depends on.
723 """
724 data = data.copy() if data is not None else {}
725 for name, var in script_instance._get_vars().items():
726 if name in data:
727 continue
728 if (initial := var.field_attrs.get('initial')) is None:
729 continue
730 if isinstance(initial, (list, tuple)) and hasattr(data, 'setlist'):
731 # Assigning a list to a QueryDict stores it as a single nested value, which a
732 # multi-select widget reads back as one bogus choice. Set the values individually
733 # so a MultiChoiceVar/MultiObjectVar default binds as it does for a plain dict.
734 data.setlist(name, list(initial))
735 else:
736 data[name] = initial
737 return script_instance.as_form(data=data, files=files)