nautobot 2.4.3__py3-none-any.whl → 2.4.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- nautobot/__init__.py +19 -3
- 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/celery/__init__.py +5 -3
- nautobot/core/jobs/__init__.py +5 -3
- 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/__init__.py +2 -0
- nautobot/core/testing/forms.py +1 -1
- nautobot/core/testing/mixins.py +9 -0
- nautobot/core/tests/test_api.py +1 -1
- nautobot/core/tests/test_graphql.py +3 -3
- nautobot/core/tests/test_jobs.py +30 -28
- 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_jobs.py +4 -6
- 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/choices.py +8 -3
- nautobot/extras/filters/__init__.py +4 -0
- nautobot/extras/jobs.py +181 -103
- nautobot/extras/management/utils.py +13 -2
- nautobot/extras/models/datasources.py +11 -4
- nautobot/extras/models/jobs.py +20 -17
- nautobot/extras/plugins/__init__.py +26 -1
- nautobot/extras/tables.py +25 -29
- nautobot/extras/templates/extras/inc/jobresult.html +12 -13
- nautobot/extras/templates/extras/objectchange.html +28 -12
- nautobot/extras/test_jobs/atomic_transaction.py +6 -6
- nautobot/extras/test_jobs/fail.py +75 -1
- nautobot/extras/tests/test_api.py +17 -16
- nautobot/extras/tests/test_datasources.py +64 -54
- nautobot/extras/tests/test_filters.py +2 -0
- nautobot/extras/tests/test_jobs.py +69 -62
- nautobot/extras/tests/test_models.py +1 -1
- nautobot/extras/tests/test_plugins.py +32 -1
- nautobot/extras/tests/test_relationships.py +5 -5
- nautobot/extras/tests/test_views.py +12 -2
- nautobot/extras/views.py +10 -1
- 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 +28 -1
- nautobot/ipam/migrations/0051_added_optional_vrf_relationship_to_vdc.py +41 -0
- nautobot/ipam/models.py +63 -5
- nautobot/ipam/querysets.py +6 -0
- nautobot/ipam/tables.py +21 -7
- nautobot/ipam/templates/ipam/rir.html +1 -43
- nautobot/ipam/tests/test_api.py +107 -66
- nautobot/ipam/tests/test_filters.py +145 -5
- nautobot/ipam/tests/test_models.py +16 -0
- nautobot/ipam/tests/test_views.py +15 -2
- nautobot/ipam/urls.py +1 -21
- nautobot/ipam/views.py +24 -41
- 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/code-reference/nautobot/apps/jobs.html +43 -5
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +35 -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 +30 -43
- 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 +374 -0
- nautobot/project-static/docs/requirements.txt +2 -2
- 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.5.dist-info}/METADATA +4 -4
- {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/RECORD +198 -186
- {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/NOTICE +0 -0
- {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/WHEEL +0 -0
- {nautobot-2.4.3.dist-info → nautobot-2.4.5.dist-info}/entry_points.txt +0 -0
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
|
|
@@ -47,7 +48,7 @@ class ExportObjectListTest(TransactionTestCase):
|
|
|
47
48
|
username=self.user.username, # otherwise run_job_for_testing defaults to a superuser account
|
|
48
49
|
content_type=ContentType.objects.get_for_model(Status).pk,
|
|
49
50
|
)
|
|
50
|
-
self.
|
|
51
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
51
52
|
log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
|
|
52
53
|
self.assertEqual(log_error.message, f'User "{self.user}" does not have permission to view status objects')
|
|
53
54
|
self.assertFalse(job_result.files.exists())
|
|
@@ -69,10 +70,12 @@ class ExportObjectListTest(TransactionTestCase):
|
|
|
69
70
|
username=self.user.username, # otherwise run_job_for_testing defaults to a superuser account
|
|
70
71
|
content_type=ContentType.objects.get_for_model(Status).pk,
|
|
71
72
|
)
|
|
72
|
-
self.
|
|
73
|
+
self.assertJobResultStatus(job_result)
|
|
73
74
|
self.assertTrue(job_result.files.exists())
|
|
74
75
|
self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_statuses.csv")
|
|
75
|
-
|
|
76
|
+
csv_bytes = job_result.files.first().file.read()
|
|
77
|
+
self.assertTrue(csv_bytes.startswith(codecs.BOM_UTF8), csv_bytes)
|
|
78
|
+
csv_data = csv_bytes.decode("utf-8")
|
|
76
79
|
self.assertIn(str(instance1.pk), csv_data)
|
|
77
80
|
self.assertNotIn(str(instance2.pk), csv_data)
|
|
78
81
|
|
|
@@ -83,7 +86,7 @@ class ExportObjectListTest(TransactionTestCase):
|
|
|
83
86
|
"ExportObjectList",
|
|
84
87
|
content_type=ContentType.objects.get_for_model(Status).pk,
|
|
85
88
|
)
|
|
86
|
-
self.
|
|
89
|
+
self.assertJobResultStatus(job_result)
|
|
87
90
|
self.assertTrue(job_result.files.exists())
|
|
88
91
|
self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_statuses.csv")
|
|
89
92
|
csv_data = job_result.files.first().file.read().decode("utf-8")
|
|
@@ -104,7 +107,7 @@ class ExportObjectListTest(TransactionTestCase):
|
|
|
104
107
|
content_type=ContentType.objects.get_for_model(Status).pk,
|
|
105
108
|
export_template=et.pk,
|
|
106
109
|
)
|
|
107
|
-
self.
|
|
110
|
+
self.assertJobResultStatus(job_result)
|
|
108
111
|
self.assertTrue(job_result.files.exists())
|
|
109
112
|
self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_statuses.txt")
|
|
110
113
|
text_data = job_result.files.first().file.read().decode("utf-8")
|
|
@@ -126,7 +129,7 @@ class ExportObjectListTest(TransactionTestCase):
|
|
|
126
129
|
content_type=ContentType.objects.get_for_model(DeviceType).pk,
|
|
127
130
|
export_format="yaml",
|
|
128
131
|
)
|
|
129
|
-
self.
|
|
132
|
+
self.assertJobResultStatus(job_result)
|
|
130
133
|
self.assertTrue(job_result.files.exists())
|
|
131
134
|
self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_device_types.yaml")
|
|
132
135
|
yaml_data = job_result.files.first().file.read().decode("utf-8")
|
|
@@ -156,7 +159,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
156
159
|
content_type=ContentType.objects.get_for_model(Status).pk,
|
|
157
160
|
csv_data=self.csv_data,
|
|
158
161
|
)
|
|
159
|
-
self.
|
|
162
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
160
163
|
log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
|
|
161
164
|
self.assertEqual(log_error.message, f'User "{self.user}" does not have permission to create status objects')
|
|
162
165
|
self.assertFalse(Status.objects.filter(name__startswith="test_status").exists())
|
|
@@ -169,7 +172,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
169
172
|
username=self.user.username, # otherwise run_job_for_testing defaults to a superuser account
|
|
170
173
|
content_type=ContentType.objects.get_for_model(Status).pk,
|
|
171
174
|
)
|
|
172
|
-
self.
|
|
175
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
173
176
|
|
|
174
177
|
def test_csv_import_with_constrained_permission(self):
|
|
175
178
|
"""Job should only allow the user to import objects they have permission to add."""
|
|
@@ -188,7 +191,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
188
191
|
content_type=ContentType.objects.get_for_model(Status).pk,
|
|
189
192
|
csv_data=self.csv_data,
|
|
190
193
|
)
|
|
191
|
-
self.
|
|
194
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
192
195
|
log_successes = JobLogEntry.objects.filter(
|
|
193
196
|
job_result=job_result, log_level=LogLevelChoices.LOG_INFO, message__icontains="created"
|
|
194
197
|
)
|
|
@@ -217,7 +220,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
217
220
|
content_type=ContentType.objects.get_for_model(Status).pk,
|
|
218
221
|
csv_data=self.csv_data,
|
|
219
222
|
)
|
|
220
|
-
self.
|
|
223
|
+
self.assertJobResultStatus(job_result)
|
|
221
224
|
self.assertFalse(
|
|
222
225
|
JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
|
|
223
226
|
)
|
|
@@ -243,7 +246,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
243
246
|
content_type=ContentType.objects.get_for_model(Prefix).pk,
|
|
244
247
|
csv_file=csv_file.id,
|
|
245
248
|
)
|
|
246
|
-
self.
|
|
249
|
+
self.assertJobResultStatus(job_result)
|
|
247
250
|
self.assertFalse(
|
|
248
251
|
JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
|
|
249
252
|
)
|
|
@@ -284,7 +287,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
284
287
|
content_type=ContentType.objects.get_for_model(Device).pk,
|
|
285
288
|
csv_file=csv_file.id,
|
|
286
289
|
)
|
|
287
|
-
self.
|
|
290
|
+
self.assertJobResultStatus(job_result)
|
|
288
291
|
self.assertFalse(
|
|
289
292
|
JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
|
|
290
293
|
)
|
|
@@ -310,7 +313,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
310
313
|
csv_data=csv_data,
|
|
311
314
|
roll_back_if_error=True,
|
|
312
315
|
)
|
|
313
|
-
self.
|
|
316
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
314
317
|
log_info = JobLogEntry.objects.filter(
|
|
315
318
|
job_result=job_result, log_level=LogLevelChoices.LOG_INFO, message__icontains="created"
|
|
316
319
|
)
|
|
@@ -332,7 +335,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
332
335
|
content_type=ContentType.objects.get_for_model(Status).pk,
|
|
333
336
|
csv_data=csv_data,
|
|
334
337
|
)
|
|
335
|
-
self.
|
|
338
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
336
339
|
log_errors = JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
|
|
337
340
|
self.assertEqual(log_errors[0].message, "Row 1: `color`: `Enter a valid hexadecimal RGB color code.`")
|
|
338
341
|
self.assertFalse(Status.objects.filter(name="test_status0").exists())
|
|
@@ -372,7 +375,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
372
375
|
content_type=ContentType.objects.get_for_model(LocationType).pk,
|
|
373
376
|
csv_data=location_types_csv,
|
|
374
377
|
)
|
|
375
|
-
self.
|
|
378
|
+
self.assertJobResultStatus(location_types_job_result)
|
|
376
379
|
|
|
377
380
|
location_type_count = LocationType.objects.filter(name="ContactAssignmentImportTestLocationType").count()
|
|
378
381
|
self.assertEqual(location_type_count, 1, f"Unexpected count of LocationTypes {location_type_count}")
|
|
@@ -383,7 +386,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
383
386
|
content_type=ContentType.objects.get_for_model(Location).pk,
|
|
384
387
|
csv_data=locations_csv,
|
|
385
388
|
)
|
|
386
|
-
self.
|
|
389
|
+
self.assertJobResultStatus(locations_job_result)
|
|
387
390
|
|
|
388
391
|
location_count = Location.objects.filter(location_type__name="ContactAssignmentImportTestLocationType").count()
|
|
389
392
|
self.assertEqual(location_count, 2, f"Unexpected count of Locations {location_count}")
|
|
@@ -394,7 +397,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
394
397
|
content_type=ContentType.objects.get_for_model(Contact).pk,
|
|
395
398
|
csv_data=contacts_csv,
|
|
396
399
|
)
|
|
397
|
-
self.
|
|
400
|
+
self.assertJobResultStatus(contacts_job_result)
|
|
398
401
|
|
|
399
402
|
contact_count = Contact.objects.filter(name="Bob-ContactAssignmentImportTestLocation").count()
|
|
400
403
|
self.assertEqual(contact_count, 1, f"Unexpected number of contacts {contact_count}")
|
|
@@ -405,7 +408,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
405
408
|
content_type=ContentType.objects.get_for_model(Role).pk,
|
|
406
409
|
csv_data=roles_csv,
|
|
407
410
|
)
|
|
408
|
-
self.
|
|
411
|
+
self.assertJobResultStatus(roles_job_result)
|
|
409
412
|
|
|
410
413
|
role_count = Role.objects.filter(name="ContactAssignmentImportTestLocation-On Site").count()
|
|
411
414
|
self.assertEqual(role_count, 1, f"Unexpected number of role values {role_count}")
|
|
@@ -423,8 +426,7 @@ class ImportObjectsTestCase(TransactionTestCase):
|
|
|
423
426
|
content_type=ContentType.objects.get_for_model(ContactAssociation).pk,
|
|
424
427
|
csv_data=associations_csv,
|
|
425
428
|
)
|
|
426
|
-
|
|
427
|
-
self.assertEqual(associations_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
|
|
429
|
+
self.assertJobResultStatus(associations_job_result)
|
|
428
430
|
|
|
429
431
|
|
|
430
432
|
class LogsCleanupTestCase(TransactionTestCase):
|
|
@@ -466,7 +468,7 @@ class LogsCleanupTestCase(TransactionTestCase):
|
|
|
466
468
|
cleanup_types=[CleanupTypes.JOB_RESULT],
|
|
467
469
|
max_age=0,
|
|
468
470
|
)
|
|
469
|
-
self.
|
|
471
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
470
472
|
log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
|
|
471
473
|
self.assertEqual(log_error.message, f'User "{self.user}" does not have permission to delete JobResult records')
|
|
472
474
|
self.assertEqual(JobResult.objects.count(), job_result_count + 1)
|
|
@@ -481,7 +483,7 @@ class LogsCleanupTestCase(TransactionTestCase):
|
|
|
481
483
|
cleanup_types=[CleanupTypes.OBJECT_CHANGE],
|
|
482
484
|
max_age=0,
|
|
483
485
|
)
|
|
484
|
-
self.
|
|
486
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
485
487
|
log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
|
|
486
488
|
self.assertEqual(
|
|
487
489
|
log_error.message, f'User "{self.user}" does not have permission to delete ObjectChange records'
|
|
@@ -529,7 +531,7 @@ class LogsCleanupTestCase(TransactionTestCase):
|
|
|
529
531
|
cleanup_types=[CleanupTypes.JOB_RESULT, CleanupTypes.OBJECT_CHANGE],
|
|
530
532
|
max_age=0,
|
|
531
533
|
)
|
|
532
|
-
self.
|
|
534
|
+
self.assertJobResultStatus(job_result)
|
|
533
535
|
self.assertEqual(job_result.result["extras.JobResult"], 1)
|
|
534
536
|
self.assertEqual(job_result.result["extras.ObjectChange"], 1)
|
|
535
537
|
with self.assertRaises(JobResult.DoesNotExist):
|
|
@@ -626,7 +628,7 @@ class BulkEditTestCase(TransactionTestCase):
|
|
|
626
628
|
tag.content_types.add(self.namespace_ct)
|
|
627
629
|
|
|
628
630
|
def _common_no_error_test_assertion(self, model, job_result, expected_count, **filter_params):
|
|
629
|
-
self.
|
|
631
|
+
self.assertJobResultStatus(job_result)
|
|
630
632
|
self.assertEqual(model.objects.filter(**filter_params).count(), expected_count)
|
|
631
633
|
self.assertFalse(
|
|
632
634
|
JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
|
|
@@ -647,7 +649,7 @@ class BulkEditTestCase(TransactionTestCase):
|
|
|
647
649
|
form_data={"pk": pk_list, "color": "aa1409"},
|
|
648
650
|
username=self.user.username,
|
|
649
651
|
)
|
|
650
|
-
self.
|
|
652
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
651
653
|
job_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
|
|
652
654
|
self.assertEqual(job_log.message, f'User "{self.user}" does not have permission to update status objects')
|
|
653
655
|
|
|
@@ -958,7 +960,7 @@ class BulkDeleteTestCase(TransactionTestCase):
|
|
|
958
960
|
)
|
|
959
961
|
|
|
960
962
|
def _common_no_error_test_assertion(self, model, job_result, **filter_params):
|
|
961
|
-
self.
|
|
963
|
+
self.assertJobResultStatus(job_result)
|
|
962
964
|
self.assertEqual(model.objects.filter(**filter_params).count(), 0)
|
|
963
965
|
self.assertFalse(
|
|
964
966
|
JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
|
|
@@ -976,7 +978,7 @@ class BulkDeleteTestCase(TransactionTestCase):
|
|
|
976
978
|
pk_list=statuses_to_delete,
|
|
977
979
|
username=self.user.username,
|
|
978
980
|
)
|
|
979
|
-
self.
|
|
981
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
980
982
|
job_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
|
|
981
983
|
self.assertEqual(job_log.message, f'User "{self.user}" does not have permission to delete status objects')
|
|
982
984
|
self.assertEqual(Status.objects.filter(pk__in=statuses_to_delete).count(), len(statuses_to_delete))
|
|
@@ -999,7 +1001,7 @@ class BulkDeleteTestCase(TransactionTestCase):
|
|
|
999
1001
|
pk_list=statuses_to_delete,
|
|
1000
1002
|
username=self.user.username,
|
|
1001
1003
|
)
|
|
1002
|
-
self.
|
|
1004
|
+
self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
|
|
1003
1005
|
error_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
|
|
1004
1006
|
self.assertEqual(
|
|
1005
1007
|
error_log.message, "You do not have permissions to delete some of the objects provided in `pk_list`."
|
|
@@ -1372,7 +1372,7 @@ class StatsPanel(Panel):
|
|
|
1372
1372
|
value = [related_object_list_url, related_object_count, related_object_title]
|
|
1373
1373
|
stats[related_object_model_class] = value
|
|
1374
1374
|
related_object_model_filterset = get_filterset_for_model(related_object_model_class)
|
|
1375
|
-
if self.filter_name not in related_object_model_filterset.
|
|
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
|
+
)
|
|
@@ -4,65 +4,3 @@
|
|
|
4
4
|
{% block extra_breadcrumbs %}
|
|
5
5
|
<li><a href="{% url 'dcim:device' pk=object.device.pk %}">{{ object.device }}</a></li>
|
|
6
6
|
{% endblock extra_breadcrumbs %}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
{% block content_left_page %}
|
|
10
|
-
<div class="panel panel-default">
|
|
11
|
-
<div class="panel-heading">
|
|
12
|
-
<strong>Virtual Device Context</strong>
|
|
13
|
-
</div>
|
|
14
|
-
<table class="table table-hover panel-body attr-table">
|
|
15
|
-
<tr>
|
|
16
|
-
<td>Name</td>
|
|
17
|
-
<td>
|
|
18
|
-
{{ object.name }}
|
|
19
|
-
</td>
|
|
20
|
-
</tr>
|
|
21
|
-
<tr>
|
|
22
|
-
<td>Identifier</td>
|
|
23
|
-
<td>
|
|
24
|
-
{{ object.identifier }}
|
|
25
|
-
</td>
|
|
26
|
-
</tr>
|
|
27
|
-
<tr>
|
|
28
|
-
<td>Role</td>
|
|
29
|
-
<td>
|
|
30
|
-
{{ object.role| hyperlinked_object_with_color }}
|
|
31
|
-
</td>
|
|
32
|
-
</tr>
|
|
33
|
-
<tr>
|
|
34
|
-
<td>Status</td>
|
|
35
|
-
<td>
|
|
36
|
-
{{ object.status| hyperlinked_object_with_color }}
|
|
37
|
-
</td>
|
|
38
|
-
</tr>
|
|
39
|
-
<tr>
|
|
40
|
-
<td>Device</td>
|
|
41
|
-
<td>
|
|
42
|
-
{{ object.device|hyperlinked_object }}
|
|
43
|
-
</td>
|
|
44
|
-
</tr>
|
|
45
|
-
<tr>
|
|
46
|
-
<td>Primary IPv4</td>
|
|
47
|
-
<td>
|
|
48
|
-
{{ object.primary_ip4|hyperlinked_object }}
|
|
49
|
-
</td>
|
|
50
|
-
</tr>
|
|
51
|
-
<tr>
|
|
52
|
-
<td>Primary IPv6</td>
|
|
53
|
-
<td>
|
|
54
|
-
{{ object.primary_ip6|hyperlinked_object }}
|
|
55
|
-
</td>
|
|
56
|
-
</tr>
|
|
57
|
-
{% include 'inc/tenant_table_row.html' %}
|
|
58
|
-
<tr>
|
|
59
|
-
<td>Description</td>
|
|
60
|
-
<td>{{ object.description|placeholder }}</td>
|
|
61
|
-
</tr>
|
|
62
|
-
</table>
|
|
63
|
-
</div>
|
|
64
|
-
{% endblock content_left_page %}
|
|
65
|
-
|
|
66
|
-
{% block content_full_width_page %}
|
|
67
|
-
{% include 'panel_table.html' with table=interfaces_table heading="Interfaces" %}
|
|
68
|
-
{% endblock content_full_width_page %}
|
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
{% endif %}
|
|
24
24
|
</div>
|
|
25
25
|
</div>
|
|
26
|
+
<div class="panel panel-default">
|
|
27
|
+
<div class="panel-heading"><strong>VRF Assignments</strong></div>
|
|
28
|
+
<div class="panel-body">
|
|
29
|
+
{% render_field form.vrfs %}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
26
32
|
{% include 'inc/tenancy_form_panel.html' %}
|
|
27
33
|
{% include 'inc/extras_features_edit_form_fields.html' %}
|
|
28
34
|
{% endblock %}
|