Repository: netbox-community/netbox
Pull request: 16149 add (optional) obj hyperlink to script list table
### Fixes: #16149 

* Fix documentation to refer to obj instead of object
* Add (optional) obj, url to script table (was only on reports table)
* hyperlink the obj, url to the respective object when display
* add the obj to the log message as documentation states and backwards compatability


Problem statement:
Custom script logs are missing hyperlinks
### Deployment Type

Self-hosted

### NetBox Version

v4.0.2

### Python Version

3.11

### Steps to Reproduce

1. Create a custom script with logs using `self.log_info(some_object, message)`
2. Run the script to write the logs
3. View the logs in the jobs log view

### Expected Behavior

Prior to 4.0 the object passed to `log_info` was displayed with a hyperlink to quicly reach the details view.

### Observed Behavior

Now we have a URL column instead wich we have to copy and paste into the browser or search for the name of the object.

Hints from issue discussion:
Having this functionality for script logs would also be nice. Currently the same logging functions are used in the `run` function as well as the `test_` functions. But when called from inside the `run` function, the second parameter (which is the object) is never displayed in the logs on the front end. There is simply no column to display the value.
I think in an effort to merge scripts and reports it would be nice to also get some consistency there.
More generally, it seems that the logging functions for scripts (syntax `log_info(message, obj)`) ignore the `obj` argument in NetBox 4.0, despite the [documentation](https://docs.netbox.dev/en/stable/customization/custom-scripts/#logging) saying otherwise. Note also that the documentation is naming the argument `object=` where it should be `obj=` according to the code.

https://github.com/netbox-community/netbox/blob/44771d1221f617cc8175397bf75bd0c4c7797f9b/netbox/extras/scripts.py#L496-L508

The obj parameter is not referenced in the JSON that is stored in the DB. It is however prepended to the message in the system/console logs. You can also verify in the DB directly: `select * from core_job order by id desc limit 1 \gx` (to get the results of the last script execution). `data["log"]` has no reference to any object.
@arthanson what is the status of this issue?

Commit message: 16149 review feedback

Patch to review:
diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md
index 2a8f252aa2c..75463d871f5 100644
--- a/docs/customization/custom-scripts.md
+++ b/docs/customization/custom-scripts.md
@@ -138,11 +138,11 @@ These two methods will load data in YAML or JSON format, respectively, from file
 
 The Script object provides a set of convenient functions for recording messages at different severity levels:
 
-* `log_debug(message, object=None)`
-* `log_success(message, object=None)`
-* `log_info(message, object=None)`
-* `log_warning(message, object=None)`
-* `log_failure(message, object=None)`
+* `log_debug(message, obj=None)`
+* `log_success(message, obj=None)`
+* `log_info(message, obj=None)`
+* `log_warning(message, obj=None)`
+* `log_failure(message, obj=None)`
 
 Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. A message may optionally be associated with a particular object by passing it as the second argument to the logging method.
 
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index 0e74c3f0de3..e836477111e 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -500,12 +500,16 @@ def _log(self, message, obj=None, level=LogLevelChoices.LOG_DEFAULT):
                 'time': timezone.now().isoformat(),
                 'status': level,
                 'message': str(message),
+                'obj': str(obj) if obj else None,
+                'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
             })
 
             # Record to the system log
             if obj:
                 message = f"{obj}: {message}"
-            self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
+                self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], f"{obj}: {message}")
+            else:
+                self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
 
     def log_debug(self, message, obj=None):
         self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 8c78ad0dec1..4a9d09aa1a2 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -1,6 +1,7 @@
 import json
 
 import django_tables2 as tables
+from django.utils.html import format_html
 from django.utils.translation import gettext_lazy as _
 
 from extras.models import *
@@ -545,6 +546,12 @@ class ScriptResultsTable(BaseTable):
         template_code="""{% load log_levels %}{% log_level record.status %}""",
         verbose_name=_('Level')
     )
+    object = tables.Column(
+        verbose_name=_('Object')
+    )
+    url = tables.Column(
+        verbose_name=_('URL')
+    )
     message = columns.MarkdownColumn(
         verbose_name=_('Message')
     )
@@ -552,8 +559,17 @@ class ScriptResultsTable(BaseTable):
     class Meta(BaseTable.Meta):
         empty_text = _(EMPTY_TABLE_TEXT)
         fields = (
-            'index', 'time', 'status', 'message',
+            'index', 'time', 'status', 'object', 'url', 'message',
         )
+        default_columns = (
+            'index', 'time', 'status', 'object', 'message',
+        )
+
+    def render_object(self, value, record):
+        return format_html("<a href='{}'>{}</a>", record['url'], value)
+
+    def render_url(self, value):
+        return format_html("<a href='{}'>{}</a>", value, value)
 
 
 class ReportResultsTable(BaseTable):
@@ -585,3 +601,9 @@ class Meta(BaseTable.Meta):
         fields = (
             'index', 'method', 'time', 'status', 'object', 'url', 'message',
         )
+
+    def render_object(self, value, record):
+        return format_html("<a href='{}'>{}</a>", record['url'], value)
+
+    def render_url(self, value):
+        return format_html("<a href='{}'>{}</a>", value, value)
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 82f519c006a..b33c1e22ac0 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -1201,6 +1201,8 @@ def get_table(self, job, request, bulk_actions=True):
                         'time': log.get('time'),
                         'status': log.get('status'),
                         'message': log.get('message'),
+                        'object': log.get('obj'),
+                        'url': log.get('url'),
                     }
                     data.append(result)
 
diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html
index ccc7f4d2351..1b297673b6c 100644
--- a/netbox/templates/extras/script_result.html
+++ b/netbox/templates/extras/script_result.html
@@ -44,7 +44,7 @@
       {# Object table controls #}
       <div class="row mb-3">
         <div class="col-auto ms-auto d-print-none">
-          {% if request.user.is_authenticated %}
+          {% if request.user.is_authenticated and job.completed %}
             <div class="table-configure input-group">
               <button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#ObjectTable_config"
                 class="btn">


Code review comments:
diff_hunk: @@ -545,15 +546,30 @@ class ScriptResultsTable(BaseTable):
         template_code="""{% load log_levels %}{% log_level record.status %}""",
         verbose_name=_('Level')
     )
+    object = tables.Column(
+        verbose_name=_('Object')
+    )
+    url = tables.Column(
+        verbose_name=_('URL')
+    )
path: netbox/extras/tables/tables.py
text: @user1:
I would ditch the URL column. IMO it's poor form to print a raw URL as a value, and it's redundant to the `object` column.

diff_hunk: @@ -500,12 +500,16 @@ def _log(self, message, obj=None, level=LogLevelChoices.LOG_DEFAULT):
                 'time': timezone.now().isoformat(),
                 'status': level,
                 'message': str(message),
+                'obj': str(obj) if obj else None,
+                'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
             })
 
             # Record to the system log
             if obj:
                 message = f"{obj}: {message}"
-            self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
+                self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], f"{obj}: {message}")
+            else:
+                self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
path: netbox/extras/scripts.py
text: @user1:
This appears to be redundant to the line above (which is already prefixing the message with `{obj}: `).