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
nautobot/apps/filters.py CHANGED
@@ -43,6 +43,7 @@ from nautobot.extras.filters.mixins import (
43
43
  StatusFilter,
44
44
  )
45
45
  from nautobot.extras.plugins import FilterExtension
46
+ from nautobot.ipam.filters import PrefixFilter
46
47
  from nautobot.tenancy.filters import TenancyModelFilterSetMixin
47
48
 
48
49
  __all__ = (
@@ -72,6 +73,7 @@ __all__ = (
72
73
  "NaturalKeyOrPKMultipleChoiceFilter",
73
74
  "NautobotFilterSet",
74
75
  "NumericArrayFilter",
76
+ "PrefixFilter",
75
77
  "RelatedMembershipBooleanFilter",
76
78
  "RelationshipFilter",
77
79
  "RelationshipModelFilterSetMixin",
@@ -16,7 +16,7 @@ from nautobot.dcim.filters import (
16
16
  )
17
17
  from nautobot.dcim.models import Location
18
18
  from nautobot.extras.filters import NautobotFilterSet, StatusModelFilterSetMixin
19
- from nautobot.tenancy.filters import TenancyModelFilterSetMixin
19
+ from nautobot.tenancy.filters.mixins import TenancyModelFilterSetMixin
20
20
 
21
21
  from .models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
22
22
 
@@ -17,8 +17,7 @@ class CircuitTerminationModelTestCase(ModelTestCases.BaseModelTestCase):
17
17
  provider = Provider.objects.first()
18
18
  circuit_type = CircuitType.objects.first()
19
19
 
20
- location_type_1 = LocationType.objects.get(name="Campus")
21
- location_type_1.content_types.set([])
20
+ location_type_1 = LocationType.objects.create(name="University")
22
21
  location_type_2 = LocationType.objects.get(name="Building")
23
22
  location_type_2.content_types.add(ContentType.objects.get_for_model(CircuitTermination))
24
23
  status = Status.objects.get_for_model(Circuit).first()
@@ -26,7 +25,10 @@ class CircuitTerminationModelTestCase(ModelTestCases.BaseModelTestCase):
26
25
  cid="Circuit 1", provider=provider, circuit_type=circuit_type, status=status
27
26
  )
28
27
  cls.provider_network = ProviderNetwork.objects.create(name="Provider Network 1", provider=provider)
29
- cls.location_1 = Location.objects.filter(location_type=location_type_1)[0]
28
+ location_status = Status.objects.get_for_model(Location).first()
29
+ cls.location_1 = Location.objects.create(
30
+ name="Department", location_type=location_type_1, status=location_status
31
+ )
30
32
  cls.location_2 = Location.objects.filter(location_type=location_type_2)[0]
31
33
 
32
34
  cloud_resource_type = CloudResourceType.objects.get_for_model(CloudNetwork).first()
nautobot/cloud/filters.py CHANGED
@@ -1,5 +1,3 @@
1
- import django_filters
2
-
3
1
  from nautobot.cloud import models
4
2
  from nautobot.core.filters import (
5
3
  BaseFilterSet,
@@ -11,7 +9,7 @@ from nautobot.dcim.models import Manufacturer
11
9
  from nautobot.extras.filters import NautobotFilterSet
12
10
  from nautobot.extras.models import SecretsGroup
13
11
  from nautobot.extras.utils import FeatureQuery
14
- from nautobot.ipam.models import Prefix
12
+ from nautobot.ipam.filters import PrefixFilter
15
13
 
16
14
 
17
15
  class CloudAccountFilterSet(NautobotFilterSet):
@@ -98,7 +96,7 @@ class CloudNetworkFilterSet(NautobotFilterSet):
98
96
  queryset=models.CloudNetwork.objects.all(),
99
97
  label="Parent cloud network (name or ID)",
100
98
  )
101
- prefixes = django_filters.ModelMultipleChoiceFilter(queryset=Prefix.objects.all())
99
+ prefixes = PrefixFilter()
102
100
 
103
101
  class Meta:
104
102
  model = models.CloudNetwork
@@ -117,8 +115,7 @@ class CloudNetworkPrefixAssignmentFilterSet(BaseFilterSet):
117
115
  queryset=models.CloudNetwork.objects.all(),
118
116
  label="Cloud network (name or ID)",
119
117
  )
120
- # Prefix doesn't have an appropriate natural key for NaturalKeyOrPKMultipleChoiceFilter
121
- prefix = django_filters.ModelMultipleChoiceFilter(queryset=Prefix.objects.all())
118
+ prefix = PrefixFilter()
122
119
 
123
120
  class Meta:
124
121
  model = models.CloudNetworkPrefixAssignment
@@ -65,6 +65,7 @@ class CloudNetworkTestCase(FilterTestCases.FilterTestCase):
65
65
  ("name",),
66
66
  ("parent", "parent__id"),
67
67
  ("parent", "parent__name"),
68
+ ("prefixes", "prefixes__id"),
68
69
  ]
69
70
  exclude_q_filter_predicates = [
70
71
  "parent__name",
@@ -79,6 +80,16 @@ class CloudNetworkTestCase(FilterTestCases.FilterTestCase):
79
80
  queryset = queryset.filter(children__isnull=True)
80
81
  return queryset
81
82
 
83
+ def test_prefixes_filter_by_string(self):
84
+ """Test filtering by prefix strings as an alternative to pk."""
85
+ prefix = self.queryset.filter(prefixes__isnull=False).first().prefixes.first()
86
+ params = {"prefixes": [prefix.prefix]}
87
+ self.assertQuerysetEqualAndNotEmpty(
88
+ self.filterset(params, self.queryset).qs,
89
+ self.queryset.filter(prefixes__network=prefix.network, prefixes__prefix_length=prefix.prefix_length),
90
+ ordered=False,
91
+ )
92
+
82
93
 
83
94
  class CloudNetworkPrefixAssignmentTestCase(FilterTestCases.FilterTestCase):
84
95
  queryset = models.CloudNetworkPrefixAssignment.objects.all()
@@ -89,6 +100,16 @@ class CloudNetworkPrefixAssignmentTestCase(FilterTestCases.FilterTestCase):
89
100
  ("prefix", "prefix__id"),
90
101
  ]
91
102
 
103
+ def test_prefix_filter_by_string(self):
104
+ """Test filtering by prefix strings as an alternative to pk."""
105
+ prefix = self.queryset.first().prefix
106
+ params = {"prefix": [prefix.prefix]}
107
+ self.assertQuerysetEqualAndNotEmpty(
108
+ self.filterset(params, self.queryset).qs,
109
+ self.queryset.filter(prefix__network=prefix.network, prefix__prefix_length=prefix.prefix_length),
110
+ ordered=False,
111
+ )
112
+
92
113
 
93
114
  class CloudServiceNetworkAssignmentTestCase(FilterTestCases.FilterTestCase):
94
115
  queryset = models.CloudServiceNetworkAssignment.objects.all()
nautobot/core/admin.py CHANGED
@@ -9,7 +9,9 @@ from django_celery_beat.models import (
9
9
  PeriodicTask,
10
10
  SolarSchedule,
11
11
  )
12
+ import social_django.admin # noqa: F401 # unused-import -- but this import installs the social_django admin
12
13
  from social_django.models import Association, Nonce, UserSocialAuth
14
+ import taggit.admin # noqa: F401 # unused-import -- but this import installs the taggit admin
13
15
  from taggit.models import Tag
14
16
 
15
17
  from nautobot.core.forms import BootstrapMixin
@@ -229,7 +229,8 @@ class ExportObjectList(Job):
229
229
  # The force_csv=True attribute is a hack, but much easier than trying to construct a valid HttpRequest
230
230
  # object from scratch that passes all implicit and explicit assumptions in Django and DRF.
231
231
  serializer = serializer_class(queryset, many=True, context={"request": None}, force_csv=True)
232
- csv_data = renderer.render(serializer.data)
232
+ # Explicitly add UTF-8 BOM to the data so that Excel will understand non-ASCII characters correctly...
233
+ csv_data = codecs.BOM_UTF8 + renderer.render(serializer.data).encode("utf-8")
233
234
  self.create_file(filename + ".csv", csv_data)
234
235
 
235
236
 
@@ -1,5 +1,6 @@
1
1
  from typing import Optional
2
2
 
3
+ from django.apps import apps
3
4
  from django.conf import settings
4
5
  from django.core.management.base import BaseCommand
5
6
  from django.urls import get_resolver
@@ -162,6 +163,10 @@ class Command(BaseCommand):
162
163
  if model:
163
164
  app_name = model._meta.app_label
164
165
 
166
+ # Retrieve the base URL for the app to be used in the URL pattern
167
+ app_config = apps.get_app_config(app_name)
168
+ base_url = app_config.base_url if hasattr(app_config, "base_url") else app_name
169
+
165
170
  if app_name == "users" and pattern.name in ["login", "logout"]:
166
171
  # No need to test the login and logout endpoints for performance testing
167
172
  url_pattern = f"/{pattern.pattern}" # /login, /logout
@@ -199,7 +204,7 @@ class Command(BaseCommand):
199
204
  elif is_api_endpoint:
200
205
  if not is_app:
201
206
  # One of the nautobot apps: nautobot.circuits, nautobot.dcim, and etc.
202
- url_pattern = f"/api/{app_name}/{pattern.pattern}" # /api/dcim/devices/
207
+ url_pattern = f"/api/{base_url}/{pattern.pattern}" # /api/dcim/devices/
203
208
  app_name = f"{app_name}-api" # dcim-api
204
209
  view_name = f"{app_name}:{pattern.name}" # dcim-api:device-list
205
210
  else:
@@ -207,16 +212,14 @@ class Command(BaseCommand):
207
212
  view_name = (
208
213
  f"plugins-api:{api_app_name}:{pattern.name}" # plugins-api:example_app-api:examplemodel-list
209
214
  )
210
- app_name = app_name.replace("_", "-") # example_app -> example-app
211
- url_pattern = f"/api/plugins/{app_name}/{pattern.pattern}" # /api/plugins/example-app/models/
215
+ url_pattern = f"/api/plugins/{base_url}/{pattern.pattern}" # /api/plugins/example-app/models/
212
216
  else:
213
217
  if not is_app:
214
- url_pattern = f"/{app_name}/{pattern.pattern}" # /dcim/devices/
218
+ url_pattern = f"/{base_url}/{pattern.pattern}" # /dcim/devices/
215
219
  view_name = f"{app_name}:{pattern.name}" # dcim:device_list
216
220
  else:
217
221
  view_name = f"plugins:{app_name}:{pattern.name}" # plugins:example_app:examplemodel_list
218
- app_name = app_name.replace("_", "-") # example_app -> example-app
219
- url_pattern = f"/plugins/{app_name}/{pattern.pattern}" # /plugins/example-app/models/
222
+ url_pattern = f"/plugins/{base_url}/{pattern.pattern}" # /plugins/example-app/models/
220
223
 
221
224
  return url_pattern, view_name, is_api_endpoint
222
225
 
@@ -112,7 +112,12 @@ def serialize_object(obj, extra=None, exclude=None):
112
112
 
113
113
  # Include any tags. Check for tags cached on the instance; fall back to using the manager.
114
114
  if is_taggable(obj):
115
- tags = getattr(obj, "_tags", []) or obj.tags.all()
115
+ # Note that when upgrading from Nautobot 1.x to 2.0, this method may be called during data migrations,
116
+ # specifically ipam_0022 and dcim_0034, to create ObjectChange records.
117
+ # This can be problematic (see issue #6952) as the Tag records in the DB still have `created` as a `DateField`,
118
+ # but the 2.x code expects this to be a `DateTimeField` (as it will be after the upgrade completes in full).
119
+ # We "cleverly" bypass that issue by using `.only("name")` since that's the only actual Tag field we need here.
120
+ tags = getattr(obj, "_tags", []) or obj.tags.only("name")
116
121
  data["tags"] = [tag.name for tag in tags]
117
122
 
118
123
  # Append any extra data
@@ -27,6 +27,7 @@
27
27
  <script src="{% versioned_static 'js/dropdown.js' %}"
28
28
  onerror="window.location='{% url 'media_failure' %}?filename=js/dropdown.js'"></script>
29
29
  <script type="text/javascript">
30
+ var nautobot_static_url = "{% static '' %}";
30
31
  var nautobot_api_path = "{% url 'api-root' %}";
31
32
  var nautobot_csrf_token = "{{ csrf_token }}";
32
33
  var loading = $(".loading");
@@ -1,9 +1,13 @@
1
+ import logging
2
+
1
3
  from django import template
2
4
  from django.utils.html import format_html_join
3
5
 
4
6
  from nautobot.core.utils.lookup import get_view_for_model
5
7
  from nautobot.core.views.utils import get_obj_from_context
6
8
 
9
+ logger = logging.getLogger(__name__)
10
+
7
11
  register = template.Library()
8
12
 
9
13
 
@@ -26,15 +30,27 @@ def render_components(context, components):
26
30
  @register.simple_tag(takes_context=True)
27
31
  def render_detail_view_extra_buttons(context):
28
32
  """
29
- Render the "extra_buttons" if any from the base detail view associated with the context object.
33
+ Render the "extra_buttons" from the context's object_detail_content, or as fallback, from the base detail view.
30
34
 
31
35
  This makes it possible for "extra" tabs (such as Changelog and Notes, and any added by App TemplateExtensions)
32
36
  to automatically still render any `extra_buttons` defined by the base detail view, without the tab-specific views
33
37
  needing to explicitly inherit from the base view.
34
38
  """
35
- obj = get_obj_from_context(context)
36
- base_detail_view = get_view_for_model(obj)
37
- object_detail_content = getattr(base_detail_view, "object_detail_content", None)
39
+ object_detail_content = context.get("object_detail_content")
40
+ if object_detail_content is None:
41
+ obj = get_obj_from_context(context)
42
+ if obj is None:
43
+ logger.error("No 'obj' or 'object' found in the render context!")
44
+ return ""
45
+ base_detail_view = get_view_for_model(obj)
46
+ if base_detail_view is None:
47
+ logger.warning(
48
+ "Unable to identify the base detail view - check that it has a valid name, i.e. %sUIViewSet or %sView",
49
+ type(obj).__name__,
50
+ type(obj).__name__,
51
+ )
52
+ return ""
53
+ object_detail_content = getattr(base_detail_view, "object_detail_content", None)
38
54
  if object_detail_content is not None and object_detail_content.extra_buttons:
39
55
  return render_components(context, object_detail_content.extra_buttons)
40
56
  return ""
@@ -24,7 +24,7 @@ class FormTestCases:
24
24
  self.skipTest(f"{self.form_class.__name__}.{field_name} has no query_params")
25
25
  field_model = field_class.queryset.model
26
26
  filterset_class = get_filterset_for_model(field_model)
27
- filterset_fields = set(filterset_class.declared_filters.keys())
27
+ filterset_fields = set(filterset_class.get_filters().keys())
28
28
  invalid_query_params = query_params_fields - filterset_fields
29
29
  self.assertFalse(
30
30
  invalid_query_params,
@@ -609,7 +609,7 @@ class WritableNestedSerializerTest(testing.APITestCase):
609
609
  dcim_models.LocationType.objects.get(name="Building"),
610
610
  ]
611
611
  for location_type in self.locations_types:
612
- location_type.content_types.set([vlan_group_ct, vlan_ct])
612
+ location_type.content_types.add(vlan_group_ct, vlan_ct)
613
613
 
614
614
  self.statuses = extras_models.Status.objects.get_for_model(dcim_models.Location)
615
615
  self.location1 = dcim_models.Location.objects.create(
@@ -8,7 +8,7 @@ from django.apps import apps
8
8
  from django.contrib.auth import get_user_model
9
9
  from django.contrib.auth.models import Group
10
10
  from django.contrib.contenttypes.models import ContentType
11
- from django.db.models import Q
11
+ from django.db.models import Count, Q
12
12
  from django.test import override_settings, TestCase
13
13
  from django.test.client import RequestFactory
14
14
  from django.urls import reverse
@@ -919,8 +919,8 @@ class GraphQLQueryTest(GraphQLTestCaseBase):
919
919
  priority=789,
920
920
  ),
921
921
  )
922
- prefixes = Prefix.objects.all()[:2]
923
- cls.namespace = prefixes[0].namespace
922
+ cls.namespace = Namespace.objects.annotate(prefix_count=Count("prefixes")).filter(prefix_count__gt=2).first()
923
+ prefixes = Prefix.objects.filter(namespace=cls.namespace)
924
924
  vrfs = (
925
925
  VRF.objects.create(name="VRF 1", rd="65000:100", namespace=cls.namespace),
926
926
  VRF.objects.create(name="VRF 2", rd="65000:200", namespace=cls.namespace),
@@ -1,3 +1,4 @@
1
+ import codecs
1
2
  from datetime import timedelta
2
3
  import json
3
4
  from pathlib import Path
@@ -72,7 +73,9 @@ class ExportObjectListTest(TransactionTestCase):
72
73
  self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
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
 
@@ -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
+ )