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
@@ -1,5 +1,6 @@
1
1
  {% extends 'generic/object_retrieve.html' %}
2
2
  {% load helpers %}
3
+ {% load static %}
3
4
 
4
5
  {% block title %}{{ object }}{% endblock %}
5
6
 
@@ -95,6 +96,20 @@
95
96
  </tr>
96
97
  </table>
97
98
  </div>
99
+ <div class="panel panel-default">
100
+ <div class="panel-heading">
101
+ <strong>Object Data</strong>
102
+ </div>
103
+ <div class="panel-body">
104
+ <div class="editor-container"
105
+ data-lang="json"
106
+ data-value="{{ object.object_data|render_json:False }}"
107
+ style="max-height: 300px">
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ <div class="col-md-7">
98
113
  <div class="panel panel-default">
99
114
  <div class="panel-heading">
100
115
  <strong>Difference</strong>
@@ -119,22 +134,17 @@
119
134
  {% endif %}
120
135
  </span>
121
136
  {% else %}
122
- <pre class="diff-removed">{{ diff_removed|render_json }}</pre>
123
- <pre class="diff-added">{{ diff_added|render_json }}</pre>
137
+ <div class="editor-container"
138
+ data-mode="diff"
139
+ data-original="{{ diff_removed | render_json:False }}"
140
+ data-modified="{{ diff_added | render_json:False }}"
141
+ data-lang="json"
142
+ style="max-height: 730px">
143
+ </div>
124
144
  {% endif %}
125
145
  </div>
126
146
  </div>
127
147
  </div>
128
- <div class="col-md-7">
129
- <div class="panel panel-default">
130
- <div class="panel-heading">
131
- <strong>Object Data</strong>
132
- </div>
133
- <div class="panel-body">
134
- <pre>{{ object.object_data|render_json }}</pre>
135
- </div>
136
- </div>
137
- </div>
138
148
  </div>
139
149
  <div class="row">
140
150
  <div class="col-md-12">
@@ -147,3 +157,9 @@
147
157
  </div>
148
158
  </div>
149
159
  {% endblock %}
160
+
161
+ {% block javascript %}
162
+ {{ block.super }}
163
+ <script src="{% static 'js/editor.js' %}"></script>
164
+ {% endblock %}
165
+
@@ -196,20 +196,6 @@ class ComputedFieldTest(APIViewTestCases.APIViewTestCase):
196
196
 
197
197
  class ConfigContextTest(APIViewTestCases.APIViewTestCase):
198
198
  model = ConfigContext
199
- create_data = [
200
- {
201
- "name": "Config Context 4",
202
- "data": {"more_foo": True},
203
- },
204
- {
205
- "name": "Config Context 5",
206
- "data": {"more_bar": False},
207
- },
208
- {
209
- "name": "Config Context 6",
210
- "data": {"more_baz": None},
211
- },
212
- ]
213
199
  bulk_update_data = {
214
200
  "description": "New description",
215
201
  }
@@ -220,6 +206,21 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
220
206
  ConfigContext.objects.create(name="Config Context 1", weight=100, data={"foo": 123})
221
207
  ConfigContext.objects.create(name="Config Context 2", weight=200, data={"bar": 456})
222
208
  ConfigContext.objects.create(name="Config Context 3", weight=300, data={"baz": 789})
209
+ cls.create_data = [
210
+ {
211
+ "name": "Config Context 4",
212
+ "data": {"more_foo": True},
213
+ "tags": [tag.pk for tag in Tag.objects.get_for_model(Device)],
214
+ },
215
+ {
216
+ "name": "Config Context 5",
217
+ "data": {"more_bar": False},
218
+ },
219
+ {
220
+ "name": "Config Context 6",
221
+ "data": {"more_baz": None},
222
+ },
223
+ ]
223
224
 
224
225
  def test_render_configcontext_for_object(self):
225
226
  """
@@ -1797,7 +1798,7 @@ class JobTest(
1797
1798
  )
1798
1799
  self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
1799
1800
  self.assertIn(
1800
- "task_queue and job_queue are both specified. Please specifiy only one or another.", str(response.content)
1801
+ "task_queue and job_queue are both specified. Please specify only one or another.", str(response.content)
1801
1802
  )
1802
1803
 
1803
1804
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
@@ -880,6 +880,8 @@ class JobFilterSetTestCase(FilterTestCases.FilterTestCase):
880
880
  generic_filter_tests = (
881
881
  ("grouping",),
882
882
  ("job_class_name",),
883
+ ("job_queues", "job_queues__id"),
884
+ ("job_queues", "job_queues__name"),
883
885
  ("module_name",),
884
886
  ("name",),
885
887
  )
@@ -17,11 +17,12 @@ from nautobot.dcim.models import Device, DeviceType, Location, LocationType, Man
17
17
  from nautobot.dcim.tests.test_views import create_test_device
18
18
  from nautobot.extras import plugins
19
19
  from nautobot.extras.choices import CustomFieldTypeChoices, RelationshipTypeChoices
20
+ from nautobot.extras.context_managers import web_request_context
20
21
  from nautobot.extras.jobs import get_job
21
22
  from nautobot.extras.models import CustomField, Relationship, RelationshipAssociation, Role, Secret, Status
22
23
  from nautobot.extras.plugins.exceptions import PluginImproperlyConfigured
23
24
  from nautobot.extras.plugins.utils import load_plugin
24
- from nautobot.extras.plugins.validators import wrap_model_clean_methods
25
+ from nautobot.extras.plugins.validators import CustomValidator, wrap_model_clean_methods
25
26
  from nautobot.extras.plugins.views import extract_app_data
26
27
  from nautobot.extras.registry import DatasourceContent, registry
27
28
  from nautobot.ipam.models import IPAddress, Namespace, Prefix
@@ -478,6 +479,16 @@ class AppAPITest(APIViewTestCases.APIViewTestCase):
478
479
  pass
479
480
 
480
481
 
482
+ class TestUserContextCustomValidator(CustomValidator):
483
+ model = "dcim.locationtype"
484
+
485
+ def clean(self):
486
+ """
487
+ Used to validate that the correct user context is available in the custom validator.
488
+ """
489
+ self.validation_error(self.context["user"])
490
+
491
+
481
492
  class AppCustomValidationTest(TestCase):
482
493
  def setUp(self):
483
494
  # When creating a fresh test DB, wrapping model clean methods fails, which is normal.
@@ -485,6 +496,7 @@ class AppCustomValidationTest(TestCase):
485
496
  # must manually call the method again to actually perform the action, now that the
486
497
  # ContentType table has been created.
487
498
  wrap_model_clean_methods()
499
+ super().setUp()
488
500
 
489
501
  def test_custom_validator_raises_exception(self):
490
502
  location_type = LocationType.objects.get(name="Campus")
@@ -513,6 +525,25 @@ class AppCustomValidationTest(TestCase):
513
525
  with self.assertRaises(ValidationError):
514
526
  relationship_assoc.clean()
515
527
 
528
+ def test_custom_validator_non_web_request_uses_anonymous_user(self):
529
+ location_type = LocationType.objects.get(name="Campus")
530
+ registry["plugin_custom_validators"]["dcim.locationtype"] = [TestUserContextCustomValidator]
531
+
532
+ from django.contrib.auth.models import AnonymousUser
533
+
534
+ with self.assertRaises(ValidationError) as context:
535
+ location_type.clean()
536
+ self.assertEqual(context.exception.message, AnonymousUser())
537
+
538
+ def test_custom_validator_web_request_uses_real_user(self):
539
+ location_type = LocationType.objects.get(name="Campus")
540
+ registry["plugin_custom_validators"]["dcim.locationtype"] = [TestUserContextCustomValidator]
541
+
542
+ with self.assertRaises(ValidationError) as context:
543
+ with web_request_context(user=self.user):
544
+ location_type.clean()
545
+ self.assertEqual(context.exception.message, self.user)
546
+
516
547
 
517
548
  class ExampleModelCustomActionViewTest(TestCase):
518
549
  """Test for custom action view `all_names` added to Example App"""
@@ -823,9 +823,12 @@ class DynamicGroupTestCase(
823
823
  location_ct = ContentType.objects.get_for_model(Location)
824
824
  instance = self._get_queryset().exclude(content_type=location_ct).first()
825
825
  # Add view permissions for the group's members:
826
- self.add_permissions(get_permission_for_model(instance.content_type.model_class(), "view"))
826
+ self.add_permissions(
827
+ get_permission_for_model(instance.content_type.model_class(), "view"), "extras.view_dynamicgroup"
828
+ )
827
829
 
828
- response = super().test_get_object_with_permission()
830
+ response = self.client.get(instance.get_absolute_url())
831
+ self.assertHttpStatus(response, 200)
829
832
 
830
833
  response_body = extract_page_body(response.content.decode(response.charset))
831
834
  # Check that the "members" table in the detail view includes all appropriate member objects
@@ -1177,6 +1180,13 @@ class GitRepositoryTestCase(
1177
1180
  self.form_data = form_data
1178
1181
  super().test_edit_object_with_constrained_permission()
1179
1182
 
1183
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
1184
+ def test_view_when_no_sync_job_result_exists(self):
1185
+ instance = self._get_queryset().first()
1186
+ response = self.client.get(reverse("extras:gitrepository_result", kwargs={"pk": instance.pk}))
1187
+ self.assertEqual(response.status_code, 200)
1188
+ self.assertEqual(response.context["result"], {})
1189
+
1180
1190
  def test_post_sync_repo_anonymous(self):
1181
1191
  self.client.logout()
1182
1192
  url = reverse("extras:gitrepository_sync", kwargs={"pk": self._get_queryset().first().pk})
nautobot/extras/views.py CHANGED
@@ -1165,6 +1165,9 @@ class GitRepositoryResultView(generic.ObjectView):
1165
1165
  def get_extra_context(self, request, instance):
1166
1166
  job_result = instance.get_latest_sync()
1167
1167
 
1168
+ if job_result is None:
1169
+ job_result = {}
1170
+
1168
1171
  return {
1169
1172
  "result": job_result,
1170
1173
  "base_template": "extras/gitrepository.html",
@@ -63,14 +63,13 @@ class VRFDeviceAssignmentSerializer(ValidatedModelSerializer):
63
63
  validators = []
64
64
 
65
65
  def validate(self, attrs):
66
- if attrs.get("device"):
67
- validator = UniqueTogetherValidator(queryset=VRFDeviceAssignment.objects.all(), fields=("device", "vrf"))
68
- validator(attrs, self)
69
- if attrs.get("virtual_machine"):
70
- validator = UniqueTogetherValidator(
71
- queryset=VRFDeviceAssignment.objects.all(), fields=("virtual_machine", "vrf")
72
- )
73
- validator(attrs, self)
66
+ foreign_key_fields = ["device", "virtual_machine", "virtual_device_context"]
67
+ for foreign_key in foreign_key_fields:
68
+ if attrs.get(foreign_key):
69
+ validator = UniqueTogetherValidator(
70
+ queryset=VRFDeviceAssignment.objects.all(), fields=(foreign_key, "vrf")
71
+ )
72
+ validator(attrs, self)
74
73
  return super().validate(attrs)
75
74
 
76
75
 
@@ -55,13 +55,13 @@ class VRFViewSet(NautobotModelViewSet):
55
55
  filterset_class = filters.VRFFilterSet
56
56
 
57
57
 
58
- class VRFDeviceAssignmentViewSet(NautobotModelViewSet):
58
+ class VRFDeviceAssignmentViewSet(ModelViewSet):
59
59
  queryset = VRFDeviceAssignment.objects.all()
60
60
  serializer_class = serializers.VRFDeviceAssignmentSerializer
61
61
  filterset_class = filters.VRFDeviceAssignmentFilterSet
62
62
 
63
63
 
64
- class VRFPrefixAssignmentViewSet(NautobotModelViewSet):
64
+ class VRFPrefixAssignmentViewSet(ModelViewSet):
65
65
  queryset = VRFPrefixAssignment.objects.all()
66
66
  serializer_class = serializers.VRFPrefixAssignmentSerializer
67
67
  filterset_class = filters.VRFPrefixAssignmentFilterSet
nautobot/ipam/factory.py CHANGED
@@ -15,7 +15,7 @@ from nautobot.core.factory import (
15
15
  random_instance,
16
16
  UniqueFaker,
17
17
  )
18
- from nautobot.dcim.models import Location
18
+ from nautobot.dcim.models import Location, VirtualDeviceContext
19
19
  from nautobot.extras.models import Role, Status
20
20
  from nautobot.ipam.choices import PrefixTypeChoices
21
21
  from nautobot.ipam.models import IPAddress, Namespace, Prefix, RIR, RouteTarget, VLAN, VLANGroup, VRF
@@ -127,6 +127,24 @@ class VRFFactory(PrimaryModelFactory):
127
127
  else:
128
128
  self.export_targets.set(get_random_instances(RouteTarget))
129
129
 
130
+ @factory.post_generation
131
+ def prefixes(self, create, extracted, **kwargs):
132
+ if create:
133
+ if extracted:
134
+ self.prefixes.set(extracted)
135
+ else:
136
+ self.prefixes.set(
137
+ get_random_instances(lambda: Prefix.objects.filter(namespace=self.namespace), minimum=0)
138
+ )
139
+
140
+ @factory.post_generation
141
+ def virtual_device_contexts(self, create, extracted, **kwargs):
142
+ if create:
143
+ if extracted:
144
+ self.virtual_device_contexts.set(extracted)
145
+ else:
146
+ self.virtual_device_contexts.set(get_random_instances(VirtualDeviceContext))
147
+
130
148
 
131
149
  class VLANGroupFactory(OrganizationalModelFactory):
132
150
  class Meta:
@@ -295,7 +313,6 @@ class PrefixFactory(PrimaryModelFactory):
295
313
  has_role = NautobotBoolIterator()
296
314
  has_tenant = NautobotBoolIterator()
297
315
  has_vlan = NautobotBoolIterator()
298
- # has_vrf = NautobotBoolIterator()
299
316
  is_ipv6 = NautobotBoolIterator()
300
317
 
301
318
  prefix = factory.Maybe(
@@ -321,12 +338,6 @@ class PrefixFactory(PrimaryModelFactory):
321
338
  None,
322
339
  )
323
340
  namespace = random_instance(Namespace, allow_null=False)
324
- # TODO: Update for M2M tests
325
- # vrf = factory.Maybe(
326
- # "has_vrf",
327
- # factory.SubFactory(VRFGetOrCreateFactory, tenant=factory.SelfAttribute("..tenant")),
328
- # None,
329
- # )
330
341
  rir = factory.Maybe("has_rir", random_instance(RIR, allow_null=False), None)
331
342
  date_allocated = factory.Maybe("has_date_allocated", factory.Faker("date_time", tzinfo=datetime.timezone.utc), None)
332
343
 
@@ -343,6 +354,14 @@ class PrefixFactory(PrimaryModelFactory):
343
354
  )
344
355
  )
345
356
 
357
+ @factory.post_generation
358
+ def vrfs(self, create, extracted, **kwargs):
359
+ if create:
360
+ if extracted:
361
+ self.vrfs.set(extracted)
362
+ else:
363
+ self.vrfs.set(get_random_instances(lambda: VRF.objects.filter(namespace=self.namespace), minimum=0))
364
+
346
365
  @factory.post_generation
347
366
  def children(self, create, extracted, **kwargs):
348
367
  """Creates child prefixes and ip addresses within the prefix IP space.
nautobot/ipam/filters.py CHANGED
@@ -19,10 +19,10 @@ from nautobot.core.filters import (
19
19
  TreeNodeMultipleChoiceFilter,
20
20
  )
21
21
  from nautobot.dcim.filters import LocatableModelFilterSetMixin
22
- from nautobot.dcim.models import Device, Interface, Location
22
+ from nautobot.dcim.models import Device, Interface, Location, VirtualDeviceContext
23
23
  from nautobot.extras.filters import NautobotFilterSet, RoleModelFilterSetMixin, StatusModelFilterSetMixin
24
- from nautobot.ipam import choices
25
- from nautobot.tenancy.filters import TenancyModelFilterSetMixin
24
+ from nautobot.ipam import choices, formfields
25
+ from nautobot.tenancy.filters.mixins import TenancyModelFilterSetMixin
26
26
  from nautobot.virtualization.models import VirtualMachine, VMInterface
27
27
 
28
28
  from .models import (
@@ -45,6 +45,7 @@ from .models import (
45
45
  __all__ = (
46
46
  "IPAddressFilterSet",
47
47
  "NamespaceFilterSet",
48
+ "PrefixFilter",
48
49
  "PrefixFilterSet",
49
50
  "RIRFilterSet",
50
51
  "RouteTargetFilterSet",
@@ -55,6 +56,39 @@ __all__ = (
55
56
  )
56
57
 
57
58
 
59
+ class PrefixFilter(NaturalKeyOrPKMultipleChoiceFilter):
60
+ """
61
+ Filter that supports filtering a foreign key to Prefix by either its PK or by a literal `prefix` string.
62
+ """
63
+
64
+ field_class = formfields.PrefixFilterFormField
65
+
66
+ def __init__(self, *args, **kwargs):
67
+ kwargs.setdefault("to_field_name", "pk")
68
+ kwargs.setdefault("label", "Prefix (ID or prefix string)")
69
+ kwargs.setdefault("queryset", Prefix.objects.all())
70
+ super().__init__(*args, **kwargs)
71
+
72
+ def get_filter_predicate(self, v):
73
+ # Null value filtering
74
+ if v is None:
75
+ return {f"{self.field_name}__isnull": True}
76
+
77
+ # If value is a model instance, stringify it to a pk.
78
+ if isinstance(v, Prefix):
79
+ v = v.pk
80
+
81
+ # Try to cast the value to a UUID to distinguish between PKs and prefix strings
82
+ v = str(v)
83
+ try:
84
+ uuid.UUID(v)
85
+ return {self.field_name: v}
86
+ except (AttributeError, TypeError, ValueError):
87
+ # It's a prefix string
88
+ prefixes_queryset = Prefix.objects.net_equals(v)
89
+ return {f"{self.field_name}__in": prefixes_queryset.values_list("pk", flat=True)}
90
+
91
+
58
92
  class NamespaceFilterSet(NautobotFilterSet):
59
93
  q = SearchFilter(
60
94
  filter_predicates={
@@ -96,12 +130,7 @@ class VRFFilterSet(NautobotFilterSet, StatusModelFilterSetMixin, TenancyModelFil
96
130
  to_field_name="name",
97
131
  label="Virtual Machine (ID or name)",
98
132
  )
99
- prefix = NaturalKeyOrPKMultipleChoiceFilter(
100
- field_name="prefixes",
101
- queryset=Prefix.objects.all(),
102
- to_field_name="pk", # TODO: Make this work with `prefix` "somehow"
103
- label="Prefix (ID or name)",
104
- )
133
+ prefix = PrefixFilter(field_name="prefixes")
105
134
  namespace = NaturalKeyOrPKMultipleChoiceFilter(
106
135
  queryset=Namespace.objects.all(),
107
136
  to_field_name="name",
@@ -114,6 +143,21 @@ class VRFFilterSet(NautobotFilterSet, StatusModelFilterSetMixin, TenancyModelFil
114
143
 
115
144
 
116
145
  class VRFDeviceAssignmentFilterSet(NautobotFilterSet):
146
+ q = SearchFilter(
147
+ filter_predicates={
148
+ "name": "icontains",
149
+ "vrf__name": "icontains",
150
+ "device__name": "icontains",
151
+ "virtual_machine__name": "icontains",
152
+ "virtual_device_context__name": "icontains",
153
+ "rd": "icontains",
154
+ },
155
+ )
156
+ vrf = NaturalKeyOrPKMultipleChoiceFilter(
157
+ queryset=VRF.objects.all(),
158
+ to_field_name="name",
159
+ label="VRF (ID or name)",
160
+ )
117
161
  device = NaturalKeyOrPKMultipleChoiceFilter(
118
162
  queryset=Device.objects.all(),
119
163
  to_field_name="name",
@@ -124,18 +168,25 @@ class VRFDeviceAssignmentFilterSet(NautobotFilterSet):
124
168
  to_field_name="name",
125
169
  label="Virtual Machine (ID or name)",
126
170
  )
171
+ virtual_device_context = NaturalKeyOrPKMultipleChoiceFilter(
172
+ queryset=VirtualDeviceContext.objects.all(),
173
+ to_field_name="name",
174
+ label="Virtual Device Context (ID or name)",
175
+ )
127
176
 
128
177
  class Meta:
129
178
  model = VRFDeviceAssignment
130
- fields = ["id", "vrf", "device", "virtual_machine"]
179
+ fields = ["id", "name", "rd"]
131
180
 
132
181
 
133
182
  class VRFPrefixAssignmentFilterSet(NautobotFilterSet):
134
- prefix = NaturalKeyOrPKMultipleChoiceFilter(
135
- queryset=Prefix.objects.all(),
136
- to_field_name="pk", # TODO: Make this work with `prefix` "somehow"
137
- label="Prefix (ID or name)",
183
+ q = SearchFilter(
184
+ filter_predicates={
185
+ # "prefix__prefix": "iexact", # TODO?
186
+ "vrf__name": "icontains",
187
+ },
138
188
  )
189
+ prefix = PrefixFilter()
139
190
  vrf = NaturalKeyOrPKMultipleChoiceFilter(
140
191
  queryset=VRF.objects.all(),
141
192
  to_field_name="name",
@@ -200,10 +251,7 @@ class PrefixFilterSet(
200
251
  StatusModelFilterSetMixin,
201
252
  RoleModelFilterSetMixin,
202
253
  ):
203
- # Prefix doesn't have an appropriate single natural-key field for a NaturalKeyOrPKMultipleChoiceFilter
204
- parent = django_filters.ModelMultipleChoiceFilter(
205
- queryset=Prefix.objects.all(),
206
- )
254
+ parent = PrefixFilter()
207
255
  prefix = MultiValueCharFilter(
208
256
  method="filter_prefix",
209
257
  label="Prefix",
@@ -343,10 +391,7 @@ class PrefixLocationAssignmentFilterSet(NautobotFilterSet):
343
391
  "location__name": "icontains",
344
392
  },
345
393
  )
346
- prefix = MultiValueCharFilter(
347
- method="filter_prefix",
348
- label="Prefix",
349
- )
394
+ prefix = PrefixFilter()
350
395
  location = TreeNodeMultipleChoiceFilter(
351
396
  prefers_id=True,
352
397
  queryset=Location.objects.all(),
@@ -357,13 +402,6 @@ class PrefixLocationAssignmentFilterSet(NautobotFilterSet):
357
402
  def _strip_values(self, values):
358
403
  return [value.strip() for value in values if value.strip()]
359
404
 
360
- def filter_prefix(self, queryset, name, value):
361
- prefixes = self._strip_values(value)
362
- with contextlib.suppress(netaddr.AddrFormatError, ValueError):
363
- prefixes_queryset = Prefix.objects.net_equals(*prefixes)
364
- return queryset.filter(prefix__in=prefixes_queryset)
365
- return queryset.none()
366
-
367
405
  class Meta:
368
406
  model = PrefixLocationAssignment
369
407
  fields = ["id", "prefix", "location"]
@@ -1,8 +1,12 @@
1
1
  from django import forms
2
2
  from django.core.exceptions import ValidationError
3
3
  from django.core.validators import validate_ipv4_address, validate_ipv6_address
4
+ from django.db.models import Q
4
5
  from netaddr import AddrFormatError, IPAddress, IPNetwork
5
6
 
7
+ from nautobot.core.forms.fields import MultiMatchModelMultipleChoiceField
8
+ from nautobot.core.utils.data import is_uuid
9
+
6
10
  #
7
11
  # Form fields
8
12
  #
@@ -58,3 +62,50 @@ class IPNetworkFormField(forms.Field):
58
62
  return IPNetwork(value)
59
63
  except AddrFormatError:
60
64
  raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
65
+
66
+
67
+ class PrefixFilterFormField(MultiMatchModelMultipleChoiceField):
68
+ @property
69
+ def filter(self):
70
+ from nautobot.ipam.filters import PrefixFilter # avoid circular definition
71
+
72
+ return PrefixFilter
73
+
74
+ def _check_values(self, values): # pylint: disable=arguments-renamed
75
+ null_value_present = self.null_label is not None and values and self.null_value in values
76
+ if null_value_present:
77
+ values = [v for v in values if v != self.null_value]
78
+ # deduplicate given values to avoid creating many querysets or
79
+ # requiring the database backend deduplicate efficiently.
80
+ try:
81
+ values = frozenset(values)
82
+ except TypeError:
83
+ raise ValidationError(self.error_messages["invalid_list"], code="invalid_list")
84
+ pk_values = set()
85
+ prefix_queries = []
86
+ for value in values:
87
+ if is_uuid(value):
88
+ pk_values.add(value)
89
+ query = Q(pk=value)
90
+ else:
91
+ ipnetwork = IPNetwork(value)
92
+ query = Q(
93
+ network=ipnetwork.network,
94
+ prefix_length=ipnetwork.prefixlen,
95
+ broadcast=ipnetwork.broadcast or ipnetwork[-1],
96
+ )
97
+ prefix_queries.append(query)
98
+ if not self.queryset.filter(query).exists():
99
+ raise ValidationError(
100
+ self.error_messages["invalid_choice"],
101
+ code="invalid_choice",
102
+ params={"value": value},
103
+ )
104
+ aggregate_query = Q(pk__in=pk_values)
105
+ for prefix_query in prefix_queries:
106
+ aggregate_query |= prefix_query
107
+ qs = self.queryset.filter(aggregate_query)
108
+ result = list(qs)
109
+ if null_value_present:
110
+ result.append(self.null_value)
111
+ return result
nautobot/ipam/forms.py CHANGED
@@ -23,7 +23,7 @@ from nautobot.dcim.form_mixins import (
23
23
  LocatableModelFilterFormMixin,
24
24
  LocatableModelFormMixin,
25
25
  )
26
- from nautobot.dcim.models import Device, Location, Rack
26
+ from nautobot.dcim.models import Device, Location, Rack, VirtualDeviceContext
27
27
  from nautobot.extras.forms import (
28
28
  NautobotBulkEditForm,
29
29
  NautobotFilterForm,
@@ -111,6 +111,9 @@ class VRFForm(NautobotModelForm, TenancyForm):
111
111
  namespace = DynamicModelChoiceField(queryset=Namespace.objects.all())
112
112
  devices = DynamicModelMultipleChoiceField(queryset=Device.objects.all(), required=False)
113
113
  virtual_machines = DynamicModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), required=False)
114
+ virtual_device_contexts = DynamicModelMultipleChoiceField(
115
+ queryset=VirtualDeviceContext.objects.all(), required=False
116
+ )
114
117
  prefixes = DynamicModelMultipleChoiceField(
115
118
  queryset=Prefix.objects.all(),
116
119
  required=False,
@@ -134,6 +137,7 @@ class VRFForm(NautobotModelForm, TenancyForm):
134
137
  "tags",
135
138
  "devices",
136
139
  "virtual_machines",
140
+ "virtual_device_contexts",
137
141
  "prefixes",
138
142
  ]
139
143
  labels = {
@@ -156,6 +160,14 @@ class VRFBulkEditForm(TagsBulkEditFormMixin, StatusModelBulkEditFormMixin, Nauto
156
160
  remove_prefixes = DynamicModelMultipleChoiceField(
157
161
  queryset=Prefix.objects.all(), required=False, query_params={"namespace": "$namespace"}
158
162
  )
163
+ add_virtual_device_contexts = DynamicModelMultipleChoiceField(
164
+ queryset=VirtualDeviceContext.objects.all(),
165
+ required=False,
166
+ )
167
+ remove_virtual_device_contexts = DynamicModelMultipleChoiceField(
168
+ queryset=VirtualDeviceContext.objects.all(),
169
+ required=False,
170
+ )
159
171
 
160
172
  class Meta:
161
173
  nullable_fields = [
@@ -0,0 +1,41 @@
1
+ # Generated by Django 4.2.19 on 2025-02-24 21:20
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ("virtualization", "0030_alter_virtualmachine_local_config_context_data_owner_content_type_and_more"),
10
+ ("dcim", "0067_controllermanageddevicegroup_tenant"),
11
+ ("ipam", "0050_vlangroup_range"),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AddField(
16
+ model_name="vrf",
17
+ name="virtual_device_contexts",
18
+ field=models.ManyToManyField(
19
+ related_name="vrfs", through="ipam.VRFDeviceAssignment", to="dcim.virtualdevicecontext"
20
+ ),
21
+ ),
22
+ migrations.AddField(
23
+ model_name="vrfdeviceassignment",
24
+ name="virtual_device_context",
25
+ field=models.ForeignKey(
26
+ blank=True,
27
+ null=True,
28
+ on_delete=django.db.models.deletion.CASCADE,
29
+ related_name="vrf_assignments",
30
+ to="dcim.virtualdevicecontext",
31
+ ),
32
+ ),
33
+ migrations.AlterUniqueTogether(
34
+ name="vrfdeviceassignment",
35
+ unique_together={
36
+ ("vrf", "virtual_device_context"),
37
+ ("vrf", "virtual_machine"),
38
+ ("vrf", "device"),
39
+ },
40
+ ),
41
+ ]