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.
- nautobot/apps/filters.py +2 -0
- nautobot/circuits/filters.py +1 -1
- nautobot/circuits/tests/test_models.py +5 -3
- nautobot/cloud/filters.py +3 -6
- nautobot/cloud/tests/test_filters.py +21 -0
- nautobot/core/admin.py +2 -0
- nautobot/core/jobs/__init__.py +2 -1
- nautobot/core/management/commands/generate_performance_test_endpoints.py +9 -6
- nautobot/core/models/utils.py +6 -1
- nautobot/core/templates/inc/javascript.html +1 -0
- nautobot/core/templatetags/ui_framework.py +20 -4
- nautobot/core/testing/forms.py +1 -1
- nautobot/core/tests/test_api.py +1 -1
- nautobot/core/tests/test_graphql.py +3 -3
- nautobot/core/tests/test_jobs.py +4 -1
- nautobot/core/ui/object_detail.py +1 -1
- nautobot/dcim/api/serializers.py +36 -0
- nautobot/dcim/api/views.py +1 -1
- nautobot/dcim/elevations.py +17 -4
- nautobot/dcim/factory.py +9 -1
- nautobot/dcim/filters/__init__.py +27 -1
- nautobot/dcim/forms.py +13 -1
- nautobot/dcim/models/devices.py +11 -5
- nautobot/dcim/signals.py +26 -0
- nautobot/dcim/templates/dcim/virtualdevicecontext_retrieve.html +0 -62
- nautobot/dcim/templates/dcim/virtualdevicecontext_update.html +6 -0
- nautobot/dcim/tests/test_api.py +176 -0
- nautobot/dcim/tests/test_filters.py +56 -3
- nautobot/dcim/tests/test_models.py +40 -0
- nautobot/dcim/views.py +24 -14
- nautobot/extras/api/mixins.py +1 -1
- nautobot/extras/api/views.py +2 -2
- nautobot/extras/filters/__init__.py +4 -0
- nautobot/extras/models/datasources.py +7 -3
- nautobot/extras/plugins/__init__.py +26 -1
- nautobot/extras/templates/extras/inc/jobresult.html +12 -13
- nautobot/extras/templates/extras/objectchange.html +28 -12
- nautobot/extras/tests/test_api.py +16 -15
- nautobot/extras/tests/test_filters.py +2 -0
- nautobot/extras/tests/test_plugins.py +32 -1
- nautobot/extras/tests/test_views.py +12 -2
- nautobot/extras/views.py +3 -0
- nautobot/ipam/api/serializers.py +7 -8
- nautobot/ipam/api/views.py +2 -2
- nautobot/ipam/factory.py +27 -8
- nautobot/ipam/filters.py +67 -29
- nautobot/ipam/formfields.py +51 -0
- nautobot/ipam/forms.py +13 -1
- nautobot/ipam/migrations/0051_added_optional_vrf_relationship_to_vdc.py +41 -0
- nautobot/ipam/models.py +63 -5
- nautobot/ipam/tables.py +21 -7
- nautobot/ipam/tests/test_api.py +107 -66
- nautobot/ipam/tests/test_filters.py +145 -5
- nautobot/ipam/tests/test_views.py +15 -2
- nautobot/project-static/css/base.css +11 -0
- nautobot/project-static/css/dark.css +2 -1
- nautobot/project-static/docs/code-reference/nautobot/apps/filters.html +62 -0
- nautobot/project-static/docs/development/apps/api/configuration-view.html +0 -3
- nautobot/project-static/docs/development/apps/api/models/graphql.html +0 -4
- nautobot/project-static/docs/development/apps/api/platform-features/custom-validators.html +94 -1
- nautobot/project-static/docs/development/apps/api/platform-features/filter-extensions.html +0 -3
- nautobot/project-static/docs/development/apps/api/platform-features/jinja2-filters.html +0 -3
- nautobot/project-static/docs/development/apps/api/platform-features/populating-extensibility-features.html +0 -3
- nautobot/project-static/docs/development/apps/api/platform-features/secrets-providers.html +0 -3
- nautobot/project-static/docs/development/apps/api/prometheus.html +0 -3
- nautobot/project-static/docs/development/apps/api/testing.html +0 -6
- nautobot/project-static/docs/development/apps/api/ui-extensions/banners.html +0 -3
- nautobot/project-static/docs/development/apps/api/ui-extensions/home-page.html +0 -3
- nautobot/project-static/docs/development/apps/api/ui-extensions/object-views.html +0 -3
- nautobot/project-static/docs/development/apps/api/views/core-view-overrides.html +0 -3
- nautobot/project-static/docs/development/apps/api/views/nautobot-generic-views.html +1 -7
- nautobot/project-static/docs/development/apps/api/views/nautobotuiviewset.html +0 -7
- nautobot/project-static/docs/development/apps/api/views/nautobotuiviewsetrouter.html +0 -4
- nautobot/project-static/docs/development/apps/api/views/notes.html +0 -3
- nautobot/project-static/docs/development/apps/index.html +2 -35
- nautobot/project-static/docs/development/apps/migration/code-updates.html +1 -1
- nautobot/project-static/docs/development/core/application-registry.html +0 -6
- nautobot/project-static/docs/development/core/best-practices.html +0 -27
- nautobot/project-static/docs/development/core/docker-compose-advanced-use-cases.html +58 -4
- nautobot/project-static/docs/development/core/getting-started.html +12 -16
- nautobot/project-static/docs/development/core/homepage.html +0 -3
- nautobot/project-static/docs/development/core/style-guide.html +0 -5
- nautobot/project-static/docs/development/core/templates.html +0 -3
- nautobot/project-static/docs/development/core/testing.html +0 -9
- nautobot/project-static/docs/development/jobs/index.html +3 -29
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/overview/application_stack.html +0 -18
- nautobot/project-static/docs/release-notes/version-2.4.html +191 -0
- nautobot/project-static/docs/requirements.txt +1 -1
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +290 -290
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/configuration/settings.html +0 -10
- nautobot/project-static/docs/user-guide/administration/guides/docker.html +0 -15
- nautobot/project-static/docs/user-guide/administration/installation/index.html +0 -16
- nautobot/project-static/docs/user-guide/administration/installation/nautobot.html +1 -4
- nautobot/project-static/docs/user-guide/administration/installation/services.html +0 -11
- nautobot/project-static/docs/user-guide/administration/migration/migrating-from-postgresql.html +3 -3
- nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +5 -35
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/tables/v2-code-location-changes.yaml +1 -1
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +1 -1
- nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/circuits/providernetwork.html +0 -3
- nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleport.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleporttemplate.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverport.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/consoleserverporttemplate.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebay.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/devicebaytemplate.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/deviceredundancygroup.html +0 -3
- nautobot/project-static/docs/user-guide/core-data-model/dcim/devicetype.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/frontport.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/frontporttemplate.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/interface.html +1 -17
- nautobot/project-static/docs/user-guide/core-data-model/dcim/interfaceredundancygroup.html +0 -3
- nautobot/project-static/docs/user-guide/core-data-model/dcim/interfacetemplate.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/inventoryitem.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/location.html +0 -3
- nautobot/project-static/docs/user-guide/core-data-model/dcim/locationtype.html +1 -7
- nautobot/project-static/docs/user-guide/core-data-model/dcim/platform.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlet.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/poweroutlettemplate.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/powerport.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/powerporttemplate.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/rearport.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/dcim/rearporttemplate.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/extras/configcontext.html +0 -6
- nautobot/project-static/docs/user-guide/core-data-model/extras/configcontextschema.html +0 -3
- nautobot/project-static/docs/user-guide/core-data-model/ipam/ipaddress.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +0 -4
- nautobot/project-static/docs/user-guide/core-data-model/virtualization/vminterface.html +0 -8
- nautobot/project-static/docs/user-guide/feature-guides/custom-fields.html +3 -3
- nautobot/project-static/docs/user-guide/feature-guides/graphql.html +0 -6
- nautobot/project-static/docs/user-guide/platform-functionality/computedfield.html +0 -3
- nautobot/project-static/docs/user-guide/platform-functionality/customfield.html +3 -15
- nautobot/project-static/docs/user-guide/platform-functionality/dynamicgroup.html +0 -26
- nautobot/project-static/docs/user-guide/platform-functionality/gitrepository.html +0 -8
- nautobot/project-static/docs/user-guide/platform-functionality/graphql.html +0 -3
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +0 -8
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/job-scheduling-and-approvals.html +0 -7
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobbutton.html +0 -3
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/jobhook.html +0 -3
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +0 -14
- nautobot/project-static/docs/user-guide/platform-functionality/note.html +0 -3
- nautobot/project-static/docs/user-guide/platform-functionality/relationship.html +1 -10
- nautobot/project-static/docs/user-guide/platform-functionality/rest-api/authentication.html +0 -3
- nautobot/project-static/docs/user-guide/platform-functionality/rest-api/filtering.html +0 -14
- nautobot/project-static/docs/user-guide/platform-functionality/rest-api/overview.html +0 -19
- nautobot/project-static/docs/user-guide/platform-functionality/secret.html +3 -9
- nautobot/project-static/docs/user-guide/platform-functionality/status.html +0 -8
- nautobot/project-static/docs/user-guide/platform-functionality/tag.html +0 -4
- nautobot/project-static/docs/user-guide/platform-functionality/template-filters.html +1 -13
- nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +0 -5
- nautobot/project-static/js/editor.js +292 -0
- nautobot/project-static/monaco-editor-0.52.2/README.md +81 -0
- nautobot/project-static/monaco-editor-0.52.2/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- nautobot/project-static/monaco-editor-0.52.2/vs/base/worker/workerMain.js +31 -0
- nautobot/project-static/monaco-editor-0.52.2/vs/basic-languages/xml/xml.js +10 -0
- nautobot/project-static/monaco-editor-0.52.2/vs/basic-languages/yaml/yaml.js +10 -0
- nautobot/project-static/monaco-editor-0.52.2/vs/editor/editor.main.css +8 -0
- nautobot/project-static/monaco-editor-0.52.2/vs/editor/editor.main.js +798 -0
- nautobot/project-static/monaco-editor-0.52.2/vs/language/json/jsonMode.js +19 -0
- nautobot/project-static/monaco-editor-0.52.2/vs/language/json/jsonWorker.js +42 -0
- nautobot/project-static/monaco-editor-0.52.2/vs/loader.js +11 -0
- nautobot/tenancy/filters/__init__.py +3 -5
- nautobot/tenancy/tests/test_filters.py +10 -0
- nautobot/virtualization/views.py +0 -1
- nautobot/wireless/tables.py +9 -4
- nautobot/wireless/tests/test_api.py +0 -9
- {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/METADATA +2 -2
- {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/RECORD +175 -163
- {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/NOTICE +0 -0
- {nautobot-2.4.3.dist-info → nautobot-2.4.4.dist-info}/WHEEL +0 -0
- {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",
|
nautobot/circuits/filters.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
nautobot/core/jobs/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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/{
|
|
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
|
-
|
|
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"/{
|
|
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
|
-
|
|
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
|
|
nautobot/core/models/utils.py
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 ""
|
nautobot/core/testing/forms.py
CHANGED
|
@@ -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.
|
|
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,
|
nautobot/core/tests/test_api.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
923
|
-
|
|
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),
|
nautobot/core/tests/test_jobs.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
)
|
nautobot/dcim/api/serializers.py
CHANGED
|
@@ -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()
|
nautobot/dcim/api/views.py
CHANGED
|
@@ -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
|
)
|
nautobot/dcim/elevations.py
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
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}{
|
|
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: #{
|
|
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={"
|
|
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
|
nautobot/dcim/models/devices.py
CHANGED
|
@@ -670,11 +670,17 @@ class Device(PrimaryModel, ConfigContextModel):
|
|
|
670
670
|
|
|
671
671
|
# Validate location
|
|
672
672
|
if self.location is not None:
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
+
)
|