nautobot 2.4.3__py3-none-any.whl → 2.4.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. nautobot/apps/filters.py +2 -0
  2. nautobot/circuits/filters.py +1 -1
  3. nautobot/circuits/tests/test_models.py +5 -3
  4. nautobot/cloud/filters.py +3 -6
  5. nautobot/cloud/tests/test_filters.py +21 -0
  6. nautobot/core/admin.py +2 -0
  7. nautobot/core/jobs/__init__.py +2 -1
  8. nautobot/core/management/commands/generate_performance_test_endpoints.py +9 -6
  9. nautobot/core/models/utils.py +6 -1
  10. nautobot/core/templates/inc/javascript.html +1 -0
  11. nautobot/core/templatetags/ui_framework.py +20 -4
  12. nautobot/core/testing/forms.py +1 -1
  13. nautobot/core/tests/test_api.py +1 -1
  14. nautobot/core/tests/test_graphql.py +3 -3
  15. nautobot/core/tests/test_jobs.py +4 -1
  16. nautobot/core/ui/object_detail.py +1 -1
  17. nautobot/dcim/api/serializers.py +36 -0
  18. nautobot/dcim/api/views.py +1 -1
  19. nautobot/dcim/elevations.py +17 -4
  20. nautobot/dcim/factory.py +9 -1
  21. nautobot/dcim/filters/__init__.py +27 -1
  22. nautobot/dcim/forms.py +13 -1
  23. nautobot/dcim/models/devices.py +11 -5
  24. nautobot/dcim/signals.py +26 -0
  25. nautobot/dcim/templates/dcim/virtualdevicecontext_retrieve.html +0 -62
  26. nautobot/dcim/templates/dcim/virtualdevicecontext_update.html +6 -0
  27. nautobot/dcim/tests/test_api.py +176 -0
  28. nautobot/dcim/tests/test_filters.py +56 -3
  29. nautobot/dcim/tests/test_models.py +40 -0
  30. nautobot/dcim/views.py +24 -14
  31. nautobot/extras/api/mixins.py +1 -1
  32. nautobot/extras/api/views.py +2 -2
  33. nautobot/extras/filters/__init__.py +4 -0
  34. nautobot/extras/models/datasources.py +7 -3
  35. nautobot/extras/plugins/__init__.py +26 -1
  36. nautobot/extras/templates/extras/inc/jobresult.html +12 -13
  37. nautobot/extras/templates/extras/objectchange.html +28 -12
  38. nautobot/extras/tests/test_api.py +16 -15
  39. nautobot/extras/tests/test_filters.py +2 -0
  40. nautobot/extras/tests/test_plugins.py +32 -1
  41. nautobot/extras/tests/test_views.py +12 -2
  42. nautobot/extras/views.py +3 -0
  43. nautobot/ipam/api/serializers.py +7 -8
  44. nautobot/ipam/api/views.py +2 -2
  45. nautobot/ipam/factory.py +27 -8
  46. nautobot/ipam/filters.py +67 -29
  47. nautobot/ipam/formfields.py +51 -0
  48. nautobot/ipam/forms.py +13 -1
  49. nautobot/ipam/migrations/0051_added_optional_vrf_relationship_to_vdc.py +41 -0
  50. nautobot/ipam/models.py +63 -5
  51. nautobot/ipam/tables.py +21 -7
  52. nautobot/ipam/tests/test_api.py +107 -66
  53. nautobot/ipam/tests/test_filters.py +145 -5
  54. nautobot/ipam/tests/test_views.py +15 -2
  55. nautobot/project-static/css/base.css +11 -0
  56. nautobot/project-static/css/dark.css +2 -1
  57. nautobot/project-static/docs/code-reference/nautobot/apps/filters.html +62 -0
  58. nautobot/project-static/docs/development/apps/api/configuration-view.html +0 -3
  59. nautobot/project-static/docs/development/apps/api/models/graphql.html +0 -4
  60. nautobot/project-static/docs/development/apps/api/platform-features/custom-validators.html +94 -1
  61. nautobot/project-static/docs/development/apps/api/platform-features/filter-extensions.html +0 -3
  62. nautobot/project-static/docs/development/apps/api/platform-features/jinja2-filters.html +0 -3
  63. nautobot/project-static/docs/development/apps/api/platform-features/populating-extensibility-features.html +0 -3
  64. nautobot/project-static/docs/development/apps/api/platform-features/secrets-providers.html +0 -3
  65. nautobot/project-static/docs/development/apps/api/prometheus.html +0 -3
  66. nautobot/project-static/docs/development/apps/api/testing.html +0 -6
  67. nautobot/project-static/docs/development/apps/api/ui-extensions/banners.html +0 -3
  68. nautobot/project-static/docs/development/apps/api/ui-extensions/home-page.html +0 -3
  69. nautobot/project-static/docs/development/apps/api/ui-extensions/object-views.html +0 -3
  70. nautobot/project-static/docs/development/apps/api/views/core-view-overrides.html +0 -3
  71. nautobot/project-static/docs/development/apps/api/views/nautobot-generic-views.html +1 -7
  72. nautobot/project-static/docs/development/apps/api/views/nautobotuiviewset.html +0 -7
  73. nautobot/project-static/docs/development/apps/api/views/nautobotuiviewsetrouter.html +0 -4
  74. nautobot/project-static/docs/development/apps/api/views/notes.html +0 -3
  75. nautobot/project-static/docs/development/apps/index.html +2 -35
  76. nautobot/project-static/docs/development/apps/migration/code-updates.html +1 -1
  77. nautobot/project-static/docs/development/core/application-registry.html +0 -6
  78. nautobot/project-static/docs/development/core/best-practices.html +0 -27
  79. nautobot/project-static/docs/development/core/docker-compose-advanced-use-cases.html +58 -4
  80. nautobot/project-static/docs/development/core/getting-started.html +12 -16
  81. nautobot/project-static/docs/development/core/homepage.html +0 -3
  82. nautobot/project-static/docs/development/core/style-guide.html +0 -5
  83. nautobot/project-static/docs/development/core/templates.html +0 -3
  84. nautobot/project-static/docs/development/core/testing.html +0 -9
  85. nautobot/project-static/docs/development/jobs/index.html +3 -29
  86. nautobot/project-static/docs/objects.inv +0 -0
  87. nautobot/project-static/docs/overview/application_stack.html +0 -18
  88. nautobot/project-static/docs/release-notes/version-2.4.html +191 -0
  89. nautobot/project-static/docs/requirements.txt +1 -1
  90. nautobot/project-static/docs/search/search_index.json +1 -1
  91. nautobot/project-static/docs/sitemap.xml +290 -290
  92. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  93. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +0 -10
  94. nautobot/project-static/docs/user-guide/administration/guides/docker.html +0 -15
  95. nautobot/project-static/docs/user-guide/administration/installation/index.html +0 -16
  96. nautobot/project-static/docs/user-guide/administration/installation/nautobot.html +1 -4
  97. nautobot/project-static/docs/user-guide/administration/installation/services.html +0 -11
  98. nautobot/project-static/docs/user-guide/administration/migration/migrating-from-postgresql.html +3 -3
  99. nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +5 -35
  100. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/tables/v2-code-location-changes.yaml +1 -1
  101. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +1 -1
  102. nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +0 -4
  103. nautobot/project-static/docs/user-guide/core-data-model/circuits/providernetwork.html +0 -3
  104. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleport.html +0 -4
  105. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleporttemplate.html +0 -4
  106. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverport.html +0 -4
  107. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverporttemplate.html +0 -4
  108. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebay.html +0 -4
  109. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebaytemplate.html +0 -4
  110. nautobot/project-static/docs/user-guide/core-data-model/dcim/deviceredundancygroup.html +0 -3
  111. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicetype.html +0 -4
  112. nautobot/project-static/docs/user-guide/core-data-model/dcim/frontport.html +0 -4
  113. nautobot/project-static/docs/user-guide/core-data-model/dcim/frontporttemplate.html +0 -4
  114. nautobot/project-static/docs/user-guide/core-data-model/dcim/interface.html +1 -17
  115. nautobot/project-static/docs/user-guide/core-data-model/dcim/interfaceredundancygroup.html +0 -3
  116. nautobot/project-static/docs/user-guide/core-data-model/dcim/interfacetemplate.html +0 -4
  117. nautobot/project-static/docs/user-guide/core-data-model/dcim/inventoryitem.html +0 -4
  118. nautobot/project-static/docs/user-guide/core-data-model/dcim/location.html +0 -3
  119. nautobot/project-static/docs/user-guide/core-data-model/dcim/locationtype.html +1 -7
  120. nautobot/project-static/docs/user-guide/core-data-model/dcim/platform.html +0 -4
  121. nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlet.html +0 -4
  122. nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlettemplate.html +0 -4
  123. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerport.html +0 -4
  124. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerporttemplate.html +0 -4
  125. nautobot/project-static/docs/user-guide/core-data-model/dcim/rearport.html +0 -4
  126. nautobot/project-static/docs/user-guide/core-data-model/dcim/rearporttemplate.html +0 -4
  127. nautobot/project-static/docs/user-guide/core-data-model/extras/configcontext.html +0 -6
  128. nautobot/project-static/docs/user-guide/core-data-model/extras/configcontextschema.html +0 -3
  129. nautobot/project-static/docs/user-guide/core-data-model/ipam/ipaddress.html +0 -4
  130. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +0 -4
  131. nautobot/project-static/docs/user-guide/core-data-model/virtualization/vminterface.html +0 -8
  132. nautobot/project-static/docs/user-guide/feature-guides/custom-fields.html +3 -3
  133. nautobot/project-static/docs/user-guide/feature-guides/graphql.html +0 -6
  134. nautobot/project-static/docs/user-guide/platform-functionality/computedfield.html +0 -3
  135. nautobot/project-static/docs/user-guide/platform-functionality/customfield.html +3 -15
  136. nautobot/project-static/docs/user-guide/platform-functionality/dynamicgroup.html +0 -26
  137. nautobot/project-static/docs/user-guide/platform-functionality/gitrepository.html +0 -8
  138. nautobot/project-static/docs/user-guide/platform-functionality/graphql.html +0 -3
  139. nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +0 -8
  140. nautobot/project-static/docs/user-guide/platform-functionality/jobs/job-scheduling-and-approvals.html +0 -7
  141. nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobbutton.html +0 -3
  142. nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobhook.html +0 -3
  143. nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +0 -14
  144. nautobot/project-static/docs/user-guide/platform-functionality/note.html +0 -3
  145. nautobot/project-static/docs/user-guide/platform-functionality/relationship.html +1 -10
  146. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/authentication.html +0 -3
  147. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/filtering.html +0 -14
  148. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/overview.html +0 -19
  149. nautobot/project-static/docs/user-guide/platform-functionality/secret.html +3 -9
  150. nautobot/project-static/docs/user-guide/platform-functionality/status.html +0 -8
  151. nautobot/project-static/docs/user-guide/platform-functionality/tag.html +0 -4
  152. nautobot/project-static/docs/user-guide/platform-functionality/template-filters.html +1 -13
  153. nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +0 -5
  154. nautobot/project-static/js/editor.js +292 -0
  155. nautobot/project-static/monaco-editor-0.52.2/README.md +81 -0
  156. nautobot/project-static/monaco-editor-0.52.2/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  157. nautobot/project-static/monaco-editor-0.52.2/vs/base/worker/workerMain.js +31 -0
  158. nautobot/project-static/monaco-editor-0.52.2/vs/basic-languages/xml/xml.js +10 -0
  159. nautobot/project-static/monaco-editor-0.52.2/vs/basic-languages/yaml/yaml.js +10 -0
  160. nautobot/project-static/monaco-editor-0.52.2/vs/editor/editor.main.css +8 -0
  161. nautobot/project-static/monaco-editor-0.52.2/vs/editor/editor.main.js +798 -0
  162. nautobot/project-static/monaco-editor-0.52.2/vs/language/json/jsonMode.js +19 -0
  163. nautobot/project-static/monaco-editor-0.52.2/vs/language/json/jsonWorker.js +42 -0
  164. nautobot/project-static/monaco-editor-0.52.2/vs/loader.js +11 -0
  165. nautobot/tenancy/filters/__init__.py +3 -5
  166. nautobot/tenancy/tests/test_filters.py +10 -0
  167. nautobot/virtualization/views.py +0 -1
  168. nautobot/wireless/tables.py +9 -4
  169. nautobot/wireless/tests/test_api.py +0 -9
  170. {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/METADATA +2 -2
  171. {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/RECORD +175 -163
  172. {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/LICENSE.txt +0 -0
  173. {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/NOTICE +0 -0
  174. {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/WHEEL +0 -0
  175. {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/entry_points.txt +0 -0
@@ -4,65 +4,3 @@
4
4
  {% block extra_breadcrumbs %}
5
5
  <li><a href="{% url 'dcim:device' pk=object.device.pk %}">{{ object.device }}</a></li>
6
6
  {% endblock extra_breadcrumbs %}
7
-
8
-
9
- {% block content_left_page %}
10
- <div class="panel panel-default">
11
- <div class="panel-heading">
12
- <strong>Virtual Device Context</strong>
13
- </div>
14
- <table class="table table-hover panel-body attr-table">
15
- <tr>
16
- <td>Name</td>
17
- <td>
18
- {{ object.name }}
19
- </td>
20
- </tr>
21
- <tr>
22
- <td>Identifier</td>
23
- <td>
24
- {{ object.identifier }}
25
- </td>
26
- </tr>
27
- <tr>
28
- <td>Role</td>
29
- <td>
30
- {{ object.role| hyperlinked_object_with_color }}
31
- </td>
32
- </tr>
33
- <tr>
34
- <td>Status</td>
35
- <td>
36
- {{ object.status| hyperlinked_object_with_color }}
37
- </td>
38
- </tr>
39
- <tr>
40
- <td>Device</td>
41
- <td>
42
- {{ object.device|hyperlinked_object }}
43
- </td>
44
- </tr>
45
- <tr>
46
- <td>Primary IPv4</td>
47
- <td>
48
- {{ object.primary_ip4|hyperlinked_object }}
49
- </td>
50
- </tr>
51
- <tr>
52
- <td>Primary IPv6</td>
53
- <td>
54
- {{ object.primary_ip6|hyperlinked_object }}
55
- </td>
56
- </tr>
57
- {% include 'inc/tenant_table_row.html' %}
58
- <tr>
59
- <td>Description</td>
60
- <td>{{ object.description|placeholder }}</td>
61
- </tr>
62
- </table>
63
- </div>
64
- {% endblock content_left_page %}
65
-
66
- {% block content_full_width_page %}
67
- {% include 'panel_table.html' with table=interfaces_table heading="Interfaces" %}
68
- {% endblock content_full_width_page %}
@@ -23,6 +23,12 @@
23
23
  {% endif %}
24
24
  </div>
25
25
  </div>
26
+ <div class="panel panel-default">
27
+ <div class="panel-heading"><strong>VRF Assignments</strong></div>
28
+ <div class="panel-body">
29
+ {% render_field form.vrfs %}
30
+ </div>
31
+ </div>
26
32
  {% include 'inc/tenancy_form_panel.html' %}
27
33
  {% include 'inc/extras_features_edit_form_fields.html' %}
28
34
  {% endblock %}
@@ -1716,6 +1716,182 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
1716
1716
  )
1717
1717
  self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1718
1718
 
1719
+ def _parent_device_test_data(self):
1720
+ location = Location.objects.filter(location_type=LocationType.objects.get(name="Campus")).first()
1721
+ device_status = Status.objects.get_for_model(Device).first()
1722
+ device_role = Role.objects.get_for_model(Device).first()
1723
+ device_type = DeviceType.objects.first()
1724
+
1725
+ device_type_parent = DeviceType.objects.create(
1726
+ manufacturer=device_type.manufacturer,
1727
+ model=f"{device_type.model} Parent",
1728
+ u_height=device_type.u_height,
1729
+ subdevice_role=SubdeviceRoleChoices.ROLE_PARENT,
1730
+ )
1731
+ device_type_child = DeviceType.objects.create(
1732
+ manufacturer=device_type.manufacturer,
1733
+ model=f"{device_type.model} Child",
1734
+ u_height=device_type.u_height,
1735
+ subdevice_role=SubdeviceRoleChoices.ROLE_CHILD,
1736
+ )
1737
+
1738
+ parent_device = Device.objects.create(
1739
+ device_type=device_type_parent,
1740
+ role=device_role,
1741
+ status=device_status,
1742
+ name="Device Parent",
1743
+ location=location,
1744
+ )
1745
+ device_bay_1 = DeviceBay.objects.create(name="db1", device_id=parent_device.pk)
1746
+ device_bay_2 = DeviceBay.objects.create(name="db2", device_id=parent_device.pk)
1747
+
1748
+ return parent_device, device_bay_1, device_bay_2, device_type_child
1749
+
1750
+ def test_creating_device_with_parent_bay(self):
1751
+ # Create test data
1752
+ parent_device, device_bay_1, device_bay_2, device_type_child = self._parent_device_test_data()
1753
+
1754
+ self.add_permissions("dcim.add_device")
1755
+ url = reverse("dcim-api:device-list")
1756
+
1757
+ # Test creating device with parent bay by device bay data
1758
+ data = {
1759
+ "device_type": device_type_child.pk,
1760
+ "role": parent_device.role.pk,
1761
+ "location": parent_device.location.pk,
1762
+ "name": "Device parent bay test #1",
1763
+ "status": parent_device.status.pk,
1764
+ "parent_bay": {"device": {"name": parent_device.name}, "name": device_bay_1.name},
1765
+ }
1766
+
1767
+ response = self.client.post(url, data, format="json", **self.header)
1768
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
1769
+
1770
+ created_device = Device.objects.get(name="Device parent bay test #1")
1771
+ self.assertEqual(created_device.parent_bay.pk, device_bay_1.pk)
1772
+
1773
+ # Test creating device with parent bay by device_bay.pk
1774
+ data = {
1775
+ "device_type": device_type_child.pk,
1776
+ "role": parent_device.role.pk,
1777
+ "location": parent_device.location.pk,
1778
+ "name": "Device parent bay test #2",
1779
+ "status": parent_device.status.pk,
1780
+ "parent_bay": device_bay_2.pk,
1781
+ }
1782
+
1783
+ response = self.client.post(url, data, format="json", **self.header)
1784
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
1785
+
1786
+ created_device = Device.objects.get(name="Device parent bay test #2")
1787
+ self.assertEqual(created_device.parent_bay.pk, device_bay_2.pk)
1788
+
1789
+ # Test creating device with parent bay already taken
1790
+ data = {
1791
+ "device_type": device_type_child.pk,
1792
+ "role": parent_device.role.pk,
1793
+ "location": parent_device.location.pk,
1794
+ "name": "Device parent bay test #3",
1795
+ "status": parent_device.status.pk,
1796
+ "parent_bay": device_bay_1.pk,
1797
+ }
1798
+
1799
+ response = self.client.post(url, data, format="json", **self.header)
1800
+ self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1801
+ self.assertIn("Cannot install device; parent bay is already taken", response.content.decode(response.charset))
1802
+
1803
+ # Assert that on the #1 device, parent bay stayed the same
1804
+ old_device = Device.objects.get(name="Device parent bay test #1")
1805
+ self.assertEqual(old_device.parent_bay.pk, device_bay_1.pk)
1806
+
1807
+ def test_update_device_with_parent_bay(self):
1808
+ # Create test data
1809
+ parent_device, device_bay_1, device_bay_2, device_type_child = self._parent_device_test_data()
1810
+
1811
+ self.add_permissions("dcim.change_device")
1812
+
1813
+ child_device = Device.objects.create(
1814
+ device_type=device_type_child,
1815
+ role=parent_device.role,
1816
+ location=parent_device.location,
1817
+ name="Device parent bay test #4",
1818
+ status=parent_device.status,
1819
+ )
1820
+ # Test setting parent bay during the update
1821
+ patch_data = {"parent_bay": device_bay_1.pk}
1822
+ response = self.client.patch(self._get_detail_url(child_device), patch_data, format="json", **self.header)
1823
+ self.assertHttpStatus(response, status.HTTP_200_OK)
1824
+
1825
+ updated_device = Device.objects.get(name="Device parent bay test #4")
1826
+ self.assertEqual(updated_device.parent_bay.pk, device_bay_1.pk)
1827
+
1828
+ # Changing the parent bay is not allowed without removing it first
1829
+ patch_data = {"parent_bay": device_bay_2.pk}
1830
+ response = self.client.patch(self._get_detail_url(child_device), patch_data, format="json", **self.header)
1831
+ self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1832
+ self.assertIn(
1833
+ f"Cannot install the specified device; device is already installed in {device_bay_1.name}",
1834
+ response.content.decode(response.charset),
1835
+ )
1836
+
1837
+ # Assert that parent bay stayed the same
1838
+ updated_device = Device.objects.get(name="Device parent bay test #4")
1839
+ self.assertEqual(updated_device.parent_bay.pk, device_bay_1.pk)
1840
+
1841
+ def test_reassign_device_to_parent_bay(self):
1842
+ # Create test data
1843
+ parent_device, device_bay_1, device_bay_2, device_type_child = self._parent_device_test_data()
1844
+ device_name = "Device parent bay test #5"
1845
+ child_device = Device.objects.create(
1846
+ device_type=device_type_child,
1847
+ role=parent_device.role,
1848
+ location=parent_device.location,
1849
+ name=device_name,
1850
+ status=parent_device.status,
1851
+ )
1852
+ device_bay_1.installed_device = child_device
1853
+ device_bay_1.save()
1854
+
1855
+ self.add_permissions("dcim.change_device", "dcim.view_device", "dcim.change_devicebay")
1856
+ child_device_detail_url = self._get_detail_url(child_device)
1857
+
1858
+ response = self.client.get(child_device_detail_url, **self.header)
1859
+ self.assertHttpStatus(response, status.HTTP_200_OK)
1860
+ self.assertEqual(response.json()["parent_bay"]["id"], str(device_bay_1.pk))
1861
+
1862
+ # Test unassigning parent bay
1863
+ patch_data = {"parent_bay": None}
1864
+ response = self.client.patch(child_device_detail_url, patch_data, format="json", **self.header)
1865
+ self.assertHttpStatus(response, status.HTTP_200_OK)
1866
+
1867
+ child_device.refresh_from_db()
1868
+ with self.assertRaises(DeviceBay.DoesNotExist):
1869
+ child_device.parent_bay
1870
+
1871
+ # And assign it again
1872
+ patch_data = {"parent_bay": device_bay_2.pk}
1873
+ response = self.client.patch(child_device_detail_url, patch_data, format="json", **self.header)
1874
+ self.assertHttpStatus(response, status.HTTP_200_OK)
1875
+
1876
+ child_device.refresh_from_db()
1877
+ self.assertEqual(child_device.parent_bay.pk, device_bay_2.pk)
1878
+
1879
+ # Unassign it through device bay
1880
+ patch_data = {"installed_device": None}
1881
+ response = self.client.patch(self._get_detail_url(device_bay_2), patch_data, format="json", **self.header)
1882
+ self.assertHttpStatus(response, status.HTTP_200_OK)
1883
+
1884
+ child_device.refresh_from_db()
1885
+ self.assertFalse(hasattr(child_device, "parent_bay"))
1886
+
1887
+ # And assign through device bay
1888
+ patch_data = {"installed_device": child_device.pk}
1889
+ response = self.client.patch(self._get_detail_url(device_bay_1), patch_data, format="json", **self.header)
1890
+ self.assertHttpStatus(response, status.HTTP_200_OK)
1891
+
1892
+ child_device.refresh_from_db()
1893
+ self.assertEqual(child_device.parent_bay.pk, device_bay_1.pk)
1894
+
1719
1895
 
1720
1896
  class ModuleTestCase(APIViewTestCases.APIViewTestCase):
1721
1897
  model = Module
@@ -157,6 +157,8 @@ def common_test_data(cls):
157
157
  cls.loc0 = loc0
158
158
  cls.loc1 = loc1
159
159
  cls.nested_loc = nested_loc
160
+ cls.loc2 = loc2
161
+ cls.loc3 = loc3
160
162
 
161
163
  provider = Provider.objects.first()
162
164
  circuit_type = CircuitType.objects.first()
@@ -1150,6 +1152,24 @@ class RackGroupTestCase(FilterTestCases.FilterTestCase):
1150
1152
  name="Rack Group 4",
1151
1153
  location=cls.loc1,
1152
1154
  )
1155
+ RackGroup.objects.create(
1156
+ name="Rack Group 5",
1157
+ location=cls.loc2,
1158
+ description="C",
1159
+ )
1160
+ RackGroup.objects.create(
1161
+ name="Rack Group 6",
1162
+ location=cls.loc2,
1163
+ )
1164
+ RackGroup.objects.create(
1165
+ name="Rack Group 7",
1166
+ location=cls.loc3,
1167
+ description="C",
1168
+ )
1169
+ RackGroup.objects.create(
1170
+ name="Rack Group 8",
1171
+ location=cls.loc3,
1172
+ )
1153
1173
 
1154
1174
  def test_children(self):
1155
1175
  child_groups = RackGroup.objects.filter(name__startswith="Child").filter(parent__isnull=False)[:2]
@@ -1161,6 +1181,24 @@ class RackGroupTestCase(FilterTestCases.FilterTestCase):
1161
1181
  params = {"children": [rack_group_4.pk, rack_group_4.pk]}
1162
1182
  self.assertFalse(self.filterset(params, self.queryset).qs.exists())
1163
1183
 
1184
+ def test_ancestors(self):
1185
+ with self.subTest():
1186
+ pk_list = []
1187
+ parent_locations = self.loc3.ancestors(include_self=True)
1188
+ pk_list.extend([v.pk for v in parent_locations])
1189
+ params = Q(location__pk__in=pk_list)
1190
+ expected_queryset = RackGroup.objects.filter(params)
1191
+ params = {"ancestors": [self.loc3.pk]}
1192
+ self.assertQuerysetEqualAndNotEmpty(self.filterset(params, self.queryset).qs, expected_queryset)
1193
+ with self.subTest():
1194
+ pk_list = []
1195
+ parent_locations = self.loc2.ancestors(include_self=True)
1196
+ pk_list.extend([v.pk for v in parent_locations])
1197
+ params = Q(location__pk__in=pk_list)
1198
+ expected_queryset = RackGroup.objects.filter(params)
1199
+ params = {"ancestors": [self.loc2.pk]}
1200
+ self.assertQuerysetEqualAndNotEmpty(self.filterset(params, self.queryset).qs, expected_queryset)
1201
+
1164
1202
 
1165
1203
  class RackTestCase(FilterTestCases.FilterTestCase, FilterTestCases.TenancyFilterTestCaseMixin):
1166
1204
  queryset = Rack.objects.all()
@@ -4188,12 +4226,13 @@ class InterfaceVDCAssignmentTestCase(FilterTestCases.FilterTestCase):
4188
4226
 
4189
4227
  @classmethod
4190
4228
  def setUpTestData(cls):
4191
- device = Device.objects.first()
4229
+ device_1 = Device.objects.first()
4230
+ device_2 = Device.objects.last()
4192
4231
  vdc_status = Status.objects.get_for_model(VirtualDeviceContext)[0]
4193
4232
  interface_status = Status.objects.get_for_model(Interface)[0]
4194
4233
  interfaces = [
4195
4234
  Interface.objects.create(
4196
- device=device,
4235
+ device=device_1,
4197
4236
  type=InterfaceTypeChoices.TYPE_1GE_FIXED,
4198
4237
  name=f"Interface 00{idx}",
4199
4238
  status=interface_status,
@@ -4202,7 +4241,7 @@ class InterfaceVDCAssignmentTestCase(FilterTestCases.FilterTestCase):
4202
4241
  ]
4203
4242
  vdcs = [
4204
4243
  VirtualDeviceContext.objects.create(
4205
- device=device,
4244
+ device=device_1,
4206
4245
  status=vdc_status,
4207
4246
  identifier=200 + idx,
4208
4247
  name=f"Test VDC {idx}",
@@ -4213,3 +4252,17 @@ class InterfaceVDCAssignmentTestCase(FilterTestCases.FilterTestCase):
4213
4252
  InterfaceVDCAssignment.objects.create(virtual_device_context=vdcs[1], interface=interfaces[0])
4214
4253
  InterfaceVDCAssignment.objects.create(virtual_device_context=vdcs[1], interface=interfaces[1])
4215
4254
  InterfaceVDCAssignment.objects.create(virtual_device_context=vdcs[2], interface=interfaces[2])
4255
+ InterfaceVDCAssignment.objects.create(
4256
+ virtual_device_context=VirtualDeviceContext.objects.create(
4257
+ device=device_2,
4258
+ status=vdc_status,
4259
+ identifier=200,
4260
+ name="Test VDC 0",
4261
+ ),
4262
+ interface=Interface.objects.create(
4263
+ device=device_2,
4264
+ type=InterfaceTypeChoices.TYPE_1GE_FIXED,
4265
+ name="Interface 000",
4266
+ status=interface_status,
4267
+ ),
4268
+ )
@@ -1166,6 +1166,19 @@ class LocationTypeTestCase(TestCase):
1166
1166
  child_loc.delete()
1167
1167
  child.validated_save()
1168
1168
 
1169
+ def test_removing_content_type(self):
1170
+ """Validation check to prevent removing an in-use content type from a LocationType."""
1171
+
1172
+ location_type = LocationType.objects.get(name="Campus")
1173
+ device_ct = ContentType.objects.get_for_model(Device)
1174
+
1175
+ with self.assertRaises(ValidationError) as cm:
1176
+ location_type.content_types.remove(device_ct)
1177
+ self.assertIn(
1178
+ f"Cannot remove the content type {device_ct} as currently at least one device is associated to a location",
1179
+ str(cm.exception),
1180
+ )
1181
+
1169
1182
 
1170
1183
  class LocationTestCase(ModelTestCases.BaseModelTestCase):
1171
1184
  model = Location
@@ -1895,6 +1908,33 @@ class DeviceTestCase(ModelTestCases.BaseModelTestCase):
1895
1908
  parent_device.rack = rack
1896
1909
  parent_device.save()
1897
1910
 
1911
+ # Test assigning a rack in the child location of the parent device location
1912
+ location_status = Status.objects.get_for_model(Location).first()
1913
+ child_location = Location.objects.create(
1914
+ name="Child Location 1",
1915
+ location_type=self.location_type_3,
1916
+ status=location_status,
1917
+ parent=parent_device.location,
1918
+ )
1919
+ child_rack = Rack.objects.create(name="Rack 2", location=child_location, status=self.device_status)
1920
+ parent_device.rack = child_rack
1921
+ parent_device.validated_save()
1922
+
1923
+ # Test assigning a rack outside the child locations of the parent device location
1924
+ new_location = Location.objects.create(
1925
+ name="New Location 1",
1926
+ status=location_status,
1927
+ location_type=self.location_type_3,
1928
+ )
1929
+ invalid_rack = Rack.objects.create(name="Rack 3", location=new_location, status=self.device_status)
1930
+ parent_device.rack = invalid_rack
1931
+ with self.assertRaises(ValidationError) as cm:
1932
+ parent_device.validated_save()
1933
+ self.assertIn(
1934
+ f'Rack "{invalid_rack}" does not belong to location "{parent_device.location}" and its descendants.',
1935
+ str(cm.exception),
1936
+ )
1937
+
1898
1938
  child_mtime_after_parent_rack_update_save = str(Device.objects.get(name="Child Device 1").last_updated)
1899
1939
 
1900
1940
  self.assertNotEqual(child_mtime_after_parent_noop_save, child_mtime_after_parent_rack_update_save)
nautobot/dcim/views.py CHANGED
@@ -61,7 +61,7 @@ from nautobot.dcim.utils import get_all_network_driver_mappings, get_network_dri
61
61
  from nautobot.extras.models import Contact, ContactAssociation, Role, Status, Team
62
62
  from nautobot.extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectDynamicGroupsView
63
63
  from nautobot.ipam.models import IPAddress, Prefix, Service, VLAN
64
- from nautobot.ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable, VRFDeviceAssignmentTable
64
+ from nautobot.ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable, VRFDeviceAssignmentTable, VRFTable
65
65
  from nautobot.virtualization.models import VirtualMachine
66
66
  from nautobot.wireless.forms import ControllerManagedDeviceGroupWirelessNetworkFormSet
67
67
  from nautobot.wireless.models import (
@@ -1922,7 +1922,7 @@ class DeviceView(generic.ObjectView):
1922
1922
 
1923
1923
  # VRF assignments
1924
1924
  vrf_assignments = instance.vrf_assignments.restrict(request.user, "view")
1925
- vrf_table = VRFDeviceAssignmentTable(vrf_assignments, exclude=("virtual_machine", "device"))
1925
+ vrf_table = VRFDeviceAssignmentTable(vrf_assignments)
1926
1926
 
1927
1927
  # Software images
1928
1928
  if instance.software_version is not None:
@@ -4513,15 +4513,25 @@ class VirtualDeviceContextUIViewSet(NautobotUIViewSet):
4513
4513
  queryset = VirtualDeviceContext.objects.all()
4514
4514
  serializer_class = serializers.VirtualDeviceContextSerializer
4515
4515
  table_class = tables.VirtualDeviceContextTable
4516
-
4517
- def get_extra_context(self, request, instance):
4518
- if self.action == "retrieve":
4519
- interfaces_table = tables.InterfaceTable(
4520
- instance.interfaces.restrict(request.user, "view"), orderable=False, exclude=("device",)
4521
- )
4522
-
4523
- return {
4524
- "interfaces_table": interfaces_table,
4525
- **super().get_extra_context(request, instance),
4526
- }
4527
- return super().get_extra_context(request, instance)
4516
+ object_detail_content = object_detail.ObjectDetailContent(
4517
+ panels=(
4518
+ object_detail.ObjectFieldsPanel(
4519
+ section=SectionChoices.LEFT_HALF,
4520
+ weight=100,
4521
+ fields="__all__",
4522
+ ),
4523
+ object_detail.ObjectsTablePanel(
4524
+ weight=200,
4525
+ table_class=tables.InterfaceTable,
4526
+ table_attribute="interfaces",
4527
+ section=SectionChoices.FULL_WIDTH,
4528
+ exclude_columns=["device"],
4529
+ ),
4530
+ object_detail.ObjectsTablePanel(
4531
+ weight=300,
4532
+ table_class=VRFTable,
4533
+ table_attribute="vrfs",
4534
+ section=SectionChoices.FULL_WIDTH,
4535
+ ),
4536
+ ),
4537
+ )
@@ -37,7 +37,7 @@ class TaggedModelSerializerMixin(BaseModelSerializer):
37
37
 
38
38
  def _save_tags(self, instance, tags):
39
39
  if tags:
40
- instance.tags.set([t.name for t in tags])
40
+ instance.tags.set(tags)
41
41
  else:
42
42
  instance.tags.clear()
43
43
 
@@ -642,7 +642,7 @@ class JobViewSetBase(
642
642
  ):
643
643
  raise ValidationError(
644
644
  {
645
- "_task_queue": "_task_queue and _job_queue are both specified. Please specifiy only one or another."
645
+ "_task_queue": "_task_queue and _job_queue are both specified. Please specify only one or another."
646
646
  }
647
647
  )
648
648
 
@@ -685,7 +685,7 @@ class JobViewSetBase(
685
685
  "job_queue", None
686
686
  ):
687
687
  raise ValidationError(
688
- {"task_queue": "task_queue and job_queue are both specified. Please specifiy only one or another."}
688
+ {"task_queue": "task_queue and job_queue are both specified. Please specify only one or another."}
689
689
  )
690
690
  schedule_data = input_serializer.validated_data.get("schedule", None)
691
691
 
@@ -854,6 +854,10 @@ class JobFilterSet(BaseFilterSet, CustomFieldModelFilterSetMixin):
854
854
  "description": "icontains",
855
855
  },
856
856
  )
857
+ job_queues = NaturalKeyOrPKMultipleChoiceFilter(
858
+ queryset=JobQueue.objects.all(),
859
+ label="Job Queue (name or ID)",
860
+ )
857
861
 
858
862
  class Meta:
859
863
  model = Job
@@ -131,16 +131,20 @@ class GitRepository(PrimaryModel):
131
131
 
132
132
  def get_latest_sync(self):
133
133
  """
134
- Return a `JobResult` for the latest sync operation.
134
+ Return a `JobResult` for the latest sync operation if one has occurred.
135
135
 
136
136
  Returns:
137
- JobResult
137
+ Returns a `JobResult` if the repo has been synced before, otherwise returns None.
138
138
  """
139
139
  from nautobot.extras.models import JobResult
140
140
 
141
141
  # This will match all "GitRepository" jobs (pull/refresh, dry-run, etc.)
142
142
  prefix = "nautobot.core.jobs.GitRepository"
143
- return JobResult.objects.filter(task_name__startswith=prefix, task_kwargs__repository=self.pk).latest()
143
+
144
+ if JobResult.objects.filter(task_name__startswith=prefix, task_kwargs__repository=self.pk).exists():
145
+ return JobResult.objects.filter(task_name__startswith=prefix, task_kwargs__repository=self.pk).latest()
146
+ else:
147
+ return None
144
148
 
145
149
  def to_csv(self):
146
150
  return (
@@ -728,6 +728,31 @@ def register_plugin_menu_items(section_name, menu_items):
728
728
  #
729
729
 
730
730
 
731
+ class CustomValidatorContext(dict):
732
+ def __init__(self, obj):
733
+ """
734
+ If there is an active change context, meaning we are in a web request context,
735
+ we have access to the current user object. Otherwise, we are likely running inside
736
+ a management command or other non-web or non-Job context, and we should use an AnonymousUser.
737
+ This ensures people's custom validators don't outright break when running in non-web
738
+ contexts, and should generally provide a sane default, given validation based on the
739
+ user is commonly going to be least-privelege based, and thus the AnonymousUser will
740
+ cause such validation logic to fail closed.
741
+ """
742
+ from django.contrib.auth.models import AnonymousUser
743
+
744
+ from nautobot.extras.signals import change_context_state
745
+
746
+ change_context = change_context_state.get()
747
+ user = None
748
+ if change_context:
749
+ user = change_context.get_user()
750
+ if user is None:
751
+ user = AnonymousUser()
752
+
753
+ super().__init__(object=obj, user=user)
754
+
755
+
731
756
  class CustomValidator:
732
757
  """
733
758
  This class is used to register plugin custom model validators which act on specified models. It contains the clean
@@ -742,7 +767,7 @@ class CustomValidator:
742
767
  model = None
743
768
 
744
769
  def __init__(self, obj):
745
- self.context = {"object": obj}
770
+ self.context = CustomValidatorContext(obj)
746
771
 
747
772
  def validation_error(self, message):
748
773
  """
@@ -12,45 +12,43 @@
12
12
  <strong>Summary of Results</strong>
13
13
  </div>
14
14
  <table class="table table-hover panel-body">
15
- {% if result.job_model is not None %}
16
15
  <tr>
17
16
  <td>Job Description</td>
18
- <td>{{ result.job_model.description | render_markdown }}</td>
17
+ <td>{{ result.job_model.description | render_markdown | placeholder }}</td>
19
18
  </tr>
20
- {% endif %}
21
19
  <tr>
22
20
  <td>Status</td>
23
21
  <td><span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span></td>
24
22
  </tr>
25
23
  <tr>
26
24
  <td>Started at</td>
27
- <td>{{ result.date_created }}</td>
25
+ <td>{{ result.date_created | placeholder }}</td>
28
26
  </tr>
29
27
  <tr>
30
28
  <td>User</td>
31
- <td>{{ result.user }}</td>
29
+ <td>{{ result.user | placeholder }}</td>
32
30
  </tr>
33
31
  <tr>
34
32
  <td>Duration</td>
35
33
  <td>
36
- {% if result.date_done %}
37
- {{ result.duration }}
38
- {% else %}
34
+ {% if result.date_created and not result.date_done %}
39
35
  <img src="{% static 'img/ajax-loader.gif' %}">
36
+ {% else %}
37
+ {{ result.duration | placeholder}}
40
38
  {% endif %}
41
39
  </td>
42
40
  </tr>
43
41
  <tr>
44
42
  <td>Return Value</td>
45
43
  <td>
46
- {% if result.date_done %}
44
+ {% if result.date_created and not result.date_done %}
45
+ <img src="{% static 'img/ajax-loader.gif' %}">
46
+ {% else %}
47
47
  {% if result.result %}
48
48
  <pre>{{ result.result | render_json }}</pre>
49
49
  {% else %}
50
50
  {{ result.result | placeholder }}
51
51
  {% endif %}
52
- {% else %}
53
- <img src="{% static 'img/ajax-loader.gif' %}">
54
52
  {% endif %}
55
53
  </td>
56
54
  </tr>
@@ -89,6 +87,7 @@
89
87
  <input class="form-control" id="log-filter" type="text" placeholder="Filter log level or message" title="Filter log level or message" style="height: 23px" />
90
88
  </div>
91
89
  </div>
92
- {% ajax_table "log_table" "extras:jobresult_log-table" pk=result.pk %}
90
+ {% if result and result.pk %}
91
+ {% ajax_table "log_table" "extras:jobresult_log-table" pk=result.pk %}
92
+ {% endif %}
93
93
  </div>
94
-