nautobot 2.4.3__py3-none-any.whl → 2.4.5__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.

Potentially problematic release.


This version of nautobot might be problematic. Click here for more details.

Files changed (198) hide show
  1. nautobot/__init__.py +19 -3
  2. nautobot/apps/filters.py +2 -0
  3. nautobot/circuits/filters.py +1 -1
  4. nautobot/circuits/tests/test_models.py +5 -3
  5. nautobot/cloud/filters.py +3 -6
  6. nautobot/cloud/tests/test_filters.py +21 -0
  7. nautobot/core/admin.py +2 -0
  8. nautobot/core/celery/__init__.py +5 -3
  9. nautobot/core/jobs/__init__.py +5 -3
  10. nautobot/core/management/commands/generate_performance_test_endpoints.py +9 -6
  11. nautobot/core/models/utils.py +6 -1
  12. nautobot/core/templates/inc/javascript.html +1 -0
  13. nautobot/core/templatetags/ui_framework.py +20 -4
  14. nautobot/core/testing/__init__.py +2 -0
  15. nautobot/core/testing/forms.py +1 -1
  16. nautobot/core/testing/mixins.py +9 -0
  17. nautobot/core/tests/test_api.py +1 -1
  18. nautobot/core/tests/test_graphql.py +3 -3
  19. nautobot/core/tests/test_jobs.py +30 -28
  20. nautobot/core/ui/object_detail.py +1 -1
  21. nautobot/dcim/api/serializers.py +36 -0
  22. nautobot/dcim/api/views.py +1 -1
  23. nautobot/dcim/elevations.py +17 -4
  24. nautobot/dcim/factory.py +9 -1
  25. nautobot/dcim/filters/__init__.py +27 -1
  26. nautobot/dcim/forms.py +13 -1
  27. nautobot/dcim/models/devices.py +11 -5
  28. nautobot/dcim/signals.py +26 -0
  29. nautobot/dcim/templates/dcim/virtualdevicecontext_retrieve.html +0 -62
  30. nautobot/dcim/templates/dcim/virtualdevicecontext_update.html +6 -0
  31. nautobot/dcim/tests/test_api.py +176 -0
  32. nautobot/dcim/tests/test_filters.py +56 -3
  33. nautobot/dcim/tests/test_jobs.py +4 -6
  34. nautobot/dcim/tests/test_models.py +40 -0
  35. nautobot/dcim/views.py +24 -14
  36. nautobot/extras/api/mixins.py +1 -1
  37. nautobot/extras/api/views.py +2 -2
  38. nautobot/extras/choices.py +8 -3
  39. nautobot/extras/filters/__init__.py +4 -0
  40. nautobot/extras/jobs.py +181 -103
  41. nautobot/extras/management/utils.py +13 -2
  42. nautobot/extras/models/datasources.py +11 -4
  43. nautobot/extras/models/jobs.py +20 -17
  44. nautobot/extras/plugins/__init__.py +26 -1
  45. nautobot/extras/tables.py +25 -29
  46. nautobot/extras/templates/extras/inc/jobresult.html +12 -13
  47. nautobot/extras/templates/extras/objectchange.html +28 -12
  48. nautobot/extras/test_jobs/atomic_transaction.py +6 -6
  49. nautobot/extras/test_jobs/fail.py +75 -1
  50. nautobot/extras/tests/test_api.py +17 -16
  51. nautobot/extras/tests/test_datasources.py +64 -54
  52. nautobot/extras/tests/test_filters.py +2 -0
  53. nautobot/extras/tests/test_jobs.py +69 -62
  54. nautobot/extras/tests/test_models.py +1 -1
  55. nautobot/extras/tests/test_plugins.py +32 -1
  56. nautobot/extras/tests/test_relationships.py +5 -5
  57. nautobot/extras/tests/test_views.py +12 -2
  58. nautobot/extras/views.py +10 -1
  59. nautobot/ipam/api/serializers.py +7 -8
  60. nautobot/ipam/api/views.py +2 -2
  61. nautobot/ipam/factory.py +27 -8
  62. nautobot/ipam/filters.py +67 -29
  63. nautobot/ipam/formfields.py +51 -0
  64. nautobot/ipam/forms.py +28 -1
  65. nautobot/ipam/migrations/0051_added_optional_vrf_relationship_to_vdc.py +41 -0
  66. nautobot/ipam/models.py +63 -5
  67. nautobot/ipam/querysets.py +6 -0
  68. nautobot/ipam/tables.py +21 -7
  69. nautobot/ipam/templates/ipam/rir.html +1 -43
  70. nautobot/ipam/tests/test_api.py +107 -66
  71. nautobot/ipam/tests/test_filters.py +145 -5
  72. nautobot/ipam/tests/test_models.py +16 -0
  73. nautobot/ipam/tests/test_views.py +15 -2
  74. nautobot/ipam/urls.py +1 -21
  75. nautobot/ipam/views.py +24 -41
  76. nautobot/project-static/css/base.css +11 -0
  77. nautobot/project-static/css/dark.css +2 -1
  78. nautobot/project-static/docs/code-reference/nautobot/apps/filters.html +62 -0
  79. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +43 -5
  80. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +35 -0
  81. nautobot/project-static/docs/development/apps/api/configuration-view.html +0 -3
  82. nautobot/project-static/docs/development/apps/api/models/graphql.html +0 -4
  83. nautobot/project-static/docs/development/apps/api/platform-features/custom-validators.html +94 -1
  84. nautobot/project-static/docs/development/apps/api/platform-features/filter-extensions.html +0 -3
  85. nautobot/project-static/docs/development/apps/api/platform-features/jinja2-filters.html +0 -3
  86. nautobot/project-static/docs/development/apps/api/platform-features/populating-extensibility-features.html +0 -3
  87. nautobot/project-static/docs/development/apps/api/platform-features/secrets-providers.html +0 -3
  88. nautobot/project-static/docs/development/apps/api/prometheus.html +0 -3
  89. nautobot/project-static/docs/development/apps/api/testing.html +0 -6
  90. nautobot/project-static/docs/development/apps/api/ui-extensions/banners.html +0 -3
  91. nautobot/project-static/docs/development/apps/api/ui-extensions/home-page.html +0 -3
  92. nautobot/project-static/docs/development/apps/api/ui-extensions/object-views.html +0 -3
  93. nautobot/project-static/docs/development/apps/api/views/core-view-overrides.html +0 -3
  94. nautobot/project-static/docs/development/apps/api/views/nautobot-generic-views.html +1 -7
  95. nautobot/project-static/docs/development/apps/api/views/nautobotuiviewset.html +0 -7
  96. nautobot/project-static/docs/development/apps/api/views/nautobotuiviewsetrouter.html +0 -4
  97. nautobot/project-static/docs/development/apps/api/views/notes.html +0 -3
  98. nautobot/project-static/docs/development/apps/index.html +2 -35
  99. nautobot/project-static/docs/development/apps/migration/code-updates.html +1 -1
  100. nautobot/project-static/docs/development/core/application-registry.html +0 -6
  101. nautobot/project-static/docs/development/core/best-practices.html +0 -27
  102. nautobot/project-static/docs/development/core/docker-compose-advanced-use-cases.html +58 -4
  103. nautobot/project-static/docs/development/core/getting-started.html +12 -16
  104. nautobot/project-static/docs/development/core/homepage.html +0 -3
  105. nautobot/project-static/docs/development/core/style-guide.html +0 -5
  106. nautobot/project-static/docs/development/core/templates.html +0 -3
  107. nautobot/project-static/docs/development/core/testing.html +0 -9
  108. nautobot/project-static/docs/development/jobs/index.html +30 -43
  109. nautobot/project-static/docs/objects.inv +0 -0
  110. nautobot/project-static/docs/overview/application_stack.html +0 -18
  111. nautobot/project-static/docs/release-notes/version-2.4.html +374 -0
  112. nautobot/project-static/docs/requirements.txt +2 -2
  113. nautobot/project-static/docs/search/search_index.json +1 -1
  114. nautobot/project-static/docs/sitemap.xml +290 -290
  115. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  116. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +0 -10
  117. nautobot/project-static/docs/user-guide/administration/guides/docker.html +0 -15
  118. nautobot/project-static/docs/user-guide/administration/installation/index.html +0 -16
  119. nautobot/project-static/docs/user-guide/administration/installation/nautobot.html +1 -4
  120. nautobot/project-static/docs/user-guide/administration/installation/services.html +0 -11
  121. nautobot/project-static/docs/user-guide/administration/migration/migrating-from-postgresql.html +3 -3
  122. nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +5 -35
  123. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/tables/v2-code-location-changes.yaml +1 -1
  124. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +1 -1
  125. nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +0 -4
  126. nautobot/project-static/docs/user-guide/core-data-model/circuits/providernetwork.html +0 -3
  127. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleport.html +0 -4
  128. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleporttemplate.html +0 -4
  129. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverport.html +0 -4
  130. nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverporttemplate.html +0 -4
  131. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebay.html +0 -4
  132. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebaytemplate.html +0 -4
  133. nautobot/project-static/docs/user-guide/core-data-model/dcim/deviceredundancygroup.html +0 -3
  134. nautobot/project-static/docs/user-guide/core-data-model/dcim/devicetype.html +0 -4
  135. nautobot/project-static/docs/user-guide/core-data-model/dcim/frontport.html +0 -4
  136. nautobot/project-static/docs/user-guide/core-data-model/dcim/frontporttemplate.html +0 -4
  137. nautobot/project-static/docs/user-guide/core-data-model/dcim/interface.html +1 -17
  138. nautobot/project-static/docs/user-guide/core-data-model/dcim/interfaceredundancygroup.html +0 -3
  139. nautobot/project-static/docs/user-guide/core-data-model/dcim/interfacetemplate.html +0 -4
  140. nautobot/project-static/docs/user-guide/core-data-model/dcim/inventoryitem.html +0 -4
  141. nautobot/project-static/docs/user-guide/core-data-model/dcim/location.html +0 -3
  142. nautobot/project-static/docs/user-guide/core-data-model/dcim/locationtype.html +1 -7
  143. nautobot/project-static/docs/user-guide/core-data-model/dcim/platform.html +0 -4
  144. nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlet.html +0 -4
  145. nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlettemplate.html +0 -4
  146. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerport.html +0 -4
  147. nautobot/project-static/docs/user-guide/core-data-model/dcim/powerporttemplate.html +0 -4
  148. nautobot/project-static/docs/user-guide/core-data-model/dcim/rearport.html +0 -4
  149. nautobot/project-static/docs/user-guide/core-data-model/dcim/rearporttemplate.html +0 -4
  150. nautobot/project-static/docs/user-guide/core-data-model/extras/configcontext.html +0 -6
  151. nautobot/project-static/docs/user-guide/core-data-model/extras/configcontextschema.html +0 -3
  152. nautobot/project-static/docs/user-guide/core-data-model/ipam/ipaddress.html +0 -4
  153. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +0 -4
  154. nautobot/project-static/docs/user-guide/core-data-model/virtualization/vminterface.html +0 -8
  155. nautobot/project-static/docs/user-guide/feature-guides/custom-fields.html +3 -3
  156. nautobot/project-static/docs/user-guide/feature-guides/graphql.html +0 -6
  157. nautobot/project-static/docs/user-guide/platform-functionality/computedfield.html +0 -3
  158. nautobot/project-static/docs/user-guide/platform-functionality/customfield.html +3 -15
  159. nautobot/project-static/docs/user-guide/platform-functionality/dynamicgroup.html +0 -26
  160. nautobot/project-static/docs/user-guide/platform-functionality/gitrepository.html +0 -8
  161. nautobot/project-static/docs/user-guide/platform-functionality/graphql.html +0 -3
  162. nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +0 -8
  163. nautobot/project-static/docs/user-guide/platform-functionality/jobs/job-scheduling-and-approvals.html +0 -7
  164. nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobbutton.html +0 -3
  165. nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobhook.html +0 -3
  166. nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +0 -14
  167. nautobot/project-static/docs/user-guide/platform-functionality/note.html +0 -3
  168. nautobot/project-static/docs/user-guide/platform-functionality/relationship.html +1 -10
  169. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/authentication.html +0 -3
  170. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/filtering.html +0 -14
  171. nautobot/project-static/docs/user-guide/platform-functionality/rest-api/overview.html +0 -19
  172. nautobot/project-static/docs/user-guide/platform-functionality/secret.html +3 -9
  173. nautobot/project-static/docs/user-guide/platform-functionality/status.html +0 -8
  174. nautobot/project-static/docs/user-guide/platform-functionality/tag.html +0 -4
  175. nautobot/project-static/docs/user-guide/platform-functionality/template-filters.html +1 -13
  176. nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +0 -5
  177. nautobot/project-static/js/editor.js +292 -0
  178. nautobot/project-static/monaco-editor-0.52.2/README.md +81 -0
  179. nautobot/project-static/monaco-editor-0.52.2/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  180. nautobot/project-static/monaco-editor-0.52.2/vs/base/worker/workerMain.js +31 -0
  181. nautobot/project-static/monaco-editor-0.52.2/vs/basic-languages/xml/xml.js +10 -0
  182. nautobot/project-static/monaco-editor-0.52.2/vs/basic-languages/yaml/yaml.js +10 -0
  183. nautobot/project-static/monaco-editor-0.52.2/vs/editor/editor.main.css +8 -0
  184. nautobot/project-static/monaco-editor-0.52.2/vs/editor/editor.main.js +798 -0
  185. nautobot/project-static/monaco-editor-0.52.2/vs/language/json/jsonMode.js +19 -0
  186. nautobot/project-static/monaco-editor-0.52.2/vs/language/json/jsonWorker.js +42 -0
  187. nautobot/project-static/monaco-editor-0.52.2/vs/loader.js +11 -0
  188. nautobot/tenancy/filters/__init__.py +3 -5
  189. nautobot/tenancy/tests/test_filters.py +10 -0
  190. nautobot/virtualization/views.py +0 -1
  191. nautobot/wireless/tables.py +9 -4
  192. nautobot/wireless/tests/test_api.py +0 -9
  193. {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/METADATA +4 -4
  194. {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/RECORD +198 -186
  195. {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/LICENSE.txt +0 -0
  196. {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/NOTICE +0 -0
  197. {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/WHEEL +0 -0
  198. {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,4 @@
1
+ import codecs
1
2
  from datetime import timedelta
2
3
  import json
3
4
  from pathlib import Path
@@ -47,7 +48,7 @@ class ExportObjectListTest(TransactionTestCase):
47
48
  username=self.user.username, # otherwise run_job_for_testing defaults to a superuser account
48
49
  content_type=ContentType.objects.get_for_model(Status).pk,
49
50
  )
50
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
51
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
51
52
  log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
52
53
  self.assertEqual(log_error.message, f'User "{self.user}" does not have permission to view status objects')
53
54
  self.assertFalse(job_result.files.exists())
@@ -69,10 +70,12 @@ class ExportObjectListTest(TransactionTestCase):
69
70
  username=self.user.username, # otherwise run_job_for_testing defaults to a superuser account
70
71
  content_type=ContentType.objects.get_for_model(Status).pk,
71
72
  )
72
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
73
+ self.assertJobResultStatus(job_result)
73
74
  self.assertTrue(job_result.files.exists())
74
75
  self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_statuses.csv")
75
- csv_data = job_result.files.first().file.read().decode("utf-8")
76
+ csv_bytes = job_result.files.first().file.read()
77
+ self.assertTrue(csv_bytes.startswith(codecs.BOM_UTF8), csv_bytes)
78
+ csv_data = csv_bytes.decode("utf-8")
76
79
  self.assertIn(str(instance1.pk), csv_data)
77
80
  self.assertNotIn(str(instance2.pk), csv_data)
78
81
 
@@ -83,7 +86,7 @@ class ExportObjectListTest(TransactionTestCase):
83
86
  "ExportObjectList",
84
87
  content_type=ContentType.objects.get_for_model(Status).pk,
85
88
  )
86
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
89
+ self.assertJobResultStatus(job_result)
87
90
  self.assertTrue(job_result.files.exists())
88
91
  self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_statuses.csv")
89
92
  csv_data = job_result.files.first().file.read().decode("utf-8")
@@ -104,7 +107,7 @@ class ExportObjectListTest(TransactionTestCase):
104
107
  content_type=ContentType.objects.get_for_model(Status).pk,
105
108
  export_template=et.pk,
106
109
  )
107
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
110
+ self.assertJobResultStatus(job_result)
108
111
  self.assertTrue(job_result.files.exists())
109
112
  self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_statuses.txt")
110
113
  text_data = job_result.files.first().file.read().decode("utf-8")
@@ -126,7 +129,7 @@ class ExportObjectListTest(TransactionTestCase):
126
129
  content_type=ContentType.objects.get_for_model(DeviceType).pk,
127
130
  export_format="yaml",
128
131
  )
129
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
132
+ self.assertJobResultStatus(job_result)
130
133
  self.assertTrue(job_result.files.exists())
131
134
  self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_device_types.yaml")
132
135
  yaml_data = job_result.files.first().file.read().decode("utf-8")
@@ -156,7 +159,7 @@ class ImportObjectsTestCase(TransactionTestCase):
156
159
  content_type=ContentType.objects.get_for_model(Status).pk,
157
160
  csv_data=self.csv_data,
158
161
  )
159
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
162
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
160
163
  log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
161
164
  self.assertEqual(log_error.message, f'User "{self.user}" does not have permission to create status objects')
162
165
  self.assertFalse(Status.objects.filter(name__startswith="test_status").exists())
@@ -169,7 +172,7 @@ class ImportObjectsTestCase(TransactionTestCase):
169
172
  username=self.user.username, # otherwise run_job_for_testing defaults to a superuser account
170
173
  content_type=ContentType.objects.get_for_model(Status).pk,
171
174
  )
172
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
175
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
173
176
 
174
177
  def test_csv_import_with_constrained_permission(self):
175
178
  """Job should only allow the user to import objects they have permission to add."""
@@ -188,7 +191,7 @@ class ImportObjectsTestCase(TransactionTestCase):
188
191
  content_type=ContentType.objects.get_for_model(Status).pk,
189
192
  csv_data=self.csv_data,
190
193
  )
191
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
194
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
192
195
  log_successes = JobLogEntry.objects.filter(
193
196
  job_result=job_result, log_level=LogLevelChoices.LOG_INFO, message__icontains="created"
194
197
  )
@@ -217,7 +220,7 @@ class ImportObjectsTestCase(TransactionTestCase):
217
220
  content_type=ContentType.objects.get_for_model(Status).pk,
218
221
  csv_data=self.csv_data,
219
222
  )
220
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
223
+ self.assertJobResultStatus(job_result)
221
224
  self.assertFalse(
222
225
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
223
226
  )
@@ -243,7 +246,7 @@ class ImportObjectsTestCase(TransactionTestCase):
243
246
  content_type=ContentType.objects.get_for_model(Prefix).pk,
244
247
  csv_file=csv_file.id,
245
248
  )
246
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
249
+ self.assertJobResultStatus(job_result)
247
250
  self.assertFalse(
248
251
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
249
252
  )
@@ -284,7 +287,7 @@ class ImportObjectsTestCase(TransactionTestCase):
284
287
  content_type=ContentType.objects.get_for_model(Device).pk,
285
288
  csv_file=csv_file.id,
286
289
  )
287
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
290
+ self.assertJobResultStatus(job_result)
288
291
  self.assertFalse(
289
292
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
290
293
  )
@@ -310,7 +313,7 @@ class ImportObjectsTestCase(TransactionTestCase):
310
313
  csv_data=csv_data,
311
314
  roll_back_if_error=True,
312
315
  )
313
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
316
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
314
317
  log_info = JobLogEntry.objects.filter(
315
318
  job_result=job_result, log_level=LogLevelChoices.LOG_INFO, message__icontains="created"
316
319
  )
@@ -332,7 +335,7 @@ class ImportObjectsTestCase(TransactionTestCase):
332
335
  content_type=ContentType.objects.get_for_model(Status).pk,
333
336
  csv_data=csv_data,
334
337
  )
335
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
338
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
336
339
  log_errors = JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
337
340
  self.assertEqual(log_errors[0].message, "Row 1: `color`: `Enter a valid hexadecimal RGB color code.`")
338
341
  self.assertFalse(Status.objects.filter(name="test_status0").exists())
@@ -372,7 +375,7 @@ class ImportObjectsTestCase(TransactionTestCase):
372
375
  content_type=ContentType.objects.get_for_model(LocationType).pk,
373
376
  csv_data=location_types_csv,
374
377
  )
375
- self.assertEqual(location_types_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
378
+ self.assertJobResultStatus(location_types_job_result)
376
379
 
377
380
  location_type_count = LocationType.objects.filter(name="ContactAssignmentImportTestLocationType").count()
378
381
  self.assertEqual(location_type_count, 1, f"Unexpected count of LocationTypes {location_type_count}")
@@ -383,7 +386,7 @@ class ImportObjectsTestCase(TransactionTestCase):
383
386
  content_type=ContentType.objects.get_for_model(Location).pk,
384
387
  csv_data=locations_csv,
385
388
  )
386
- self.assertEqual(locations_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
389
+ self.assertJobResultStatus(locations_job_result)
387
390
 
388
391
  location_count = Location.objects.filter(location_type__name="ContactAssignmentImportTestLocationType").count()
389
392
  self.assertEqual(location_count, 2, f"Unexpected count of Locations {location_count}")
@@ -394,7 +397,7 @@ class ImportObjectsTestCase(TransactionTestCase):
394
397
  content_type=ContentType.objects.get_for_model(Contact).pk,
395
398
  csv_data=contacts_csv,
396
399
  )
397
- self.assertEqual(contacts_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
400
+ self.assertJobResultStatus(contacts_job_result)
398
401
 
399
402
  contact_count = Contact.objects.filter(name="Bob-ContactAssignmentImportTestLocation").count()
400
403
  self.assertEqual(contact_count, 1, f"Unexpected number of contacts {contact_count}")
@@ -405,7 +408,7 @@ class ImportObjectsTestCase(TransactionTestCase):
405
408
  content_type=ContentType.objects.get_for_model(Role).pk,
406
409
  csv_data=roles_csv,
407
410
  )
408
- self.assertEqual(roles_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
411
+ self.assertJobResultStatus(roles_job_result)
409
412
 
410
413
  role_count = Role.objects.filter(name="ContactAssignmentImportTestLocation-On Site").count()
411
414
  self.assertEqual(role_count, 1, f"Unexpected number of role values {role_count}")
@@ -423,8 +426,7 @@ class ImportObjectsTestCase(TransactionTestCase):
423
426
  content_type=ContentType.objects.get_for_model(ContactAssociation).pk,
424
427
  csv_data=associations_csv,
425
428
  )
426
-
427
- self.assertEqual(associations_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
429
+ self.assertJobResultStatus(associations_job_result)
428
430
 
429
431
 
430
432
  class LogsCleanupTestCase(TransactionTestCase):
@@ -466,7 +468,7 @@ class LogsCleanupTestCase(TransactionTestCase):
466
468
  cleanup_types=[CleanupTypes.JOB_RESULT],
467
469
  max_age=0,
468
470
  )
469
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
471
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
470
472
  log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
471
473
  self.assertEqual(log_error.message, f'User "{self.user}" does not have permission to delete JobResult records')
472
474
  self.assertEqual(JobResult.objects.count(), job_result_count + 1)
@@ -481,7 +483,7 @@ class LogsCleanupTestCase(TransactionTestCase):
481
483
  cleanup_types=[CleanupTypes.OBJECT_CHANGE],
482
484
  max_age=0,
483
485
  )
484
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
486
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
485
487
  log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
486
488
  self.assertEqual(
487
489
  log_error.message, f'User "{self.user}" does not have permission to delete ObjectChange records'
@@ -529,7 +531,7 @@ class LogsCleanupTestCase(TransactionTestCase):
529
531
  cleanup_types=[CleanupTypes.JOB_RESULT, CleanupTypes.OBJECT_CHANGE],
530
532
  max_age=0,
531
533
  )
532
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
534
+ self.assertJobResultStatus(job_result)
533
535
  self.assertEqual(job_result.result["extras.JobResult"], 1)
534
536
  self.assertEqual(job_result.result["extras.ObjectChange"], 1)
535
537
  with self.assertRaises(JobResult.DoesNotExist):
@@ -626,7 +628,7 @@ class BulkEditTestCase(TransactionTestCase):
626
628
  tag.content_types.add(self.namespace_ct)
627
629
 
628
630
  def _common_no_error_test_assertion(self, model, job_result, expected_count, **filter_params):
629
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
631
+ self.assertJobResultStatus(job_result)
630
632
  self.assertEqual(model.objects.filter(**filter_params).count(), expected_count)
631
633
  self.assertFalse(
632
634
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
@@ -647,7 +649,7 @@ class BulkEditTestCase(TransactionTestCase):
647
649
  form_data={"pk": pk_list, "color": "aa1409"},
648
650
  username=self.user.username,
649
651
  )
650
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
652
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
651
653
  job_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
652
654
  self.assertEqual(job_log.message, f'User "{self.user}" does not have permission to update status objects')
653
655
 
@@ -958,7 +960,7 @@ class BulkDeleteTestCase(TransactionTestCase):
958
960
  )
959
961
 
960
962
  def _common_no_error_test_assertion(self, model, job_result, **filter_params):
961
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
963
+ self.assertJobResultStatus(job_result)
962
964
  self.assertEqual(model.objects.filter(**filter_params).count(), 0)
963
965
  self.assertFalse(
964
966
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
@@ -976,7 +978,7 @@ class BulkDeleteTestCase(TransactionTestCase):
976
978
  pk_list=statuses_to_delete,
977
979
  username=self.user.username,
978
980
  )
979
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
981
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
980
982
  job_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
981
983
  self.assertEqual(job_log.message, f'User "{self.user}" does not have permission to delete status objects')
982
984
  self.assertEqual(Status.objects.filter(pk__in=statuses_to_delete).count(), len(statuses_to_delete))
@@ -999,7 +1001,7 @@ class BulkDeleteTestCase(TransactionTestCase):
999
1001
  pk_list=statuses_to_delete,
1000
1002
  username=self.user.username,
1001
1003
  )
1002
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
1004
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
1003
1005
  error_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
1004
1006
  self.assertEqual(
1005
1007
  error_log.message, "You do not have permissions to delete some of the objects provided in `pk_list`."
@@ -1372,7 +1372,7 @@ class StatsPanel(Panel):
1372
1372
  value = [related_object_list_url, related_object_count, related_object_title]
1373
1373
  stats[related_object_model_class] = value
1374
1374
  related_object_model_filterset = get_filterset_for_model(related_object_model_class)
1375
- if self.filter_name not in related_object_model_filterset.declared_filters:
1375
+ if self.filter_name not in related_object_model_filterset.get_filters():
1376
1376
  raise FieldDoesNotExist(
1377
1377
  f"{self.filter_name} is not a valid filter field for {related_object_model_class_meta.verbose_name}"
1378
1378
  )
@@ -1,6 +1,7 @@
1
1
  import contextlib
2
2
 
3
3
  from django.contrib.contenttypes.models import ContentType
4
+ from django.core.exceptions import ValidationError
4
5
  from drf_spectacular.utils import extend_schema_field
5
6
  from rest_framework import serializers
6
7
  from rest_framework.validators import UniqueTogetherValidator, UniqueValidator
@@ -560,11 +561,46 @@ class DeviceSerializer(TaggedModelSerializerMixin, NautobotModelSerializer):
560
561
  )
561
562
  validator(attrs, self)
562
563
 
564
+ # Validate parent bay
565
+ if parent_bay := attrs.get("parent_bay", None):
566
+ if parent_bay.installed_device and parent_bay.installed_device != self.instance:
567
+ raise ValidationError(
568
+ {
569
+ "installed_device": f"Cannot install device; parent bay is already taken ({parent_bay.installed_device})"
570
+ }
571
+ )
572
+
573
+ if self.instance:
574
+ parent_bay.installed_device = self.instance
575
+ parent_bay.full_clean()
576
+
563
577
  # Enforce model validation
564
578
  super().validate(attrs)
565
579
 
566
580
  return attrs
567
581
 
582
+ def create(self, validated_data):
583
+ instance = super().create(validated_data)
584
+ self.update_parent_bay(validated_data, instance)
585
+ return instance
586
+
587
+ def update(self, instance, validated_data):
588
+ instance = super().update(instance, validated_data)
589
+ self.update_parent_bay(validated_data, instance)
590
+ return instance
591
+
592
+ def update_parent_bay(self, validated_data, instance):
593
+ update_parent_bay = "parent_bay" in validated_data.keys()
594
+ parent_bay = validated_data.get("parent_bay")
595
+ if update_parent_bay:
596
+ if parent_bay:
597
+ parent_bay.installed_device = instance
598
+ parent_bay.save()
599
+ elif hasattr(instance, "parent_bay"):
600
+ parent_bay = instance.parent_bay
601
+ parent_bay.installed_device = None
602
+ parent_bay.validated_save()
603
+
568
604
 
569
605
  class DeviceNAPALMSerializer(serializers.Serializer):
570
606
  method = serializers.DictField()
@@ -183,7 +183,7 @@ class RackGroupViewSet(NautobotModelViewSet):
183
183
 
184
184
 
185
185
  class RackViewSet(NautobotModelViewSet):
186
- queryset = Rack.objects.select_related("rack_group__location").annotate(
186
+ queryset = Rack.objects.select_related("role", "status", "rack_group__location").annotate(
187
187
  device_count=count_related(Device, "rack"),
188
188
  power_feed_count=count_related(PowerFeed, "rack"),
189
189
  )
@@ -96,17 +96,30 @@ class RackElevationSVG:
96
96
  device_fullname = str(device) + device_bay_details
97
97
  device_shortname = settings.UI_RACK_VIEW_TRUNCATE_FUNCTION(str(device)) + device_bay_details
98
98
 
99
- color = device.role.color
100
- reverse_url = reverse("dcim:device", kwargs={"pk": device.pk})
99
+ role_color = device.role.color
100
+ status_color = device.status.color
101
+ device_reverse_url = reverse("dcim:device", kwargs={"pk": device.pk})
102
+ status_reverse_url = reverse("extras:status", kwargs={"pk": device.status.pk})
101
103
  link = drawing.add(
102
104
  drawing.a(
103
- href=f"{self.base_url}{reverse_url}",
105
+ href=f"{self.base_url}{device_reverse_url}",
104
106
  target="_top",
105
107
  fill="black",
106
108
  )
107
109
  )
108
110
  link.set_desc(self._get_device_description(device))
109
- link.add(drawing.rect(start, end, style=f"fill: #{color}", class_="slot"))
111
+ link.add(drawing.rect(start, end, style=f"fill: #{role_color}", class_="slot"))
112
+
113
+ status_rect = drawing.add(
114
+ drawing.a(
115
+ href=f"{self.base_url}{status_reverse_url}",
116
+ target="_top",
117
+ fill="black",
118
+ )
119
+ )
120
+ status_rect.set_desc(device.status.name)
121
+ status_end = (end[0] / 20, end[1]) # width, y
122
+ status_rect.add(drawing.rect(start, status_end, style=f"fill: #{status_color}"))
110
123
 
111
124
  # Embed front device type image if one exists
112
125
  if self.include_images and device.device_type.front_image:
nautobot/dcim/factory.py CHANGED
@@ -64,7 +64,7 @@ from nautobot.dcim.models import (
64
64
  )
65
65
  from nautobot.extras.models import ExternalIntegration, Role, Status
66
66
  from nautobot.extras.utils import FeatureQuery
67
- from nautobot.ipam.models import Prefix, VLAN, VLANGroup
67
+ from nautobot.ipam.models import Prefix, VLAN, VLANGroup, VRF
68
68
  from nautobot.tenancy.models import Tenant
69
69
  from nautobot.virtualization.models import Cluster
70
70
 
@@ -1008,3 +1008,11 @@ class VirtualDeviceContextFactory(PrimaryModelFactory):
1008
1008
  self.interfaces.set(extracted)
1009
1009
  else:
1010
1010
  self.interfaces.set(get_random_instances(Interface.objects.filter(device=self.device)))
1011
+
1012
+ @factory.post_generation
1013
+ def vrfs(self, create, extracted, **kwargs):
1014
+ if create:
1015
+ if extracted:
1016
+ self.vrfs.set(extracted)
1017
+ else:
1018
+ self.vrfs.set(get_random_instances(VRF.objects.all()))
@@ -101,7 +101,7 @@ from nautobot.extras.filters import (
101
101
  from nautobot.extras.models import ExternalIntegration, SecretsGroup
102
102
  from nautobot.extras.utils import FeatureQuery
103
103
  from nautobot.ipam.models import IPAddress, VLAN, VLANGroup
104
- from nautobot.tenancy.filters import TenancyModelFilterSetMixin
104
+ from nautobot.tenancy.filters.mixins import TenancyModelFilterSetMixin
105
105
  from nautobot.tenancy.models import Tenant
106
106
  from nautobot.virtualization.models import Cluster, VirtualMachine
107
107
  from nautobot.wireless.models import RadioProfile, WirelessNetwork
@@ -362,6 +362,12 @@ class RackGroupFilterSet(LocatableModelFilterSetMixin, NautobotFilterSet, NameSe
362
362
  to_field_name="name",
363
363
  label="Parent (name or ID)",
364
364
  )
365
+ ancestors = NaturalKeyOrPKMultipleChoiceFilter(
366
+ queryset=Location.objects.all(),
367
+ to_field_name="name",
368
+ label="Location(s) and ancestors thereof (name or ID)",
369
+ method="_ancestors",
370
+ )
365
371
  children = NaturalKeyOrPKMultipleChoiceFilter(
366
372
  queryset=RackGroup.objects.all(),
367
373
  to_field_name="name",
@@ -392,6 +398,26 @@ class RackGroupFilterSet(LocatableModelFilterSetMixin, NautobotFilterSet, NameSe
392
398
  model = RackGroup
393
399
  fields = ["id", "name", "description", "racks"]
394
400
 
401
+ def generate_query__ancestors(self, value):
402
+ """Helper method used by _ancestors() method."""
403
+ if value:
404
+ locations = Location.objects.filter(pk__in=[v.pk for v in value])
405
+ pk_list = []
406
+ for location in locations:
407
+ parent_locations = location.ancestors(include_self=True)
408
+ pk_list.extend([v.pk for v in parent_locations])
409
+ params = Q(location__pk__in=pk_list)
410
+ return params
411
+ return Q()
412
+
413
+ @extend_schema_field({"type": "string"})
414
+ def _ancestors(self, queryset, name, value):
415
+ """FilterSet method for, given a location, getting RackGroups that exist with in the parent Location(s) and the location itself."""
416
+ if value:
417
+ params = self.generate_query__ancestors(value)
418
+ return queryset.filter(params)
419
+ return queryset
420
+
395
421
 
396
422
  class RackFilterSet(
397
423
  NautobotFilterSet,
nautobot/dcim/forms.py CHANGED
@@ -510,7 +510,7 @@ class RackForm(LocatableModelFormMixin, NautobotModelForm, TenancyForm):
510
510
  rack_group = DynamicModelChoiceField(
511
511
  queryset=RackGroup.objects.all(),
512
512
  required=False,
513
- query_params={"location": "$location"},
513
+ query_params={"ancestors": "$location"},
514
514
  )
515
515
  comments = CommentField()
516
516
 
@@ -5298,6 +5298,11 @@ class VirtualDeviceContextForm(NautobotModelForm):
5298
5298
  required=True,
5299
5299
  query_params={"content_types": VirtualDeviceContext._meta.label_lower},
5300
5300
  )
5301
+ vrfs = DynamicModelMultipleChoiceField(
5302
+ queryset=VRF.objects.all(),
5303
+ required=False,
5304
+ label="VRFs",
5305
+ )
5301
5306
 
5302
5307
  class Meta:
5303
5308
  model = VirtualDeviceContext
@@ -5308,6 +5313,7 @@ class VirtualDeviceContextForm(NautobotModelForm):
5308
5313
  "status",
5309
5314
  "identifier",
5310
5315
  "interfaces",
5316
+ "vrfs",
5311
5317
  "primary_ip4",
5312
5318
  "primary_ip6",
5313
5319
  "tenant",
@@ -5323,11 +5329,15 @@ class VirtualDeviceContextForm(NautobotModelForm):
5323
5329
  self.fields["device"].disabled = True
5324
5330
  self.fields["device"].required = False
5325
5331
 
5332
+ self.initial["vrfs"] = self.instance.vrfs.values_list("id", flat=True)
5333
+
5326
5334
  def save(self, commit=True):
5327
5335
  instance = super().save(commit)
5328
5336
  if commit:
5329
5337
  interfaces = self.cleaned_data["interfaces"]
5330
5338
  instance.interfaces.set(interfaces)
5339
+ vrfs = self.cleaned_data["vrfs"]
5340
+ instance.vrfs.set(vrfs)
5331
5341
  return instance
5332
5342
 
5333
5343
 
@@ -5345,6 +5355,8 @@ class VirtualDeviceContextBulkEditForm(
5345
5355
  remove_interfaces = DynamicModelMultipleChoiceField(
5346
5356
  queryset=Interface.objects.all(), required=False, query_params={"device": "$device"}
5347
5357
  )
5358
+ add_vrfs = DynamicModelMultipleChoiceField(queryset=VRF.objects.all(), required=False)
5359
+ remove_vrfs = DynamicModelMultipleChoiceField(queryset=VRF.objects.all(), required=False)
5348
5360
 
5349
5361
  class Meta:
5350
5362
  model = VirtualDeviceContext
@@ -670,11 +670,17 @@ class Device(PrimaryModel, ConfigContextModel):
670
670
 
671
671
  # Validate location
672
672
  if self.location is not None:
673
- # TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to assign a Rack belongs to
674
- # the parent Location or the child location of `self.location`?
675
-
676
- if self.rack is not None and self.rack.location != self.location:
677
- raise ValidationError({"rack": f'Rack "{self.rack}" does not belong to location "{self.location}".'})
673
+ if self.rack is not None:
674
+ device_location = self.location
675
+ # Rack's location must be a child location or the same location as that of the parent device.
676
+ # Location is a required field on rack.
677
+ rack_location = self.rack.location
678
+ if device_location not in rack_location.ancestors(include_self=True):
679
+ raise ValidationError(
680
+ {
681
+ "rack": f'Rack "{self.rack}" does not belong to location "{self.location}" and its descendants.'
682
+ }
683
+ )
678
684
 
679
685
  # self.cluster is validated somewhat later, see below
680
686
 
nautobot/dcim/signals.py CHANGED
@@ -16,6 +16,7 @@ from .models import (
16
16
  DeviceRedundancyGroup,
17
17
  Interface,
18
18
  InterfaceVDCAssignment,
19
+ LocationType,
19
20
  PathEndpoint,
20
21
  PowerPanel,
21
22
  Rack,
@@ -355,3 +356,28 @@ def handle_controller_managed_device_group_controller_change(instance, raw=False
355
356
  group.controller = instance.controller
356
357
  group.save()
357
358
  logger.debug("Updated controller from parent %s for child %s", instance, group)
359
+
360
+
361
+ @receiver(m2m_changed, sender=LocationType.content_types.through)
362
+ def content_type_changed(instance, action, **kwargs):
363
+ """
364
+ Prevents removal of a ContentType from LocationType if it's in use by any models
365
+ associated with the locations.
366
+ """
367
+
368
+ if action != "pre_remove":
369
+ return
370
+
371
+ removed_content_types = ContentType.objects.filter(pk__in=kwargs.get("pk_set", []))
372
+
373
+ for content_type in removed_content_types:
374
+ model_class = content_type.model_class()
375
+
376
+ if model_class.objects.filter(location__location_type=instance).exists():
377
+ raise ValidationError(
378
+ {
379
+ "content_types": (
380
+ f"Cannot remove the content type {content_type} as currently at least one {model_class._meta.verbose_name} is associated to a location of this location type. "
381
+ )
382
+ }
383
+ )
@@ -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 %}