Repository: netbox-community/netbox
Pull request: 12416 warning for missing script file
### Fixes: #12416 

Fixes the case of missing script files causing the script list view to error out.  Now shows a warning message for the admin to check the file (see screenshot below).  Also removed a left-over print statement in the code.

![Monosnap Scripts | NetBox 2023-05-03 14-26-20](https://user-images.githubusercontent.com/99642/236053537-0365aded-4245-4ff4-be64-90523a0dacda.png)


Problem statement:
Cannot add or remove scripts from Custom Scripts after updating to 3.5.0
### NetBox version

v.3.5.0

### Python version

3.8

### Steps to Reproduce

1. Add any custom Python script into /opt/netbox/netbox/scripts
2. Access Scripts tab on Web UI, newly added script that was put into /opt/netbox/netbox/scripts does not show up

OR

1. Remove any existing script from /opt/netbox/netbox/scripts that was present before upgrading to v3.5.0
2. Access Scripts tab on web UI, this will result in an error message on web UI.

~~Originally I thought this was due to a bug with the netbox-secrets plugin (not supported on 3.5.0 yet, hence why the Changelog doesn't work) so I have removed the netbox-secrets plugin and dropped the related tables from the psql DB according to the Remove a Plugin documentation and restarted NetBox, however the issue with the custom scripts.~~

### Expected Behavior

New scripts should be present in the "Scripts" tab in NetBox, removed script should not be present / not error.

### Observed Behavior

Server Error
There was a problem with your request. Please contact an administrator.

The complete exception is provided below:

<class 'FileNotFoundError'>

[Errno 2] No such file or directory: '/opt/netbox/netbox/scripts/add_device_type_components.py'

Python version: 3.8.10
NetBox version: 3.5.0

Hints from issue discussion:
It's not clear from your description exactly what steps you're taking. How are you adding the new script? What script are you removing? Please edit your post above to provide clear instructions so that someone else can attempt to replicate the behavior.
Apologies. I have tried to make the steps to reproduce more clear, basically I already had a custom script in /opt/netbox/netbox/scripts long before upgrading to 3.5.0 and I can see that script in the "scripts" tab in NetBox. If I try to add more scripts into /opt/netbox/netbox/scripts they do not show up in the "scripts" tab in NetBox. It only just shows that one script that was present before the upgrade. 

But also, if I remove that script that existed in /opt/netbox/netbox/scripts before upgrading to 3.5.0 (meaning there is no custom scripts present in /opt/netbox/netbox/scripts at all) and I go to the "scripts" tab in NetBox, I get the following error: 

<class 'FileNotFoundError'>

[Errno 2] No such file or directory: '/opt/netbox/netbox/scripts/<script_name>.py'

Python version: 3.8.10
NetBox version: 3.5.0

I think the issue here is that scripts and reports work significantly different in 3.5.0 than before, and to be honest it's not really documented very well in the release notes.

There's now a concrete database representation of an "installed" script in the database, see: https://github.com/netbox-community/netbox/issues/12068

The reason your scripts were there after the upgrade, is that a migration runs, importing the scripts from the script folder. The new way of working with scripts consist of using the Add script button on the script page and either uploading the script directly, or using the new data sync feature. Deleting scripts is done by using the delete script button.

To be honest, I haven't had a lot of time myself to play around with the new script/report setup in 3.5.0, so another maintainer might be able to shine more light on the intended workflow.
To clarify the reproduction steps here:

"Access Scripts tab on Web UI, newly added script that was put into /opt/netbox/netbox/scripts does not show up"

This is by design.  To add scripts in 3.5 you need to add them in the UI.

For the second issue, to reproduce:

1. have a script in NetBox v3.4 in the scripts directory
2. Do an upgrade to v3.5
3. Delete the script (after doing the upgrade)
4. Go to the scripts page and see the error
> To clarify the reproduction steps here:
> 
> "Access Scripts tab on Web UI, newly added script that was put into /opt/netbox/netbox/scripts does not show up"
> 
> This is by design. To add scripts in 3.5 you need to add them in the UI.
> 
> For the second issue, to reproduce:
> 
> 1. have a script in NetBox v3.4 in the scripts directory
> 2. Do an upgrade to v3.5
> 3. Delete the script (after doing the upgrade)
> 4. Go to the scripts page and see the error

Correct, yes - to fix this, I re-added the script via CLI back into /opt/netbox/netbox/scripts, then deleted it via the UI. Then, re-added it via the UI and all looks good. 

However, I have ran into another error that I will open a different bug report for. If you upload a script that by chance has an error, such as an indentation error, or  in this example an undefined variable, you will get the following error message for after the upload:

<class 'NameError'>

name 'StringVar' is not defined

Python version: 3.8.10
NetBox version: 3.5.0

Then if you access the Scripts tab in the UI you will be presented with that error, so there is no way to delete it or view other scripts. The fix is to either edit and fix the script in /opt/netbox/netbox/scripts or remove all the contents from the script itself and then you should be able to access the scripts tab again.

Commit message: 12416 widen exception catching for internal script error

Patch to review:
diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py
index 1a7559e530e..bbfd63a561b 100644
--- a/netbox/extras/models/scripts.py
+++ b/netbox/extras/models/scripts.py
@@ -53,7 +53,12 @@ def _get_name(cls):
             # For child objects in submodules use the full import path w/o the root module as the name
             return cls.full_name.split(".", maxsplit=1)[1]
 
-        module = self.get_module()
+        module = None
+        try:
+            module = self.get_module()
+        except Exception as e:
+            pass
+
         scripts = {}
         ordered = getattr(module, 'script_order', [])
 
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 286ec76cd55..c7b7793a0bf 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -1033,7 +1033,6 @@ def get_required_permission(self):
         return 'extras.view_script'
 
     def get(self, request, module, name):
-        print(module)
         module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
         script = module.scripts[name]()
         form = script.as_form(initial=normalize_querydict(request.GET))
diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html
index bccbce5890c..0b42f98846c 100644
--- a/netbox/templates/extras/script_list.html
+++ b/netbox/templates/extras/script_list.html
@@ -37,43 +37,51 @@ <h5 class="card-header" id="module{{ module.pk }}">
         </h5>
         <div class="card-body">
           {% include 'inc/sync_warning.html' with object=module %}
-          <table class="table table-hover table-headings reports">
-            <thead>
-              <tr>
-                <th width="250">Name</th>
-                <th>Description</th>
-                <th>Last Run</th>
-                <th class="text-end">Status</th>
-              </tr>
-            </thead>
-            <tbody>
-              {% with jobs=module.get_latest_jobs %}
-                {% for script_name, script_class in module.scripts.items %}
-                  <tr>
-                    <td>
-                      <a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
-                    </td>
-                    <td>
-                      {{ script_class.Meta.description|markdown|placeholder }}
-                    </td>
-                    {% with last_result=jobs|get_key:script_class.name %}
-                      {% if last_result %}
-                        <td>
-                          <a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
-                        </td>
-                        <td class="text-end">
-                          {% badge last_result.get_status_display last_result.get_status_color %}
-                        </td>
-                      {% else %}
-                        <td class="text-muted">Never</td>
-                        <td class="text-end">{{ ''|placeholder }}</td>
-                      {% endif %}
-                    {% endwith %}
-                  </tr>
-                {% endfor %}
-              {% endwith %}
-            </tbody>
-          </table>
+          {% if not module.scripts %}
+            <div class="alert alert-warning d-flex align-items-center" role="alert">
+              <div>
+                Warning: Script file at: <strong>{{ module.full_path }}"</strong> is either missing or has a problem with the script code, please check the file.
+              </div>
+            </div>
+          {% else %}
+            <table class="table table-hover table-headings reports">
+              <thead>
+                <tr>
+                  <th width="250">Name</th>
+                  <th>Description</th>
+                  <th>Last Run</th>
+                  <th class="text-end">Status</th>
+                </tr>
+              </thead>
+              <tbody>
+                {% with jobs=module.get_latest_jobs %}
+                  {% for script_name, script_class in module.scripts.items %}
+                    <tr>
+                      <td>
+                        <a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
+                      </td>
+                      <td>
+                        {{ script_class.Meta.description|markdown|placeholder }}
+                      </td>
+                      {% with last_result=jobs|get_key:script_class.name %}
+                        {% if last_result %}
+                          <td>
+                            <a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
+                          </td>
+                          <td class="text-end">
+                            {% badge last_result.get_status_display last_result.get_status_color %}
+                          </td>
+                        {% else %}
+                          <td class="text-muted">Never</td>
+                          <td class="text-end">{{ ''|placeholder }}</td>
+                        {% endif %}
+                      {% endwith %}
+                    </tr>
+                  {% endfor %}
+                {% endwith %}
+              </tbody>
+            </table>
+          {% endif %}
         </div>
       </div>
     {% empty %}


Code review comments:
diff_hunk: @@ -37,43 +37,51 @@ <h5 class="card-header" id="module{{ module.pk }}">
         </h5>
         <div class="card-body">
           {% include 'inc/sync_warning.html' with object=module %}
-          <table class="table table-hover table-headings reports">
-            <thead>
-              <tr>
-                <th width="250">Name</th>
-                <th>Description</th>
-                <th>Last Run</th>
-                <th class="text-end">Status</th>
-              </tr>
-            </thead>
-            <tbody>
-              {% with jobs=module.get_latest_jobs %}
-                {% for script_name, script_class in module.scripts.items %}
-                  <tr>
-                    <td>
-                      <a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
-                    </td>
-                    <td>
-                      {{ script_class.Meta.description|markdown|placeholder }}
-                    </td>
-                    {% with last_result=jobs|get_key:script_class.name %}
-                      {% if last_result %}
-                        <td>
-                          <a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
-                        </td>
-                        <td class="text-end">
-                          {% badge last_result.get_status_display last_result.get_status_color %}
-                        </td>
-                      {% else %}
-                        <td class="text-muted">Never</td>
-                        <td class="text-end">{{ ''|placeholder }}</td>
-                      {% endif %}
-                    {% endwith %}
-                  </tr>
-                {% endfor %}
-              {% endwith %}
-            </tbody>
-          </table>
+          {% if not module.scripts %}
+            <div class="alert alert-warning d-flex align-items-center" role="alert">
+              <div>
+                Warning: Script file at: <strong>{{ module.full_path }}"</strong> is either missing or has a problem with the script code, please check the file.
+              </div>
path: netbox/templates/extras/script_list.html
text: @user1:
```suggestion
              <i class="mdi mdi-alert"></i> Script file at <code>{{ module.full_path }}</code> could not be loaded.
```

@author:
left out the <code> tag as it was really messing up spacing and formatting when narrow window.

diff_hunk: @@ -53,7 +53,12 @@ def _get_name(cls):
             # For child objects in submodules use the full import path w/o the root module as the name
             return cls.full_name.split(".", maxsplit=1)[1]
 
-        module = self.get_module()
+        module = None
+        try:
+            module = self.get_module()
+        except Exception as e:
+            pass
path: netbox/extras/models/scripts.py
text: @user1:
```suggestion
        try:
            module = self.get_module()
        except Exception as e:
            module = None
```

@user1:
We also might want to log the missing file.