nautobot 2.3.10__py3-none-any.whl → 2.3.11__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/apps/utils.py +2 -0
- nautobot/cloud/tables.py +1 -0
- nautobot/core/forms/forms.py +5 -1
- nautobot/core/tables.py +88 -22
- nautobot/core/templates/generic/object_bulk_destroy.html +12 -3
- nautobot/core/templates/generic/object_bulk_update.html +4 -2
- nautobot/core/templates/generic/object_create.html +1 -1
- nautobot/core/templates/rest_framework/api.html +3 -0
- nautobot/core/testing/api.py +3 -1
- nautobot/core/testing/integration.py +64 -0
- nautobot/core/testing/views.py +33 -27
- nautobot/core/tests/integration/test_app_navbar.py +3 -3
- nautobot/core/tests/integration/test_navbar.py +1 -1
- nautobot/core/tests/test_csv.py +3 -0
- nautobot/core/tests/test_utils.py +25 -5
- nautobot/core/utils/lookup.py +35 -0
- nautobot/core/views/generic.py +50 -39
- nautobot/core/views/mixins.py +97 -43
- nautobot/core/views/renderers.py +8 -5
- nautobot/dcim/tables/devices.py +3 -0
- nautobot/dcim/templates/dcim/device_component_add.html +8 -8
- nautobot/dcim/templates/dcim/virtualchassis_add_member.html +2 -2
- nautobot/dcim/templates/dcim/virtualchassis_edit.html +2 -2
- nautobot/dcim/tests/integration/test_create_device.py +86 -0
- nautobot/extras/tests/test_relationships.py +1 -0
- nautobot/extras/views.py +1 -0
- nautobot/ipam/factory.py +3 -0
- nautobot/ipam/filters.py +5 -0
- nautobot/ipam/forms.py +17 -0
- nautobot/ipam/models.py +2 -1
- nautobot/ipam/signals.py +2 -2
- nautobot/ipam/tables.py +3 -3
- nautobot/ipam/templates/ipam/ipaddress_assign.html +2 -2
- nautobot/ipam/tests/test_models.py +113 -1
- nautobot/ipam/tests/test_views.py +39 -5
- nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +131 -6
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +175 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/utils.html +94 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/views.html +4 -4
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.3.html +293 -138
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +270 -270
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/guides/request-profiling.html +39 -0
- nautobot/virtualization/forms.py +24 -0
- nautobot/virtualization/templates/virtualization/vminterface_edit.html +1 -0
- nautobot/virtualization/tests/test_views.py +7 -2
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/METADATA +1 -1
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/RECORD +54 -53
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/NOTICE +0 -0
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/WHEEL +0 -0
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from django.urls import reverse
|
|
2
|
+
|
|
3
|
+
from nautobot.core.testing.integration import SeleniumTestCase
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CreateDeviceTestCase(SeleniumTestCase):
|
|
7
|
+
"""
|
|
8
|
+
Create a device and all pre-requisite objects through the UI.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def test_create_device(self):
|
|
12
|
+
"""
|
|
13
|
+
This test goes through the process of creating a device in the UI. All pre-requisite objects are created:
|
|
14
|
+
- Manufacturer
|
|
15
|
+
- Device Type
|
|
16
|
+
- LocationType
|
|
17
|
+
- Location
|
|
18
|
+
- Role
|
|
19
|
+
- Device
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
self.user.is_superuser = True
|
|
23
|
+
self.user.save()
|
|
24
|
+
self.login(self.user.username, self.password)
|
|
25
|
+
|
|
26
|
+
# Manufacturer
|
|
27
|
+
self.click_navbar_entry("Devices", "Manufacturers")
|
|
28
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:manufacturer_list"))
|
|
29
|
+
self.click_list_view_add_button()
|
|
30
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:manufacturer_add"))
|
|
31
|
+
self.browser.fill("name", "Test Manufacturer 1")
|
|
32
|
+
self.click_edit_form_create_button()
|
|
33
|
+
|
|
34
|
+
# Device Type
|
|
35
|
+
self.click_navbar_entry("Devices", "Device Types")
|
|
36
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:devicetype_list"))
|
|
37
|
+
self.click_list_view_add_button()
|
|
38
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:devicetype_add"))
|
|
39
|
+
self.fill_select2_field("manufacturer", "Test Manufacturer 1")
|
|
40
|
+
self.browser.fill("model", "Test Device Type 1")
|
|
41
|
+
self.click_edit_form_create_button()
|
|
42
|
+
|
|
43
|
+
# LocationType
|
|
44
|
+
self.click_navbar_entry("Organization", "Location Types")
|
|
45
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:locationtype_list"))
|
|
46
|
+
self.click_list_view_add_button()
|
|
47
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:locationtype_add"))
|
|
48
|
+
self.fill_select2_multiselect_field("content_types", "dcim | device")
|
|
49
|
+
self.browser.fill("name", "Test Location Type 1")
|
|
50
|
+
self.click_edit_form_create_button()
|
|
51
|
+
|
|
52
|
+
# Location
|
|
53
|
+
self.click_navbar_entry("Organization", "Locations")
|
|
54
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:location_list"))
|
|
55
|
+
self.click_list_view_add_button()
|
|
56
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:location_add"))
|
|
57
|
+
self.fill_select2_field("location_type", "Test Location Type 1")
|
|
58
|
+
self.fill_select2_field("status", "") # pick first status
|
|
59
|
+
self.browser.fill("name", "Test Location 1")
|
|
60
|
+
self.click_edit_form_create_button()
|
|
61
|
+
|
|
62
|
+
# Role
|
|
63
|
+
self.click_navbar_entry("Organization", "Roles")
|
|
64
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("extras:role_list"))
|
|
65
|
+
self.click_list_view_add_button()
|
|
66
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("extras:role_add"))
|
|
67
|
+
self.browser.fill("name", "Test Role 1")
|
|
68
|
+
self.fill_select2_multiselect_field("content_types", "dcim | device")
|
|
69
|
+
self.click_edit_form_create_button()
|
|
70
|
+
|
|
71
|
+
# Device
|
|
72
|
+
self.click_navbar_entry("Devices", "Devices")
|
|
73
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_list"))
|
|
74
|
+
self.click_list_view_add_button()
|
|
75
|
+
self.assertEqual(self.browser.url, self.live_server_url + reverse("dcim:device_add"))
|
|
76
|
+
self.browser.fill("name", "Test Device Integration Test 1")
|
|
77
|
+
self.fill_select2_field("role", "Test Role 1")
|
|
78
|
+
self.fill_select2_field("device_type", "Test Device Type 1")
|
|
79
|
+
self.fill_select2_field("location", "Test Location 1")
|
|
80
|
+
self.fill_select2_field("status", "") # pick first status
|
|
81
|
+
self.click_edit_form_create_button()
|
|
82
|
+
|
|
83
|
+
# Assert that the device was created
|
|
84
|
+
self.assertTrue(self.browser.is_text_present("Created device Test Device Integration Test 1", wait_time=5))
|
|
85
|
+
self.assertTrue(self.browser.is_text_present("Test Location 1", wait_time=5))
|
|
86
|
+
self.assertTrue(self.browser.is_text_present("Test Device Type 1", wait_time=5))
|
|
@@ -1395,6 +1395,7 @@ class RequiredRelationshipTestMixin:
|
|
|
1395
1395
|
# Protected FK to SoftwareVersion prevents deletion
|
|
1396
1396
|
Controller.objects.all().delete()
|
|
1397
1397
|
Device.objects.all().update(software_version=None)
|
|
1398
|
+
Device.objects.all().delete()
|
|
1398
1399
|
|
|
1399
1400
|
# Create required relationships:
|
|
1400
1401
|
device_ct = ContentType.objects.get_for_model(Device)
|
nautobot/extras/views.py
CHANGED
nautobot/ipam/factory.py
CHANGED
|
@@ -238,6 +238,9 @@ class VLANFactory(PrimaryModelFactory):
|
|
|
238
238
|
lambda: Location.objects.filter(location_type__content_types__in=[vlan_ct]), minimum=0
|
|
239
239
|
)
|
|
240
240
|
)
|
|
241
|
+
if self.vlan_group and self.vlan_group.location:
|
|
242
|
+
# add the parent of the vlan group location to the vlan locations
|
|
243
|
+
self.locations.add(self.vlan_group.location.ancestors(include_self=True)[0])
|
|
241
244
|
|
|
242
245
|
|
|
243
246
|
class VLANGetOrCreateFactory(VLANFactory):
|
nautobot/ipam/filters.py
CHANGED
|
@@ -90,6 +90,11 @@ class VRFFilterSet(NautobotFilterSet, StatusModelFilterSetMixin, TenancyModelFil
|
|
|
90
90
|
to_field_name="name",
|
|
91
91
|
label="Device (ID or name)",
|
|
92
92
|
)
|
|
93
|
+
virtual_machines = NaturalKeyOrPKMultipleChoiceFilter(
|
|
94
|
+
queryset=VirtualMachine.objects.all(),
|
|
95
|
+
to_field_name="name",
|
|
96
|
+
label="Virtual Machine (ID or name)",
|
|
97
|
+
)
|
|
93
98
|
prefix = NaturalKeyOrPKMultipleChoiceFilter(
|
|
94
99
|
field_name="prefixes",
|
|
95
100
|
queryset=Prefix.objects.all(),
|
nautobot/ipam/forms.py
CHANGED
|
@@ -752,6 +752,7 @@ class VLANForm(NautobotModelForm, TenancyForm):
|
|
|
752
752
|
)
|
|
753
753
|
vlan_group = DynamicModelChoiceField(
|
|
754
754
|
queryset=VLANGroup.objects.all(),
|
|
755
|
+
query_params={"location": "$locations"},
|
|
755
756
|
required=False,
|
|
756
757
|
)
|
|
757
758
|
|
|
@@ -779,6 +780,22 @@ class VLANForm(NautobotModelForm, TenancyForm):
|
|
|
779
780
|
}
|
|
780
781
|
|
|
781
782
|
def clean(self):
|
|
783
|
+
vlan_group = self.cleaned_data["vlan_group"]
|
|
784
|
+
locations = self.cleaned_data["locations"]
|
|
785
|
+
# Validate Vlan Group Location is one of the ancestors of the VLAN locations specified.
|
|
786
|
+
if vlan_group and vlan_group.location and locations:
|
|
787
|
+
vlan_group_location = vlan_group.location
|
|
788
|
+
is_vlan_group_valid = False
|
|
789
|
+
for location in locations:
|
|
790
|
+
if vlan_group_location in location.ancestors(include_self=True):
|
|
791
|
+
is_vlan_group_valid = True
|
|
792
|
+
break
|
|
793
|
+
|
|
794
|
+
if not is_vlan_group_valid:
|
|
795
|
+
locations = list(locations.values_list("name", flat=True))
|
|
796
|
+
raise ValidationError(
|
|
797
|
+
{"vlan_group": [f"VLAN Group {vlan_group} is not in locations {locations} or their ancestors."]}
|
|
798
|
+
)
|
|
782
799
|
# Validation error raised in signal is not properly handled in form clean
|
|
783
800
|
# Hence handling any validationError that might occur.
|
|
784
801
|
try:
|
nautobot/ipam/models.py
CHANGED
|
@@ -1296,7 +1296,8 @@ class IPAddressToInterface(BaseModel):
|
|
|
1296
1296
|
|
|
1297
1297
|
def __str__(self):
|
|
1298
1298
|
if self.interface:
|
|
1299
|
-
|
|
1299
|
+
parent_name = self.interface.parent.name if self.interface.parent else "No Parent"
|
|
1300
|
+
return f"{self.ip_address!s} {parent_name} {self.interface.name}"
|
|
1300
1301
|
else:
|
|
1301
1302
|
return f"{self.ip_address!s} {self.vm_interface.virtual_machine.name} {self.vm_interface.name}"
|
|
1302
1303
|
|
nautobot/ipam/signals.py
CHANGED
|
@@ -65,9 +65,9 @@ def ip_address_to_interface_pre_delete(instance, raw=False, **kwargs):
|
|
|
65
65
|
# that is the primary_v{version} of the host machine.
|
|
66
66
|
|
|
67
67
|
if getattr(instance, "interface"):
|
|
68
|
-
host = instance.interface.
|
|
68
|
+
host = instance.interface.parent
|
|
69
69
|
other_assignments_exist = (
|
|
70
|
-
IPAddressToInterface.objects.filter(
|
|
70
|
+
IPAddressToInterface.objects.filter(interface__in=host.all_interfaces, ip_address=instance.ip_address)
|
|
71
71
|
.exclude(id=instance.id)
|
|
72
72
|
.exists()
|
|
73
73
|
)
|
nautobot/ipam/tables.py
CHANGED
|
@@ -346,7 +346,7 @@ class PrefixTable(StatusTableMixin, RoleTableMixin, BaseTable):
|
|
|
346
346
|
prefix = tables.TemplateColumn(
|
|
347
347
|
template_code=PREFIX_COPY_LINK, attrs={"td": {"class": "text-nowrap"}}, order_by=("network", "prefix_length")
|
|
348
348
|
)
|
|
349
|
-
|
|
349
|
+
vrf_count = LinkedCountColumn(viewname="ipam:vrf_list", url_params={"prefixes": "pk"}, verbose_name="VRFs")
|
|
350
350
|
tenant = TenantColumn()
|
|
351
351
|
namespace = tables.Column(linkify=True)
|
|
352
352
|
vlan = tables.Column(linkify=True, verbose_name="VLAN")
|
|
@@ -368,7 +368,7 @@ class PrefixTable(StatusTableMixin, RoleTableMixin, BaseTable):
|
|
|
368
368
|
"type",
|
|
369
369
|
"status",
|
|
370
370
|
"children",
|
|
371
|
-
|
|
371
|
+
"vrf_count",
|
|
372
372
|
"namespace",
|
|
373
373
|
"tenant",
|
|
374
374
|
"location_count",
|
|
@@ -384,7 +384,7 @@ class PrefixTable(StatusTableMixin, RoleTableMixin, BaseTable):
|
|
|
384
384
|
"prefix",
|
|
385
385
|
"type",
|
|
386
386
|
"status",
|
|
387
|
-
|
|
387
|
+
"vrf_count",
|
|
388
388
|
"namespace",
|
|
389
389
|
"tenant",
|
|
390
390
|
"location_count",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
{% endif %}
|
|
19
19
|
{% endfor %}
|
|
20
20
|
<div class="row">
|
|
21
|
-
<div class="col-md-
|
|
21
|
+
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
|
|
22
22
|
<h3>Assign an IP Address</h3>
|
|
23
23
|
{% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %}
|
|
24
24
|
{% if form.non_field_errors %}
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
40
40
|
<div class="row">
|
|
41
|
-
<div class="col-md-
|
|
41
|
+
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1 text-right">
|
|
42
42
|
<button type="submit" class="btn btn-primary">Search</button>
|
|
43
43
|
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
|
44
44
|
</div>
|
|
@@ -9,7 +9,7 @@ import netaddr
|
|
|
9
9
|
|
|
10
10
|
from nautobot.core.testing.models import ModelTestCases
|
|
11
11
|
from nautobot.dcim import choices as dcim_choices
|
|
12
|
-
from nautobot.dcim.models import Device, DeviceType, Interface, Location, LocationType
|
|
12
|
+
from nautobot.dcim.models import Device, DeviceType, Interface, Location, LocationType, Module, ModuleBay, ModuleType
|
|
13
13
|
from nautobot.extras.models import Role, Status
|
|
14
14
|
from nautobot.ipam.choices import IPAddressTypeChoices, PrefixTypeChoices, ServiceProtocolChoices
|
|
15
15
|
from nautobot.ipam.models import (
|
|
@@ -171,6 +171,118 @@ class IPAddressToInterfaceTest(TestCase):
|
|
|
171
171
|
IPAddressToInterface.objects.create(vm_interface=None, interface=None, ip_address=ip_addr)
|
|
172
172
|
self.assertIn("Must associate to either an Interface or a VMInterface.", str(cm.exception))
|
|
173
173
|
|
|
174
|
+
def test_primary_ip_retained_when_deleted_from_device_or_module_interface(self):
|
|
175
|
+
"""Test primary_ip4 remains set when the same IP is assigned to multiple interfaces and deleted from one."""
|
|
176
|
+
|
|
177
|
+
# Create a module bay on the existing device
|
|
178
|
+
device_module_bay = ModuleBay.objects.create(parent_device=self.test_device, name="Test Bay")
|
|
179
|
+
|
|
180
|
+
# Create a module with an interface and add it to the module bay on the device
|
|
181
|
+
module = Module.objects.create(
|
|
182
|
+
module_type=ModuleType.objects.first(),
|
|
183
|
+
status=Status.objects.get_for_model(Module).first(),
|
|
184
|
+
parent_module_bay=device_module_bay,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Set status for the module interface
|
|
188
|
+
int_status = Status.objects.get_for_model(Interface).first()
|
|
189
|
+
|
|
190
|
+
# Create an interface on the module
|
|
191
|
+
interface_module = Interface.objects.create(
|
|
192
|
+
name="eth0_module",
|
|
193
|
+
module=module,
|
|
194
|
+
type=dcim_choices.InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
195
|
+
status=int_status,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Link the module to the device
|
|
199
|
+
self.test_device.installed_device = interface_module
|
|
200
|
+
self.test_device.save()
|
|
201
|
+
|
|
202
|
+
# Create IP and assign it to multiple interfaces
|
|
203
|
+
ip_address = IPAddress.objects.create(address="192.0.2.1/24", namespace=self.namespace, status=self.status)
|
|
204
|
+
assignment_device_int1 = IPAddressToInterface.objects.create(interface=self.test_int1, ip_address=ip_address)
|
|
205
|
+
assignment_module_int1 = IPAddressToInterface.objects.create(interface=interface_module, ip_address=ip_address)
|
|
206
|
+
|
|
207
|
+
# Set the primary IP on the device
|
|
208
|
+
self.test_device.primary_ip4 = assignment_device_int1.ip_address
|
|
209
|
+
self.test_device.save()
|
|
210
|
+
|
|
211
|
+
# Verify that the primary IP is set
|
|
212
|
+
self.assertEqual(self.test_device.primary_ip4, ip_address)
|
|
213
|
+
|
|
214
|
+
# Delete the IP assignment from one interface
|
|
215
|
+
assignment_device_int1.delete()
|
|
216
|
+
|
|
217
|
+
# Refresh and check that the primary IP is still assigned
|
|
218
|
+
self.test_device.refresh_from_db()
|
|
219
|
+
self.assertEqual(self.test_device.primary_ip4, ip_address)
|
|
220
|
+
|
|
221
|
+
# Verify remaining IP assignments on the IP object
|
|
222
|
+
remaining_assignments = ip_address.interface_assignments.all()
|
|
223
|
+
self.assertEqual(remaining_assignments.count(), 1)
|
|
224
|
+
self.assertIn(assignment_module_int1, remaining_assignments)
|
|
225
|
+
|
|
226
|
+
def test_primary_ip_retained_when_deleted_from_device_interface_with_nested_module(self):
|
|
227
|
+
"""Test primary_ip4 remains set when the same IP is assigned to a device and nested module interfaces, and deleted from the device interface."""
|
|
228
|
+
|
|
229
|
+
# Create a module bay on the existing device
|
|
230
|
+
device_module_bay = ModuleBay.objects.create(parent_device=self.test_device, name="Primary Module Bay")
|
|
231
|
+
|
|
232
|
+
# Create a primary module with an interface and add it to the module bay on the device
|
|
233
|
+
primary_module = Module.objects.create(
|
|
234
|
+
module_type=ModuleType.objects.first(),
|
|
235
|
+
status=Status.objects.get_for_model(Module).first(),
|
|
236
|
+
parent_module_bay=device_module_bay,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Create a secondary module bay within the primary module for nested module creation
|
|
240
|
+
nested_module_bay = ModuleBay.objects.create(parent_module=primary_module, name="Nested Module Bay")
|
|
241
|
+
|
|
242
|
+
# Create a nested module within the primary module's module bay
|
|
243
|
+
nested_module = Module.objects.create(
|
|
244
|
+
module_type=ModuleType.objects.first(),
|
|
245
|
+
status=Status.objects.get_for_model(Module).first(),
|
|
246
|
+
parent_module_bay=nested_module_bay,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Set status for the nested module interface
|
|
250
|
+
int_status = Status.objects.get_for_model(Interface).first()
|
|
251
|
+
|
|
252
|
+
# Create an interface on the nested module and assign an IP
|
|
253
|
+
nested_interface = Interface.objects.create(
|
|
254
|
+
name="eth0_nested",
|
|
255
|
+
module=nested_module,
|
|
256
|
+
type=dcim_choices.InterfaceTypeChoices.TYPE_1GE_FIXED,
|
|
257
|
+
status=int_status,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Create IP and assign it to both the device and the nested module interface
|
|
261
|
+
ip_address = IPAddress.objects.create(address="192.0.2.1/24", namespace=self.namespace, status=self.status)
|
|
262
|
+
assignment_device_int1 = IPAddressToInterface.objects.create(interface=self.test_int1, ip_address=ip_address)
|
|
263
|
+
assignment_nested_module = IPAddressToInterface.objects.create(
|
|
264
|
+
interface=nested_interface, ip_address=ip_address
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Set the primary IP on the device to the IP on the device interface
|
|
268
|
+
self.test_device.primary_ip4 = assignment_nested_module.ip_address
|
|
269
|
+
self.test_device.save()
|
|
270
|
+
|
|
271
|
+
# Verify that the primary IP is correctly set
|
|
272
|
+
self.assertEqual(self.test_device.primary_ip4, ip_address)
|
|
273
|
+
|
|
274
|
+
# Delete the IP assignment from the device interface
|
|
275
|
+
assignment_device_int1.delete()
|
|
276
|
+
|
|
277
|
+
# Refresh and check that the primary IP is still assigned to the device
|
|
278
|
+
self.test_device.refresh_from_db()
|
|
279
|
+
self.assertEqual(self.test_device.primary_ip4, ip_address)
|
|
280
|
+
|
|
281
|
+
# Confirm that the IP is still associated with the nested module interface
|
|
282
|
+
remaining_assignments = ip_address.interface_assignments.all()
|
|
283
|
+
self.assertEqual(remaining_assignments.count(), 1)
|
|
284
|
+
self.assertIn(assignment_nested_module, remaining_assignments)
|
|
285
|
+
|
|
174
286
|
|
|
175
287
|
class TestVarbinaryIPField(TestCase):
|
|
176
288
|
"""Tests for `nautobot.ipam.fields.VarbinaryIPField`."""
|
|
@@ -9,9 +9,10 @@ from django.utils.timezone import make_aware
|
|
|
9
9
|
from netaddr import IPNetwork
|
|
10
10
|
|
|
11
11
|
from nautobot.circuits.models import Circuit, Provider
|
|
12
|
-
from nautobot.core.templatetags.helpers import queryset_to_pks
|
|
12
|
+
from nautobot.core.templatetags.helpers import hyperlinked_object, queryset_to_pks
|
|
13
13
|
from nautobot.core.testing import ModelViewTestCase, post_data, ViewTestCases
|
|
14
14
|
from nautobot.core.testing.utils import extract_page_body
|
|
15
|
+
from nautobot.core.utils.lookup import get_route_for_model
|
|
15
16
|
from nautobot.dcim.models import Device, DeviceType, Interface, Location, LocationType, Manufacturer
|
|
16
17
|
from nautobot.extras.choices import CustomFieldTypeChoices, RelationshipTypeChoices
|
|
17
18
|
from nautobot.extras.models import (
|
|
@@ -178,6 +179,24 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase, ViewTestCases.List
|
|
|
178
179
|
"remove_vrfs": [vrfs[1].pk],
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
183
|
+
def test_list_objects_with_permission(self):
|
|
184
|
+
"""Test rendering of LinkedCountColumn for related fields."""
|
|
185
|
+
response = super().test_list_objects_with_permission()
|
|
186
|
+
response_body = extract_page_body(response.content.decode(response.charset))
|
|
187
|
+
|
|
188
|
+
locations_list_url = reverse(get_route_for_model(Location, "list"))
|
|
189
|
+
|
|
190
|
+
for prefix in self._get_queryset().all():
|
|
191
|
+
if str(prefix.pk) in response_body:
|
|
192
|
+
count = prefix.locations.count()
|
|
193
|
+
if count > 1:
|
|
194
|
+
self.assertBodyContains(
|
|
195
|
+
response, f'<a href="{locations_list_url}?prefixes={prefix.pk}" class="badge">{count}</a>'
|
|
196
|
+
)
|
|
197
|
+
elif count == 1:
|
|
198
|
+
self.assertBodyContains(response, hyperlinked_object(prefix.locations.first()))
|
|
199
|
+
|
|
181
200
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
182
201
|
def test_empty_queryset(self):
|
|
183
202
|
"""
|
|
@@ -1043,7 +1062,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
|
1043
1062
|
def setUpTestData(cls):
|
|
1044
1063
|
cls.locations = Location.objects.filter(location_type=LocationType.objects.get(name="Campus"))
|
|
1045
1064
|
|
|
1046
|
-
vlangroups = (
|
|
1065
|
+
cls.vlangroups = (
|
|
1047
1066
|
VLANGroup.objects.create(name="VLAN Group 1", location=cls.locations.first()),
|
|
1048
1067
|
VLANGroup.objects.create(name="VLAN Group 2", location=cls.locations.last()),
|
|
1049
1068
|
)
|
|
@@ -1053,25 +1072,40 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
|
1053
1072
|
status = Status.objects.get_for_model(VLAN).first()
|
|
1054
1073
|
|
|
1055
1074
|
cls.form_data = {
|
|
1056
|
-
"vlan_group": vlangroups[
|
|
1075
|
+
"vlan_group": cls.vlangroups[0].pk,
|
|
1057
1076
|
"vid": 999,
|
|
1058
1077
|
"name": "VLAN999 with an unwieldy long name since we increased the limit to more than 64 characters",
|
|
1059
1078
|
"tenant": None,
|
|
1060
1079
|
"status": status.pk,
|
|
1061
1080
|
"role": roles[1].pk,
|
|
1062
|
-
"locations": list(cls.locations.values_list("pk", flat=True)[:
|
|
1081
|
+
"locations": list(cls.locations.values_list("pk", flat=True)[:1]),
|
|
1063
1082
|
"description": "A new VLAN",
|
|
1064
1083
|
"tags": [t.pk for t in Tag.objects.get_for_model(VLAN)],
|
|
1065
1084
|
}
|
|
1066
1085
|
|
|
1067
1086
|
cls.bulk_edit_data = {
|
|
1068
|
-
"vlan_group": vlangroups[0].pk,
|
|
1087
|
+
"vlan_group": cls.vlangroups[0].pk,
|
|
1069
1088
|
"tenant": Tenant.objects.first().pk,
|
|
1070
1089
|
"status": status.pk,
|
|
1071
1090
|
"role": roles[0].pk,
|
|
1072
1091
|
"description": "New description",
|
|
1073
1092
|
}
|
|
1074
1093
|
|
|
1094
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1095
|
+
def test_vlan_group_not_belong_to_vlan_locations(self):
|
|
1096
|
+
"""Test that a VLAN cannot be assigned to a VLAN Group that is not in the same location as the VLAN."""
|
|
1097
|
+
vlan_group = self.vlangroups[0]
|
|
1098
|
+
form_data = self.form_data.copy()
|
|
1099
|
+
form_data["vlan_group"] = vlan_group.pk
|
|
1100
|
+
form_data["locations"] = [self.locations.last().pk]
|
|
1101
|
+
self.add_permissions("ipam.add_vlan")
|
|
1102
|
+
request = {
|
|
1103
|
+
"path": self._get_url("add"),
|
|
1104
|
+
"data": post_data(form_data),
|
|
1105
|
+
}
|
|
1106
|
+
response = self.client.post(**request)
|
|
1107
|
+
self.assertBodyContains(response, f"vlan_group: VLAN Group {vlan_group} is not in locations")
|
|
1108
|
+
|
|
1075
1109
|
|
|
1076
1110
|
class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
1077
1111
|
model = Service
|
|
@@ -9444,12 +9444,137 @@ set <code>sort_items=False</code>.</p>
|
|
|
9444
9444
|
Bases: <code><autoref identifier="django_tables2.Column" optional hover>Column</autoref></code></p>
|
|
9445
9445
|
|
|
9446
9446
|
|
|
9447
|
-
<p>Render a count of related objects linked to a filtered URL.</p>
|
|
9448
|
-
|
|
9449
|
-
|
|
9450
|
-
|
|
9451
|
-
|
|
9452
|
-
|
|
9447
|
+
<p>Render a count of related objects linked to a filtered URL, or if a single related object is present, the object.</p>
|
|
9448
|
+
|
|
9449
|
+
|
|
9450
|
+
<p><span class="doc-section-title">Parameters:</span></p>
|
|
9451
|
+
<table>
|
|
9452
|
+
<thead>
|
|
9453
|
+
<tr>
|
|
9454
|
+
<th>Name</th>
|
|
9455
|
+
<th>Type</th>
|
|
9456
|
+
<th>Description</th>
|
|
9457
|
+
<th>Default</th>
|
|
9458
|
+
</tr>
|
|
9459
|
+
</thead>
|
|
9460
|
+
<tbody>
|
|
9461
|
+
<tr class="doc-section-item">
|
|
9462
|
+
<td><code>viewname</code></td>
|
|
9463
|
+
<td>
|
|
9464
|
+
<code><autoref identifier="str" optional>str</autoref></code>
|
|
9465
|
+
</td>
|
|
9466
|
+
<td>
|
|
9467
|
+
<div class="doc-md-description">
|
|
9468
|
+
<p>The list view name to use for URL resolution, for example <code>"dcim:location_list"</code></p>
|
|
9469
|
+
</div>
|
|
9470
|
+
</td>
|
|
9471
|
+
<td>
|
|
9472
|
+
<em>required</em>
|
|
9473
|
+
</td>
|
|
9474
|
+
</tr>
|
|
9475
|
+
<tr class="doc-section-item">
|
|
9476
|
+
<td><code>url_params</code></td>
|
|
9477
|
+
<td>
|
|
9478
|
+
<code><autoref identifier="dict" optional>dict</autoref></code>
|
|
9479
|
+
</td>
|
|
9480
|
+
<td>
|
|
9481
|
+
<div class="doc-md-description">
|
|
9482
|
+
<p>Query parameters to apply to filter the list URL (e.g. <code>{"vlans": "pk"}</code> will add
|
|
9483
|
+
<code>?vlans=<record.pk></code> to the linked list URL)</p>
|
|
9484
|
+
</div>
|
|
9485
|
+
</td>
|
|
9486
|
+
<td>
|
|
9487
|
+
<code>None</code>
|
|
9488
|
+
</td>
|
|
9489
|
+
</tr>
|
|
9490
|
+
<tr class="doc-section-item">
|
|
9491
|
+
<td><code>view_kwargs</code></td>
|
|
9492
|
+
<td>
|
|
9493
|
+
<code><autoref identifier="dict" optional>dict</autoref></code>
|
|
9494
|
+
</td>
|
|
9495
|
+
<td>
|
|
9496
|
+
<div class="doc-md-description">
|
|
9497
|
+
<p>Additional kwargs to pass to <code>reverse()</code> for list URL resolution. Rarely used.</p>
|
|
9498
|
+
</div>
|
|
9499
|
+
</td>
|
|
9500
|
+
<td>
|
|
9501
|
+
<code>None</code>
|
|
9502
|
+
</td>
|
|
9503
|
+
</tr>
|
|
9504
|
+
<tr class="doc-section-item">
|
|
9505
|
+
<td><code>lookup</code></td>
|
|
9506
|
+
<td>
|
|
9507
|
+
<code><autoref identifier="str" optional>str</autoref></code>
|
|
9508
|
+
</td>
|
|
9509
|
+
<td>
|
|
9510
|
+
<div class="doc-md-description">
|
|
9511
|
+
<p>The field name on the base record that can be used to query the related objects.
|
|
9512
|
+
If not specified, <code>nautobot.core.utils.lookup.get_related_field_for_models()</code> will be called at render time
|
|
9513
|
+
to attempt to intelligently find the appropriate field.
|
|
9514
|
+
TODO: this currently does <em>not</em> support nested lookups via <code>__</code>. That may be solvable in the future.</p>
|
|
9515
|
+
</div>
|
|
9516
|
+
</td>
|
|
9517
|
+
<td>
|
|
9518
|
+
<code>None</code>
|
|
9519
|
+
</td>
|
|
9520
|
+
</tr>
|
|
9521
|
+
<tr class="doc-section-item">
|
|
9522
|
+
<td><code>reverse_lookup</code></td>
|
|
9523
|
+
<td>
|
|
9524
|
+
<code><autoref identifier="str" optional>str</autoref></code>
|
|
9525
|
+
</td>
|
|
9526
|
+
<td>
|
|
9527
|
+
<div class="doc-md-description">
|
|
9528
|
+
<p>The reverse lookup parameter to use to derive the count.
|
|
9529
|
+
If not specified, the first key in <code>url_params</code> will be implicitly used as the <code>reverse_lookup</code> value.</p>
|
|
9530
|
+
</div>
|
|
9531
|
+
</td>
|
|
9532
|
+
<td>
|
|
9533
|
+
<code>None</code>
|
|
9534
|
+
</td>
|
|
9535
|
+
</tr>
|
|
9536
|
+
<tr class="doc-section-item">
|
|
9537
|
+
<td><code>**kwargs</code></td>
|
|
9538
|
+
<td>
|
|
9539
|
+
<code><autoref identifier="dict" optional>dict</autoref></code>
|
|
9540
|
+
</td>
|
|
9541
|
+
<td>
|
|
9542
|
+
<div class="doc-md-description">
|
|
9543
|
+
<p>As the parent Column class.</p>
|
|
9544
|
+
</div>
|
|
9545
|
+
</td>
|
|
9546
|
+
<td>
|
|
9547
|
+
<code>{}</code>
|
|
9548
|
+
</td>
|
|
9549
|
+
</tr>
|
|
9550
|
+
</tbody>
|
|
9551
|
+
</table>
|
|
9552
|
+
|
|
9553
|
+
|
|
9554
|
+
<p><span class="doc-section-title">Examples:</span></p>
|
|
9555
|
+
<div class="highlight"><pre><span></span><code><a id="__codelineno-0-1" name="__codelineno-0-1" href="#__codelineno-0-1"></a><span class="k">class</span> <span class="nc">VLANTable</span><span class="p">(</span><span class="o">...</span><span class="p">,</span> <span class="n">BaseTable</span><span class="p">):</span>
|
|
9556
|
+
<a id="__codelineno-0-2" name="__codelineno-0-2" href="#__codelineno-0-2"></a> <span class="o">...</span>
|
|
9557
|
+
<a id="__codelineno-0-3" name="__codelineno-0-3" href="#__codelineno-0-3"></a> <span class="n">location_count</span> <span class="o">=</span> <span class="n">LinkedCountColumn</span><span class="p">(</span>
|
|
9558
|
+
<a id="__codelineno-0-4" name="__codelineno-0-4" href="#__codelineno-0-4"></a> <span class="c1"># Link for N related locations will be reverse("dcim:location_list") + "?vlans=<record.pk>"</span>
|
|
9559
|
+
<a id="__codelineno-0-5" name="__codelineno-0-5" href="#__codelineno-0-5"></a> <span class="n">viewname</span><span class="o">=</span><span class="s2">"dcim:location_list"</span><span class="p">,</span>
|
|
9560
|
+
<a id="__codelineno-0-6" name="__codelineno-0-6" href="#__codelineno-0-6"></a> <span class="n">url_params</span><span class="o">=</span><span class="p">{</span><span class="s2">"vlans"</span><span class="p">:</span> <span class="s2">"pk"</span><span class="p">},</span>
|
|
9561
|
+
<a id="__codelineno-0-7" name="__codelineno-0-7" href="#__codelineno-0-7"></a> <span class="n">verbose_name</span><span class="o">=</span><span class="s2">"Locations"</span><span class="p">,</span>
|
|
9562
|
+
<a id="__codelineno-0-8" name="__codelineno-0-8" href="#__codelineno-0-8"></a> <span class="p">)</span>
|
|
9563
|
+
</code></pre></div>
|
|
9564
|
+
<div class="highlight"><pre><span></span><code><a id="__codelineno-1-1" name="__codelineno-1-1" href="#__codelineno-1-1"></a><span class="k">class</span> <span class="nc">CloudNetworkTable</span><span class="p">(</span><span class="n">BaseTable</span><span class="p">):</span>
|
|
9565
|
+
<a id="__codelineno-1-2" name="__codelineno-1-2" href="#__codelineno-1-2"></a> <span class="o">...</span>
|
|
9566
|
+
<a id="__codelineno-1-3" name="__codelineno-1-3" href="#__codelineno-1-3"></a> <span class="n">circuit_count</span> <span class="o">=</span> <span class="n">LinkedCountColumn</span><span class="p">(</span>
|
|
9567
|
+
<a id="__codelineno-1-4" name="__codelineno-1-4" href="#__codelineno-1-4"></a> <span class="c1"># Link for N related circuits will be reverse("circuits:circuit_list") + "?cloud_network=<record.name>"</span>
|
|
9568
|
+
<a id="__codelineno-1-5" name="__codelineno-1-5" href="#__codelineno-1-5"></a> <span class="n">viewname</span><span class="o">=</span><span class="s2">"circuits:circuit_list"</span><span class="p">,</span>
|
|
9569
|
+
<a id="__codelineno-1-6" name="__codelineno-1-6" href="#__codelineno-1-6"></a> <span class="n">url_params</span><span class="o">=</span><span class="p">{</span><span class="s2">"cloud_network"</span><span class="p">:</span> <span class="s2">"name"</span><span class="p">},</span>
|
|
9570
|
+
<a id="__codelineno-1-7" name="__codelineno-1-7" href="#__codelineno-1-7"></a> <span class="c1"># We'd like to do the below but this module isn't currently smart enough to build the right Prefetch()</span>
|
|
9571
|
+
<a id="__codelineno-1-8" name="__codelineno-1-8" href="#__codelineno-1-8"></a> <span class="c1"># for a nested lookup:</span>
|
|
9572
|
+
<a id="__codelineno-1-9" name="__codelineno-1-9" href="#__codelineno-1-9"></a> <span class="c1"># lookup="circuit_terminations__circuit",</span>
|
|
9573
|
+
<a id="__codelineno-1-10" name="__codelineno-1-10" href="#__codelineno-1-10"></a> <span class="c1"># For the count, .annotate(circuit_count=count_related(Circuit, "circuit_terminations__cloud_network"))</span>
|
|
9574
|
+
<a id="__codelineno-1-11" name="__codelineno-1-11" href="#__codelineno-1-11"></a> <span class="n">reverse_lookup</span><span class="o">=</span><span class="s2">"circuit_terminations__cloud_network"</span><span class="p">,</span>
|
|
9575
|
+
<a id="__codelineno-1-12" name="__codelineno-1-12" href="#__codelineno-1-12"></a> <span class="n">verbose_name</span><span class="o">=</span><span class="s2">"Circuits"</span><span class="p">,</span>
|
|
9576
|
+
<a id="__codelineno-1-13" name="__codelineno-1-13" href="#__codelineno-1-13"></a> <span class="p">)</span>
|
|
9577
|
+
</code></pre></div>
|
|
9453
9578
|
|
|
9454
9579
|
|
|
9455
9580
|
|