Repository: nautobot/nautobot

Issue:
Job Buttons do not commit changes in their 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): 1.6.11
* Python version: 3.11
* Database platform, version: MySQL
* Middleware(s): NA

<!--
    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  JobButtonReciever, set it to commit by default.
2. Create a Job Button and assign to a ContentType.
3. Click the Job Button on the assigned Content Type.

<!-- What did you expect to happen? -->
### Expected Behavior

The job runs and commits the changes.

<!-- What happened instead? -->
### Observed Behavior

The Job runs and the database changes are reverted.
![image](https://github.com/nautobot/nautobot/assets/17092348/c98724cf-ec91-4e58-aad2-b940346b80b8)



Discussion:

Resolving patch:
diff --git a/nautobot/extras/templatetags/job_buttons.py b/nautobot/extras/templatetags/job_buttons.py
--- a/nautobot/extras/templatetags/job_buttons.py
+++ b/nautobot/extras/templatetags/job_buttons.py
@@ -29,6 +29,7 @@
 <input type="hidden" name="object_model_name" value="{object_model_name}">
 <input type="hidden" name="_schedule_type" value="immediately">
 <input type="hidden" name="_return_url" value="{redirect_path}">
+<input type="hidden" name="_commit" value="on">
 """
 
 NO_CONFIRM_BUTTON = """
@@ -170,8 +171,10 @@ def job_buttons(context, obj):
         for jb in buttons:
             # Render grouped buttons as list items
             button_html, form_html = _render_job_button_for_obj(jb, obj, context, content_type)
-            buttons_rendered += format_html("<li>{}</li>", button_html)
-            forms_html += form_html
+            if button_html:
+                buttons_rendered += format_html("<li>{}</li>", button_html)
+            if form_html:
+                forms_html += form_html
 
         if buttons_rendered:
             buttons_html += format_html(


Test patch:
diff --git a/nautobot/extras/tests/test_templatetags.py b/nautobot/extras/tests/test_templatetags.py
new file mode 100644
--- /dev/null
+++ b/nautobot/extras/tests/test_templatetags.py
@@ -0,0 +1,198 @@
+"""Test Templatetags provided by Extras."""
+
+from django.contrib.contenttypes.models import ContentType
+from django.test.client import RequestFactory
+from django.urls import reverse
+from nautobot.dcim.models import Site
+from nautobot.extras.models import Job, JobButton
+from nautobot.extras.templatetags.job_buttons import job_buttons
+from nautobot.utilities.testing import TestCase
+
+
+class JobButtonsTest(TestCase):
+    """Test Rendering of Job Buttons."""
+
+    def setUp(self):
+        super().setUp()
+        self.site = Site.objects.create(name="Test", slug="test")
+        self.job = Job.objects.get(job_class_name="TestJobButtonReceiverSimple")
+
+        self.site_type = ContentType.objects.get_for_model(Site)
+
+        job_button_config = {
+            "name": "Job Button Site",
+            "job": self.job,
+            "defaults": {
+                "text": "Job Button Site",
+                "button_class": "primary",
+            },
+        }
+        self.jobbutton, _ = JobButton.objects.get_or_create(**job_button_config)
+        self.jobbutton.content_types.set([self.site_type])
+
+    def test_job_buttons_non_grouped(self):
+        """Job Button without a group and missing permissions renders disabled with a confirmation."""
+
+        result = self._build_request()
+        self.assertIn(
+            f'id="confirm_modal_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn(
+            f'id="form_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn("disabled", result)
+
+    def test_job_buttons_non_grouped_no_confirm(self):
+        """Job Button without a group and missing permissions renders disabled without a confirmation."""
+
+        self.jobbutton.confirmation = False
+        self.jobbutton.save()
+
+        result = self._build_request()
+        self.assertNotIn(
+            f'id="confirm_modal_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn(
+            f'id="form_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn("disabled", result)
+
+    def test_job_buttons_non_grouped_perms(self):
+        """Job Button without a group renders with a confirmation."""
+
+        self.add_permissions("extras.run_jobbutton", "extras.run_job")
+
+        result = self._build_request()
+        self.assertIn(
+            f'id="confirm_modal_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn(
+            f'id="form_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertNotIn("disabled", result)
+
+    def test_job_buttons_non_grouped_conditional_rendering(self):
+        """Job Button Conditional Rendering Success Path renders."""
+
+        self.add_permissions("extras.run_jobbutton", "extras.run_job")
+        self.jobbutton.text = "{% if obj.slug == 'test' %}Job Button Site{% endif %}"
+        self.jobbutton.save()
+
+        result = self._build_request()
+        self.assertIn(
+            f'id="confirm_modal_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn(
+            f'id="form_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertNotIn("disabled", result)
+
+    def test_job_buttons_non_grouped_conditional_rendering_false(self):
+        """Job Button Conditional Rendering Failure Path does not render."""
+
+        self.add_permissions("extras.run_jobbutton", "extras.run_job")
+        self.jobbutton.text = "{% if obj.slug == 'nope' %}Job Button Site{% endif %}"
+        self.jobbutton.save()
+
+        result = self._build_request()
+        self.assertEqual(
+            "",
+            result,
+        )
+
+    def test_job_buttons_non_grouped_conditional_rendering_exception(self):
+        """Job Button Conditional Rendering Exception Path renders."""
+
+        self.add_permissions("extras.run_jobbutton", "extras.run_job")
+        self.jobbutton.text = "{% if obj.slug == 'nope' %}Job Button Site"
+        self.jobbutton.save()
+
+        result = self._build_request()
+        self.assertNotIn(
+            f'id="confirm_modal_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertNotIn(
+            f'id="form_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn("disabled", result)
+
+    def test_job_buttons_grouped(self):
+        """Job Button with a group and missing permissions renders disabled with a confirmation."""
+
+        self.jobbutton.group_name = "Site Buttons"
+        self.jobbutton.save()
+
+        result = self._build_request()
+        self.assertIn(
+            f'id="confirm_modal_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn(
+            f'id="form_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn("Site Buttons", result)
+        self.assertIn("disabled", result)
+
+    def test_job_buttons_grouped_perms(self):
+        """Job Button with a group and renders with a confirmation."""
+
+        self.add_permissions("extras.run_jobbutton", "extras.run_job")
+        self.jobbutton.group_name = "Site Buttons"
+        self.jobbutton.save()
+
+        result = self._build_request()
+        self.assertIn(
+            f'id="confirm_modal_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn(
+            f'id="form_id_{self.jobbutton.pk}"',
+            result,
+        )
+        self.assertIn("Site Buttons", result)
+        self.assertNotIn("disabled", result)
+
+    def test_job_buttons_grouped_conditional_rendering_false(self):
+        """Job Button with a group does not render if Conditional Rendering False."""
+
+        self.add_permissions("extras.run_jobbutton", "extras.run_job")
+        self.jobbutton.group_name = "Site Buttons"
+        self.jobbutton.text = "{% if obj.slug == 'nope' %}Job Site Button{% endif %}"
+        self.jobbutton.save()
+
+        result = self._build_request()
+        self.assertEqual("", result)
+
+    def test_job_buttons_no_buttons(self):
+        self.jobbutton.delete()
+        self.assertEqual(
+            "",
+            self._build_request(),
+        )
+
+    def _build_request(self):
+        request = RequestFactory().get(
+            reverse(
+                "dcim:site",
+                kwargs={"slug": self.site.slug},
+            )
+        )
+        context = {
+            "request": request,
+            "user": self.user,
+            "perms": {},
+            "csrf_token": "",
+            "settings": {},
+        }
+        return job_buttons(context=context, obj=self.site)
diff --git a/nautobot/ipam/tests/test_filters.py b/nautobot/ipam/tests/test_filters.py
--- a/nautobot/ipam/tests/test_filters.py
+++ b/nautobot/ipam/tests/test_filters.py
@@ -895,12 +895,16 @@ def test_vid(self):
     def test_region(self):
         regions = list(self.regions[:2])
         params = {"region_id": [regions[0].pk, regions[1].pk]}
+        # For some reason, our Factory classes are now generating two VLANs with the same VID and site and a null group,
+        # and since VLAN.meta.ordering == ["site", "group", "vid"] the sort order of these two VLANs is indeterminate.
+        # This was causing this test to consistently fail on postgres (but not mysql), so we're working around it
+        # by adding the "ordered=False" parameter to both of the below assertions.
         self.assertQuerysetEqual(
-            self.filterset(params, self.queryset).qs, self.queryset.filter(site__region__in=regions)
+            self.filterset(params, self.queryset).qs, self.queryset.filter(site__region__in=regions), ordered=False
         )
         params = {"region": [regions[0].slug, regions[1].slug]}
         self.assertQuerysetEqual(
-            self.filterset(params, self.queryset).qs, self.queryset.filter(site__region__in=regions)
+            self.filterset(params, self.queryset).qs, self.queryset.filter(site__region__in=regions), ordered=False
         )
 
     def test_site(self):