Repository: netbox-community/netbox
Pull request: Add fieldsets functionality to scripts to allow for form field groupings

### Fixes: #11833 

Introduces a `fieldsets` script attribute defined under a class named `Meta` within the script.

Notes:

- If set this ignores the field_order attribute.
- If a field is missed from the fieldset it will not be rendered (I could change this default if we want, but I do not think this would be consistent with other implementations of fieldset).

Any concerns or changes needed, I'd be happy to address.

Given this is not a breaking change, it would be nice in the next minor release.

Problem statement:
Add field grouping function to scripts function
### NetBox version

v3.4.5

### Feature type

New functionality

### Proposed functionality

Similar to fieldsets in forms or group_names in  custom_fields, it would be useful to allow scripts to have some sort of grouping of fields.

### Use case

Scripts with a number of input fields could really stand to leverage some grouping.

### Database changes

None

### External dependencies

None

Hints from issue discussion:
To add to this, it would be nice to move the scheduling fields to their own fieldset too, as the layout is a little confusing at the moment.

Commit message: update scripts docs for new behavior

Patch to review:
diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md
index af1e9b5b697..fd6e81250af 100644
--- a/docs/customization/custom-scripts.md
+++ b/docs/customization/custom-scripts.md
@@ -79,7 +79,11 @@ A human-friendly description of what your script does.
 
 ### `field_order`
 
-By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered. Any fields not included in this iterable be listed last.
+By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered within a default "Script Data" group. Any fields not included in this iterable be listed last. If `fieldsets` is defined, `field_order` will be ignored.  A fieldset group for "Script Execution Parameters" will be added to the end of the form by default for the user.
+
+### `fieldsets`
+
+`fieldsets` may be defined as an iterable of field groups and their field names to determine the order in which variables are group and rendered. Any fields not included in this iterable will not be displayed in the form. If `fieldsets` is defined, `field_order` will be ignored.  A fieldset group for "Script Execution Parameters" will be added to the end of the fieldsets by default for the user.
 
 ### `commit_default`
 
@@ -302,7 +306,7 @@ Optionally `schedule_at` can be passed in the form data with a datetime string t
 Scripts can be run on the CLI by invoking the management command:
 
 ```
-python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script> 
+python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
 ```
 
 The required ``<module>.<script>`` argument is the script to run where ``<module>`` is the name of the python file in the ``scripts`` directory without the ``.py`` extension and ``<script>`` is the name of the script class in the ``<module>`` to run.
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index 313058d5771..2cc7160603a 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -352,6 +352,18 @@ def as_form(self, data=None, files=None, initial=None):
         # Set initial "commit" checkbox state based on the script's Meta parameter
         form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
 
+        # Append the default fieldset if defined in the Meta class
+        default_fieldset = (
+            ('Script Execution Parameters', ('_schedule_at', '_interval', '_commit')),
+        )
+        if not hasattr(self.Meta, 'fieldsets'):
+            fields = (
+                name for name, _ in self._get_vars().items()
+            )
+            self.Meta.fieldsets = (('Script Data', fields),)
+
+        self.Meta.fieldsets += default_fieldset
+
         return form
 
     # Logging
diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html
index 6fbcde32252..a6094a86260 100644
--- a/netbox/templates/extras/script.html
+++ b/netbox/templates/extras/script.html
@@ -47,16 +47,44 @@
           {% csrf_token %}
           <div class="field-group my-4">
             {% if form.requires_input %}
-              <div class="row mb-2">
-                <h5 class="offset-sm-3">Script Data</h5>
-              </div>
+              {% if script.Meta.fieldsets %}
+                {# Render grouped fields according to declared fieldsets #}
+                {% for group, fields in script.Meta.fieldsets %}
+                  <div class="field-group mb-5">
+                    <div class="row mb-2">
+                      <h5 class="offset-sm-3">
+                        {% if group %}
+                          {{ group }}
+                        {% else %}
+                          {{ model|meta:"verbose_name"|bettertitle }}
+                        {% endif %}
+                      </h5>
+                    </div>
+                    {% for name in fields %}
+                      {% with field=form|getfield:name %}
+                        {% if field.name in form.nullable_fields %}
+                          {% render_field field bulk_nullable=True %}
+                        {% else %}
+                          {% render_field field %}
+                        {% endif %}
+                      {% endwith %}
+                    {% endfor %}
+                  </div>
+                {% endfor %}
+              {% else %}
+                {# Render all fields as a single group #}
+                <div class="row mb-2">
+                  <h5 class="offset-sm-3">Script Data</h5>
+                </div>
+                {% render_form form %}
+              {% endif %}
             {% else %}
               <div class="alert alert-info">
                 <i class="mdi mdi-information"></i>
                 This script does not require any input to run.
               </div>
+              {% render_form form %}
             {% endif %}
-            {% render_form form %}
           </div>
           <div class="float-end">
             <a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>


Code review comments:
diff_hunk: @@ -47,16 +47,44 @@
           {% csrf_token %}
           <div class="field-group my-4">
             {% if form.requires_input %}
-              <div class="row mb-2">
-                <h5 class="offset-sm-3">Script Data</h5>
-              </div>
+              {% if script.Meta.fieldsets %}
+                {# Render grouped fields according to declared fieldsets #}
+                {% for group, fields in script.Meta.fieldsets %}
+                  <div class="field-group mb-5">
+                    <div class="row mb-2">
+                      <h5 class="offset-sm-3">
+                        {% if group %}
+                          {{ group }}
+                        {% else %}
+                          {{ model|meta:"verbose_name"|bettertitle }}
+                        {% endif %}
+                      </h5>
+                    </div>
+                    {% for name in fields %}
+                      {% with field=form|getfield:name %}
+                        {% if field.name in form.nullable_fields %}
path: netbox/templates/extras/script.html
text: @user1:
I don't believe we support `nullable_fields` for script forms; IIRC this is limited to bulk edit forms.

@author:
Good catch.  I will resolve this.

diff_hunk: @@ -47,16 +47,44 @@
           {% csrf_token %}
           <div class="field-group my-4">
             {% if form.requires_input %}
-              <div class="row mb-2">
-                <h5 class="offset-sm-3">Script Data</h5>
-              </div>
+              {% if script.Meta.fieldsets %}
+                {# Render grouped fields according to declared fieldsets #}
+                {% for group, fields in script.Meta.fieldsets %}
+                  <div class="field-group mb-5">
+                    <div class="row mb-2">
+                      <h5 class="offset-sm-3">
+                        {% if group %}
+                          {{ group }}
+                        {% else %}
+                          {{ model|meta:"verbose_name"|bettertitle }}
path: netbox/templates/extras/script.html
text: @user1:
Group name should be required. This looks like it was copied inadvertently from elsewhere.

@author:
yep