scripts.py 24 KB

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