Repository: nautobot/nautobot

Issue:
2.0: TypeError when logging CustomFieldChoice object in Job
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Nautobot version (Docker tag too if applicable): 2.0.0-beta.1
* Python version: 3.10.11
* Database platform, version:
* Middleware(s):

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Create CustomField (type select) and CustomFieldChoice
2. Create a Job
3. Get the CustomFieldChoice in `run()` and log it via `extra` param

```
from nautobot.extras.jobs import Job
from nautobot.extras.models import CustomFieldChoice


class TypeErrorJob(Job):
    class Meta:
        name = "This Job triggers logging TypeError."

    def run(self):
        custom_field = CustomFieldChoice.objects.first()
        self.logger.info(extra={"object": custom_field}, msg=f"This triggers TypeError.")
```

<!-- What did you expect to happen? -->
### Expected Behavior
Log `This triggers TypeError.` associated with the CustomFieldChoice object

<!-- What happened instead? -->
### Observed Behavior
Logging TypeError: 'AttributeError' object is not subscriptable
![image](https://github.com/nautobot/nautobot/assets/26114847/29fe13b7-88a1-460b-bd2c-a2d355a2323e)

Full stack does not fit single screen, so here it is from logs:
```
[2023-06-23 18:21:35,092: WARNING/ForkPoolWorker-6] --- Logging error ---
[2023-06-23 18:21:35,100: WARNING/ForkPoolWorker-6] Traceback (most recent call last):
[2023-06-23 18:21:35,104: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/nautobot/core/celery/log.py", line 31, in emit
    job_result.log(
[2023-06-23 18:21:35,108: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/nautobot/extras/models/jobs.py", line 713, in log
    absolute_url=obj.get_absolute_url()[:JOB_LOG_MAX_ABSOLUTE_URL_LENGTH]
[2023-06-23 18:21:35,112: WARNING/ForkPoolWorker-6] TypeError: 'AttributeError' object is not subscriptable
[2023-06-23 18:21:35,116: WARNING/ForkPoolWorker-6] Call stack:
[2023-06-23 18:21:35,133: WARNING/ForkPoolWorker-6]   File "/usr/local/bin/nautobot-server", line 8, in <module>
    sys.exit(main())
[2023-06-23 18:21:35,137: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/nautobot/core/cli/__init__.py", line 54, in main
    run_app(
[2023-06-23 18:21:35,141: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/nautobot/core/runner/runner.py", line 263, in run_app
    management.execute_from_command_line([runner_name, command] + command_args)
[2023-06-23 18:21:35,145: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/django/core/management/__init__.py", line 419, in execute_from_command_line
    utility.execute()
[2023-06-23 18:21:35,149: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/django/core/management/__init__.py", line 413, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
[2023-06-23 18:21:35,154: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/nautobot/core/management/commands/celery.py", line 23, in run_from_argv
    celery_main.main()
[2023-06-23 18:21:35,158: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/click/core.py", line 1055, in main
    rv = self.invoke(ctx)
[2023-06-23 18:21:35,163: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/click/core.py", line 1657, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
[2023-06-23 18:21:35,169: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/click/core.py", line 1404, in invoke
    return ctx.invoke(self.callback, **ctx.params)
[2023-06-23 18:21:35,173: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/click/core.py", line 760, in invoke
    return __callback(*args, **kwargs)
[2023-06-23 18:21:35,178: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/click/decorators.py", line 26, in new_func
    return f(get_current_context(), *args, **kwargs)
[2023-06-23 18:21:35,183: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/bin/base.py", line 134, in caller
    return f(ctx, *args, **kwargs)
[2023-06-23 18:21:35,188: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/bin/worker.py", line 351, in worker
    worker.start()
[2023-06-23 18:21:35,193: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/worker/worker.py", line 203, in start
    self.blueprint.start(self)
[2023-06-23 18:21:35,279: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/bootsteps.py", line 116, in start
    step.start(parent)
[2023-06-23 18:21:35,365: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/bootsteps.py", line 365, in start
    return self.obj.start()
[2023-06-23 18:21:35,368: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/concurrency/base.py", line 129, in start
    self.on_start()
[2023-06-23 18:21:35,372: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/concurrency/prefork.py", line 109, in on_start
    P = self._pool = Pool(processes=self.limit,
[2023-06-23 18:21:35,375: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/concurrency/asynpool.py", line 463, in __init__
    super().__init__(processes, *args, **kwargs)
[2023-06-23 18:21:35,381: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/billiard/pool.py", line 1046, in __init__
    self._create_worker_process(i)
[2023-06-23 18:21:35,386: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/concurrency/asynpool.py", line 480, in _create_worker_process
    return super()._create_worker_process(i)
[2023-06-23 18:21:35,389: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/billiard/pool.py", line 1158, in _create_worker_process
    w.start()
[2023-06-23 18:21:35,397: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/billiard/process.py", line 124, in start
    self._popen = self._Popen(self)
[2023-06-23 18:21:35,401: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/billiard/context.py", line 333, in _Popen
    return Popen(process_obj)
[2023-06-23 18:21:35,405: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/billiard/popen_fork.py", line 24, in __init__
    self._launch(process_obj)
[2023-06-23 18:21:35,624: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/billiard/popen_fork.py", line 79, in _launch
    code = process_obj._bootstrap()
[2023-06-23 18:21:35,628: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/billiard/process.py", line 327, in _bootstrap
    self.run()
[2023-06-23 18:21:35,632: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/billiard/process.py", line 114, in run
    self._target(*self._args, **self._kwargs)
[2023-06-23 18:21:35,635: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/billiard/pool.py", line 292, in __call__
    sys.exit(self.workloop(pid=pid))
[2023-06-23 18:21:35,638: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/billiard/pool.py", line 362, in workloop
    result = (True, prepare_result(fun(*args, **kwargs)))
[2023-06-23 18:21:35,644: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/app/trace.py", line 649, in fast_trace_task
    R, I, T, Rstr = tasks[task].__trace__(
[2023-06-23 18:21:35,649: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/celery/app/trace.py", line 451, in trace_task
    R = retval = fun(*args, **kwargs)
[2023-06-23 18:21:35,652: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/nautobot/extras/jobs.py", line 147, in __call__
    return self.run(*args, **deserialized_kwargs)
[2023-06-23 18:21:35,655: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/nautobot_owners/list_owner_bastard_ip.py", line 63, in run
    self.logger.info(extra={"object": custom_field}, msg=f"This triggers TypeError.")
[2023-06-23 18:21:35,659: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/logging/__init__.py", line 1477, in info
    self._log(INFO, msg, args, **kwargs)
[2023-06-23 18:21:35,664: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/logging/__init__.py", line 1624, in _log
    self.handle(record)
[2023-06-23 18:21:35,667: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/logging/__init__.py", line 1634, in handle
    self.callHandlers(record)
[2023-06-23 18:21:35,671: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/logging/__init__.py", line 1696, in callHandlers
    hdlr.handle(record)
[2023-06-23 18:21:35,674: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/logging/__init__.py", line 968, in handle
    self.emit(record)
[2023-06-23 18:21:35,679: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/nautobot/core/celery/log.py", line 38, in emit
    self.handleError(record)
[2023-06-23 18:21:35,684: WARNING/ForkPoolWorker-6] Message: 'This triggers TypeError.'
Arguments: ()
```


Discussion:
- Implement a `get_absolute_url` for `CustomFieldChoice` to point to the `CustomField`. May affect 1.X as well?
- Capture failed calls to `get_absolute_url` because other models may error
Relevant line in the traceback:

```py
[2023-06-23 18:21:35,108: WARNING/ForkPoolWorker-6]   File "/usr/local/lib/python3.10/site-packages/nautobot/extras/models/jobs.py", line 713, in log
    absolute_url=obj.get_absolute_url()[:JOB_LOG_MAX_ABSOLUTE_URL_LENGTH]
```
Looks to be a potential issue in 1.x as well, but probably exacerbated in 2.0 by #3632.

Resolving patch:
diff --git a/nautobot/extras/models/customfields.py b/nautobot/extras/models/customfields.py
--- a/nautobot/extras/models/customfields.py
+++ b/nautobot/extras/models/customfields.py
@@ -696,6 +696,10 @@ class Meta:
     def __str__(self):
         return self.value
 
+    def get_absolute_url(self):
+        # 2.0 TODO: replace slug with pk
+        return reverse("extras:customfield", args=[self.field.slug])
+
     def clean(self):
         if self.field.type not in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
             raise ValidationError("Custom field choices can only be assigned to selection fields.")
diff --git a/nautobot/extras/models/jobs.py b/nautobot/extras/models/jobs.py
--- a/nautobot/extras/models/jobs.py
+++ b/nautobot/extras/models/jobs.py
@@ -824,6 +824,17 @@ def log(
 
         message = sanitize(str(message))
 
+        obj_absolute_url = None
+        if obj is not None and hasattr(obj, "get_absolute_url"):
+            try:
+                absolute_url = obj.get_absolute_url()
+                # If absolute_url is longer than we can store, we discard it instead of truncating,
+                # since a truncated URL is pretty useless.
+                if len(absolute_url) <= JOB_LOG_MAX_ABSOLUTE_URL_LENGTH:
+                    obj_absolute_url = absolute_url
+            except Exception:
+                pass
+
         log = JobLogEntry(
             job_result=self,
             log_level=level_choice,
@@ -831,9 +842,7 @@ def log(
             message=message,
             created=timezone.now().isoformat(),
             log_object=str(obj)[:JOB_LOG_MAX_LOG_OBJECT_LENGTH] if obj else None,
-            absolute_url=obj.get_absolute_url()[:JOB_LOG_MAX_ABSOLUTE_URL_LENGTH]
-            if hasattr(obj, "get_absolute_url")
-            else None,
+            absolute_url=obj_absolute_url,
         )
 
         # If the override is provided, we want to use the default database(pass no using argument)
@@ -854,6 +863,8 @@ def log(
                 log_level = logging.INFO
             logger.log(log_level, message)
 
+        return log
+
 
 #
 # Job Button


Test patch:
diff --git a/nautobot/extras/tests/test_models.py b/nautobot/extras/tests/test_models.py
--- a/nautobot/extras/tests/test_models.py
+++ b/nautobot/extras/tests/test_models.py
@@ -23,7 +23,12 @@
     Platform,
     Site,
 )
-from nautobot.extras.constants import JOB_OVERRIDABLE_FIELDS
+from nautobot.extras.constants import (
+    JOB_LOG_MAX_ABSOLUTE_URL_LENGTH,
+    JOB_LOG_MAX_GROUPING_LENGTH,
+    JOB_LOG_MAX_LOG_OBJECT_LENGTH,
+    JOB_OVERRIDABLE_FIELDS,
+)
 from nautobot.extras.choices import LogLevelChoices, SecretsGroupAccessTypeChoices, SecretsGroupSecretTypeChoices
 from nautobot.extras.jobs import get_job, Job as JobClass
 from nautobot.extras.models import (
@@ -948,6 +953,54 @@ def test_related_object(self):
         job_result.job_id = uuid.uuid4()
         self.assertIsNone(job_result.related_object)
 
+    def test_log(self):
+        """Test that logs are rendered correctly."""
+        job_result = JobResult.objects.create(name="irrelevant", obj_type=get_job_content_type(), job_id=uuid.uuid4())
+        job_result.use_job_logs_db = False
+
+        # Most basic usage
+        log = job_result.log("Hello")
+        self.assertEqual("Hello", log.message)
+        self.assertEqual(LogLevelChoices.LOG_DEFAULT, log.log_level)
+        self.assertEqual("main", log.grouping)
+        self.assertIsNone(log.log_object)
+        self.assertIsNone(log.absolute_url)
+
+        # Advanced usage
+        obj = IPAddress.objects.create(address="1.1.1.1/32")
+        log = job_result.log("Hi", obj=obj, level_choice=LogLevelChoices.LOG_WARNING, grouping="other")
+        self.assertEqual("Hi", log.message)
+        self.assertEqual(LogLevelChoices.LOG_WARNING, log.log_level)
+        self.assertEqual("other", log.grouping)
+        self.assertEqual(str(obj), log.log_object)
+        self.assertEqual(obj.get_absolute_url(), log.absolute_url)
+
+        # Length constraints
+        class MockObject1:
+            def __str__(self):
+                return "a" * (JOB_LOG_MAX_LOG_OBJECT_LENGTH * 2)
+
+            def get_absolute_url(self):
+                return "b" * (JOB_LOG_MAX_ABSOLUTE_URL_LENGTH * 2)
+
+        obj = MockObject1()
+        log = job_result.log("Hi", obj=obj, grouping="c" * JOB_LOG_MAX_GROUPING_LENGTH * 2)
+        self.assertEqual("Hi", log.message)
+        self.assertEqual("a" * JOB_LOG_MAX_LOG_OBJECT_LENGTH, log.log_object)
+        self.assertEqual("c" * JOB_LOG_MAX_GROUPING_LENGTH, log.grouping)
+        self.assertIsNone(log.absolute_url)
+
+        # Error handling
+        class MockObject2(MockObject1):
+            def get_absolute_url(self):
+                raise NotImplementedError()
+
+        obj = MockObject2()
+        log = job_result.log("Hi", obj=obj)
+        self.assertEqual("Hi", log.message)
+        self.assertEqual("a" * JOB_LOG_MAX_LOG_OBJECT_LENGTH, log.log_object)
+        self.assertIsNone(log.absolute_url)
+
 
 class SecretTest(TestCase):
     """